Debug · 12 min

VM Has an IP But No Internet: The Culprit Is Docker

My virtual machine picked up an IP address over DHCP and could reach the host, but it could not reach the internet. Network settings, driver, NAT mode; everything was correct. The cause turned out to be a Docker firewall rule. Anyone running KVM/libvirt and virt-manager on the same machine can hit this. I tried the fix everyone recommends online — restarting the virtual network — and it did nothing. The packet counters gave the real answer: Docker's policy drop in the FORWARD chain was discarding my VM's packets before they ever reached NAT. Here is the diagnosis step by step, and a permanent fix with a systemd service.

I have a Windows 10 virtual machine running on KVM/libvirt on my Fedora laptop, and I manage it through virt-manager. For a while now, that VM could not reach the internet.

But it was not the usual “no network” problem. The VM was getting a proper IP address from the network, it could reach the host, and its network adapter reported no errors at all. Even so, it could not load a single website.

Every setting I checked was correct. That was exactly the problem: the settings were correct, but another program was overriding them.

In this post I walk through how I diagnosed the problem, showing every command I used. I also include the diagnosis that turned out to be wrong, and why the fix most commonly recommended online did nothing.

My System / Test Environment#

  • Host: Fedora 44, kernel 7.1.8, KDE Plasma (Wayland)
  • Virtualization: KVM/libvirt, virt-manager 5.1.0
  • Virtual machine: Windows 10, e1000e network card, default virtual network (NAT)
  • Docker: 29.7.2 (13 containers running on the same machine)
  • Firewall: firewalld disabled

That last item matters; I come back to it at the end.

What exactly was the problem?#

The symptoms were:

  • The VM boots and Windows runs without any issue.
  • The network card looks healthy in Device Manager, no yellow warning icon.
  • It gets an automatic IP over DHCP: 192.168.122.129.
  • It can reach the host, that is, the virtual gateway (192.168.122.1).
  • But no website loads and Windows Update does not work.

These symptoms already give away the first clue: getting an IP address proves that the network card and its driver are working. To get an address over DHCP, the VM has to talk to the DHCP server on the host. So looking inside the VM is a waste of time; the problem is in how the host forwards traffic out.

Step 1: Did the VM really get an IP?#

Instead of guessing, I looked for evidence. libvirt keeps a record of the addresses it hands out:

# DHCP records for the virtual network — which machine got which IP?
sudo virsh net-dhcp-leases default

Output:

 Expiry Time           MAC address         Protocol   IP address
 2026-08-18 21:42:35   52:54:00:0f:44:80   ipv4       192.168.122.129/24

There is a record and it is from today. So the VM joined the network, talked to the DHCP server and received its address. This single line confirms three things at once: the network card works, the driver works, and the virtual network’s DHCP server works.

virt-manager showed the same information. The NIC details listed IP address: 192.168.122.129, and the settings were complete: network source default : NAT, device model e1000e, link state active.

Step 2: Ruling out the host#

Next I checked the host, one thing at a time:

# Is the virtual bridge up, and does it have the right IP?
ip -br addr show virbr0

# Is IP forwarding enabled? (should be 1)
cat /proc/sys/net/ipv4/ip_forward

# Is the virtual network active?
sudo virsh net-list --all

# Does the host itself have internet?
ping -c2 1.1.1.1

The results: virbr0 was up with the address 192.168.122.1, forwarding was enabled (1), the default network was active and set to autostart, and the host’s own internet worked fine.

I also checked the log of the virtual network’s DHCP server:

sudo journalctl -b -u virtnetworkd | tail -20
dnsmasq-dhcp[7851]: DHCPACK(virbr0) 192.168.122.129 52:54:00:0f:44:80

The DHCPACK line shows the server acknowledging the address. So the virtual network itself was working correctly too.

At this point the situation was: the VM is fine, the virtual network is fine, the host’s internet is fine. Only one possibility was left: the packets are being dropped inside the host, at the forwarding stage.

Step 3: A wrong diagnosis (an honest note)#

I made a mistake here and lost time. My thinking was: “libvirt’s NAT rule must have been wiped; if I restart the network it will recreate its rules.”

That is also the fix most commonly recommended online:

sudo virsh net-destroy default
sudo virsh net-start default

I tried it, and it did nothing.

Because when I looked at the rules, libvirt’s NAT rule was already there and already correct:

sudo nft list chain ip libvirt_network guest_nat
chain guest_nat {
    type nat hook postrouting priority srcnat; policy accept;
    ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 0 bytes 0 masquerade
}

The rule was correct. There was nothing missing to recreate.

But there was a detail in that output I overlooked, and the real answer was sitting right there: counter packets 0.

The rule was correct, but not a single packet had ever hit it. That does not mean “the rule is broken”; it means “packets are being dropped before they get here.”

That taught me something: a rule existing does not mean the rule is working. You have to look at its counter, not at its presence.

I had made a similar mistake before: while troubleshooting slow WiFi on Fedora, I also tried two wrong fixes before I measured anything.

Step 4: The real cause — Docker’s FORWARD chain#

For a packet to travel from the VM to the internet, it has to pass two stages on the host:

  1. Forwarding permission: “Is this packet allowed to move from one interface to another?”
  2. Address translation (NAT/masquerade): “Replace the source address with the host’s own.”

libvirt’s rule was in the second stage, and its counter was zero. So the packets were not getting past the first stage. I looked there:

sudo nft list table ip filter

And that is where I found the cause:

# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        counter packets 5869 bytes 404873 jump DOCKER-USER
        counter packets 5869 bytes 404873 jump DOCKER-FORWARD
    }

    chain DOCKER-FORWARD {
        iifname "br-e88d4b2aacb7" counter packets 0 bytes 0 accept
        iifname "docker0" counter packets 0 bytes 0 accept
        ...
    }

    chain DOCKER-USER {
    }
}

Four things stand out in this output:

  • policy drop means: “Silently discard every packet that is not explicitly allowed by my list.”
  • 5,869 packets had passed through the FORWARD chain.
  • Every one of Docker’s “accept” rules had a counter of zero.
  • The DOCKER-USER chain was completely empty.

So none of those 5,869 packets were ever accepted; all of them were discarded by policy drop. And those packets were my VM trying to reach the internet.

Docker’s allow list only contains its own networks: docker0 and the container bridges starting with br-. My VM’s bridge, virbr0, is not on that list, and since Docker does not recognise it, it does not let it through.

libvirt was allowing it, so why was that not enough?#

The reason is this: on modern Linux, more than one rule table can attach to the same hook, and each table is evaluated separately.

libvirt was saying “allow this packet” in its own table (ip libvirt_network), while Docker was saying “block it” in its table (ip filter). If one of the tables discards the packet, the other one allowing it changes nothing.

That is why looking only at libvirt’s rules was misleading, and it is exactly why my first diagnosis was wrong.

Step 5: The fix — an exemption in DOCKER-USER#

Docker does one thing nicely here: it reserves an empty chain called DOCKER-USER for the user, and it does not touch that chain when it rebuilds its own rules. So this is precisely the place meant for hand-written rules.

I added two rules:

# Allow traffic leaving the virtual machine
sudo nft insert rule ip filter DOCKER-USER \
  iifname "virbr0" counter accept

# Allow the replies to come back
sudo nft insert rule ip filter DOCKER-USER \
  oifname "virbr0" ct state established,related counter accept

The ct state established,related part in the second rule matters. It means only replies to connections the VM started itself may pass. Random inbound connections from outside are still not allowed in.

Step 6: Making the fix permanent#

The rules above disappear when you reboot, and Docker may also wipe them every time it restarts. For a permanent fix I wrote a small script and a systemd service.

First the script — /usr/local/sbin/libvirt-docker-forward-fix.sh:

#!/usr/bin/env bash
set -u
BR=virbr0

# Wait until Docker has created its chain (up to 30 seconds)
for _ in $(seq 30); do
  nft list chain ip filter DOCKER-USER >/dev/null 2>&1 && break
  sleep 1
done
nft list chain ip filter DOCKER-USER >/dev/null 2>&1 || exit 0

current=$(nft list chain ip filter DOCKER-USER 2>/dev/null)

# Do not add a rule that is already there
case "$current" in
  *"oifname \"$BR\" ct state established,related"*) ;;
  *) nft insert rule ip filter DOCKER-USER \
       oifname "$BR" ct state established,related counter accept ;;
esac
case "$current" in
  *"iifname \"$BR\" counter"*) ;;
  *) nft insert rule ip filter DOCKER-USER \
       iifname "$BR" counter accept ;;
esac

Make it executable:

sudo chmod 755 /usr/local/sbin/libvirt-docker-forward-fix.sh

Then the service file — /etc/systemd/system/libvirt-docker-forward.service:

[Unit]
Description=Exempt libvirt VM traffic from Docker's forward block
After=docker.service virtnetworkd.service
Wants=docker.service
PartOf=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/libvirt-docker-forward-fix.sh

[Install]
WantedBy=multi-user.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now libvirt-docker-forward.service

Two lines here are critical:

  • PartOf=docker.service: every time Docker restarts, this service runs again, so the rules come back on their own. Without it, a single systemctl restart docker brings the problem straight back.
  • After=docker.service: our rule is added after Docker has built its own rules. Otherwise Docker can overwrite it.

I also wrote the script so it never adds the same rule twice, so it can run any number of times without piling up duplicates in the chain.

Step 7: Verification#

After adding the rules I checked the counters again:

sudo nft list chain ip filter DOCKER-USER
chain DOCKER-USER {
    iifname "virbr0" counter packets 111302 bytes 5232457 accept
    oifname "virbr0" ct state established,related counter packets 31452 bytes 175087283 accept
}

More than 111,000 packets in 10 seconds, and 175 MB of data had come down to the VM. Counters climbing that fast showed the traffic was genuinely flowing.

libvirt’s NAT rule had started counting as well:

sudo nft list chain ip libvirt_network guest_nat | grep masquerade
... counter packets 21 bytes 1092 masquerade to :1024-65535

The counter is no longer zero. Packets now get past the forwarding stage, reach NAT, and the VM reaches the internet.

Why this can happen to anyone#

When I looked at the install dates of the packages on my system, I saw that Docker had been updated to 29.7.2 on 8 August 2026:

rpm -qa --qf '%{INSTALLTIME:date}  %{NAME}-%{VERSION}\n' | grep docker

The VM losing its internet started right after that date.

That is what makes this problem hard to spot: everything works one day, and the next day it does not, even though you changed nothing. You go through the VM’s settings from top to bottom, but searching there is pointless, because what changed was an update to an entirely different program.

Anyone running both Docker and KVM/libvirt on the same machine carries this risk. It gets worse when firewalld is disabled: with firewalld running, libvirt uses it as its backend and the two systems work together under one manager. With it off, Docker manages the forwarding rules alone, and the clash surfaces far more easily.

Frequently asked questions#

My VM has an IP but no internet. What should I check first?

Run sudo nft list table ip filter on the host. If you see policy drop in the FORWARD chain and you have Docker installed, that is most likely your cause. For proof, look at the counters: if the packet count in FORWARD is climbing while all of Docker’s accept rules sit at zero, the packets are being discarded by that policy.

Would stopping Docker fix it?

Yes, it would. But stopping Docker also stops your container projects. The exemption rule above is a far more practical way to run both systems side by side.

Does this fix weaken my system’s security?

No. You are only allowing traffic on your VM bridge (virbr0), and in the return direction only for established connections. libvirt’s own filtering rules keep working in their separate table, and your VMs stay behind NAT.

I use VirtualBox. Can this happen to me?

VirtualBox handles NAT internally, so it is generally unaffected by this clash. The problem shows up mainly on KVM/libvirt setups (virt-manager, GNOME Boxes).

Could I just enable firewalld instead?

You could, and on some systems it is the cleaner fix. But if you have Docker projects running, that switch can create new problems with published ports and container networks. I chose the targeted fix so I would not put a working setup at risk.

Conclusion#

The lesson I took from this: a setting looking correct does not mean it is in effect.

libvirt’s rules were correct, but another program was overriding them. Seeing that the rule was in place sent me down the wrong path; the counters gave me the right answer.

If a rule’s counter is zero, that rule is not working, no matter how correctly it was written. With firewall problems, that is the first place to look.

I used the same approach when fixing Fedora freezing under load: looking at logs and numbers instead of guessing found the problem far faster.

Quick check commands (summary)#

# 1) Did the VM get an IP? (if yes, the VM and virtual network are fine)
sudo virsh net-dhcp-leases default

# 2) Is Docker blocking forwarding? ("policy drop" + accepts at zero means yes)
sudo nft list table ip filter

# 3) Is libvirt's NAT rule seeing packets? (zero means they never get there)
sudo nft list chain ip libvirt_network guest_nat

# 4) Apply the fix
sudo nft insert rule ip filter DOCKER-USER iifname "virbr0" counter accept
sudo nft insert rule ip filter DOCKER-USER oifname "virbr0" ct state established,related counter accept

# 5) Confirm it is permanent
systemctl is-enabled libvirt-docker-forward.service

If you have run into the same problem, tell me about it in the comments; mentioning which Docker version you hit it on will help others too.

Comments

Comments use your GitHub account. Clicking “Show” loads Giscus (giscus.app).