TL;DR: Docker installs an ip filter nftables table with chain FORWARD { policy drop; jump DOCKER-USER; jump DOCKER-FORWARD; }. DOCKER-USER starts empty. Any traffic the kernel forwards that isn’t explicitly Docker’s — including libvirt’s own virbr0 NAT network — hits that DROP policy and disappears with no ICMP error, no log line, nothing. This happens even when firewalld’s libvirt zone has masquerade: yes and forward: yes, and even when libvirt’s own libvirt_network nftables table is fully correct. Fix: sudo iptables -I DOCKER-USER -i virbr0 -j ACCEPT and the same with -o virbr0, which persists across docker restarts because DOCKER-USER is the one chain Docker never rewrites.
A Windows 11 guest on libvirt’s default NAT network gets a DHCP lease, has a working default gateway, and still can’t reach the internet — no route, no reply, nothing in the guest’s own logs to explain it. The instinct is to debug the VM’s network stack or libvirt’s NAT configuration, both of which can be entirely correct. The actual fault sits one layer down, in a second, independent firewall stack that Docker installs on the host and that overrides the kernel’s forwarding decision before libvirt’s own rules are ever consulted.
You’ve done everything the manuals say. virsh net-list shows default active. The guest pulled a lease. ip_forward is 1. dnsmasq is running. Every piece of libvirt’s NAT network looks correct in isolation — and the guest still can’t ping 8.8.8.8.
This post is for anyone running libvirt/KVM (via virt-manager or bare virsh) alongside Docker on a Linux host with firewalld active — the combination on most current Fedora, RHEL, and Debian-derivative desktops. It assumes you already know your way around iptables/nft, DHCP leases, and basic bridge networking, and that the guest OS itself (this post used Windows 11, but the mechanism is guest-agnostic) is not the problem.
Table of Contents
Open Table of Contents
- Why the obvious checks all pass and the VM still can’t reach anything
- What firewalld and libvirt’s own nftables table actually look like when they’re fine
- Finding the actual drop point with tcpdump and nft, not virsh
- The fix, and why DOCKER-USER is the right place for it
- One self-inflicted detour worth flagging: don’t destroy/recreate a running network
- What generalizes beyond this specific pair of tools
- References
Why the obvious checks all pass and the VM still can’t reach anything
Direct answer: Every layer libvirt is directly responsible for — the
defaultNAT network, the DHCP lease, thevirbr0bridge,ip_forward=1, and even a correctly configuredfirewalldlibvirtzone withmasqueradeandforwardboth enabled — can be fully correct while the guest still has zero connectivity. That’s because none of those layers are the layer that’s dropping the traffic. The kernel’sFORWARDhook runs multiple independently registered nftables tables in sequence, and a completely separate table installed by Docker can veto the packet before or regardless of what libvirt’s own table decides. Checking libvirt-side and firewalld-side configuration in isolation will never surface this; you have to look at what the kernel actually does with the packet.
Start with the boring checks, because they rule out the common causes fast and they were genuinely fine here:
$ virsh net-list --all
Name State Autostart Persistent
--------------------------------------------
default active yes yes
$ virsh net-dhcp-leases default
Expiry Time MAC address Protocol IP address Hostname
2026-08-15 20:45:07 52:54:00:82:41:ce ipv4 192.168.122.129/24 DESKTOP-GV9NH7P
$ ip addr show virbr0
54: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... state UP
inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
$ cat /proc/sys/net/ipv4/ip_forward
1
Guest has an address, gateway is up, kernel is configured to forward. On a modern system with firewalld running, the next instinct — checking raw iptables -t nat -L POSTROUTING for a MASQUERADE rule — is a dead end. firewalld on current libvirt (≥ 7.x on Fedora) doesn’t manage NAT through legacy iptables rules directly; it drives an nftables table of its own, and libvirt integrates with it through firewalld’s libvirt zone rather than writing raw iptables. Checking iptables -t nat -L here only shows you Docker’s MASQUERADE rules for docker0 and any custom bridges — nothing for virbr0 — and it’s tempting to conclude libvirt’s NAT rule is simply missing. It isn’t missing; it’s in a different table, addressed a different way.
What firewalld and libvirt’s own nftables table actually look like when they’re fine
Direct answer: libvirt registers its own native nftables table,
libvirt_network, with dedicated chains for NAT (nat_POST_libvirt), forwarding (filter_FWD_libvirt), and DHCP/DNS input rules — all independent of the legacy iptables tables Docker uses. Separately,firewalldexposes alibvirtzone bound tovirbr0that controls whether that zone’s traffic is masqueraded and forwarded at all. Both of these can report a clean bill of health (masquerade: yes,forward: yes, populated NAT/forward chains with nonzero packet counters) while the guest still has no connectivity, because neither of them is the last word on whether the kernel forwards the packet.
sudo nft list ruleset | grep -A5 libvirt shows the real picture:
table ip libvirt_network {
chain forward {
type filter hook forward priority filter; policy accept;
counter packets 2211 bytes 130420 jump guest_cross
counter packets 2211 bytes 130420 jump guest_input
counter packets 2211 bytes 130420 jump guest_output
}
...
chain nat_POST_libvirt { ... }
}
Counters incrementing, policy accept, chains wired up correctly. And firewall-cmd --zone=libvirt --list-all initially showed the real problem on this host — forward: no and masquerade: no, both defaulted off:
$ sudo firewall-cmd --zone=libvirt --add-masquerade --permanent
$ sudo firewall-cmd --zone=libvirt --add-forward --permanent
$ sudo firewall-cmd --reload
That’s a legitimate fix for a legitimate problem — on a fresh Fedora Workstation install with both libvirt and firewalld present, the libvirt zone doesn’t always ship with masquerade and forward pre-enabled, and without them nothing downstream matters. Fixing it is necessary. On this host it was not sufficient — pings from the guest still timed out after reload, which is the tell that a second, independent enforcement point exists downstream.
Finding the actual drop point with tcpdump and nft, not virsh
Direct answer: When every libvirt- and firewalld-facing configuration checks out but packets still vanish, stop trusting configuration output and trust packet capture. Run
tcpdump -i virbr0 -n icmpwhile pinging from the guest: if requests leave the bridge with no replies ever arriving, the drop is happening pastvirbr0, somewhere in the host’s forwarding path. From there,nft list ruleset(notiptables -L, which only shows the legacy-compat view) across all tables — not just the ones you expect — is what surfaces a second table you didn’t know was in the forwarding hook at all.
$ sudo tcpdump -i virbr0 -n icmp -c 4
19:47:04 IP 192.168.122.129 > 8.8.8.8: ICMP echo request, id 1, seq 5
19:47:09 IP 192.168.122.129 > 8.8.8.8: ICMP echo request, id 1, seq 6
Requests leaving, no replies, ever. That rules out the guest’s own network stack — the packet gets off the bridge — and points at the host’s forwarding path. The table that explains it:
$ sudo nft list table ip filter
# 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 485 bytes 32608 jump DOCKER-USER
counter packets 485 bytes 32608 jump DOCKER-FORWARD
}
chain DOCKER-USER {
}
...
}
policy drop. DOCKER-USER is empty — zero rules, so it falls through. DOCKER-FORWARD only carries logic for Docker’s own bridges (docker0, custom networks). Nothing in this table has ever heard of virbr0. This filter table is registered at the same forward hook and the same filter priority as libvirt’s libvirt_network table — both are legitimate, both run, and this one’s default-deny policy is what a virbr0 packet meets on its way out, regardless of what libvirt_network’s own accept policy decided upstream.
This is a documented, long-standing behavior of Docker’s iptables integration, not a bug specific to this host: Docker manages the FORWARD chain’s default policy because it needs to prevent arbitrary host-to-container-network forwarding by default, and it exposes DOCKER-USER specifically as the chain admins are meant to populate with their own forwarding exceptions — it’s the one chain dockerd guarantees it will never flush or rewrite on restart.
The fix, and why DOCKER-USER is the right place for it
Direct answer: Add explicit accept rules for
virbr0traffic toDOCKER-USER, not toFORWARDdirectly and not by trying to change Docker’s defaultFORWARDpolicy back toACCEPT.DOCKER-USERis Docker’s supported extension point for exactly this situation, and rules placed there survivedockerdrestarts,docker networkchanges, and Docker upgrades — rules placed directly inFORWARDor in Docker’s other auto-managed chains will eventually get wiped the next time Docker reconciles its own ruleset.
sudo iptables -I DOCKER-USER -i virbr0 -j ACCEPT
sudo iptables -I DOCKER-USER -o virbr0 -j ACCEPT
Both directions matter: -i virbr0 covers guest-initiated traffic leaving the bridge toward the WAN interface, -o virbr0 covers return traffic being forwarded back in. iptables here is correct rather than nft directly — Docker’s tooling and health checks assume the legacy-compat interface, and mixing native nft edits into a table Docker considers its own tends to get silently reverted on the next dockerd restart, which is exactly the failure mode you’re trying to get out of.
Verify the guest recovers:
C:\> ping 8.8.8.8
Pinging 8.8.8.8 with 32 bytes of data:
Reply from 8.8.8.8: bytes=32 time=9ms TTL=116
These two rules aren’t persistent by default on most distributions — iptables -I writes to the live ruleset only. If you’re on a system without a distro-level persistence mechanism already saving iptables state, add them to a systemd unit or an iptables-restore hook that runs after both docker.service and libvirtd.service are up, since the ordering matters: if DOCKER-USER rules get applied before Docker itself starts and rewrites its chains, they can end up flushed anyway.
One self-inflicted detour worth flagging: don’t destroy/recreate a running network
While chasing the firewalld zone settings, a natural move is to bounce the network to force libvirt to re-apply its rules:
sudo virsh net-destroy default
sudo virsh net-start default
This is fine if the VM is off. If the VM is running, its vnet* TAP interface can end up detached from virbr0 — the bridge comes back up with NO-CARRIER and zero attached ports, which looks identical to a firewall problem (no connectivity, no errors) but has nothing to do with the actual fix. ip link show master virbr0 returning nothing is the tell. Rebooting the guest re-attaches its TAP interface to the bridge and clears it; a live NIC hot-unplug/replug from virsh would do the same without a full reboot, but a reboot is simpler to reason about mid-debugging.
What generalizes beyond this specific pair of tools
Docker and libvirt aren’t unique — they’re just the most common pair of Linux services that both assume they own the FORWARD chain. Any two tools that each register their own nftables table at the forward hook can produce this exact symptom: correct configuration in both tools’ own terms, silent packet loss in the kernel, and no log anywhere pointing at the actual interaction. podman with its own network backend, kubernetes via kube-proxy or cilium, and VPN clients that manage their own forwarding rules (some WireGuard setups, certain corporate VPN agents) all follow the same pattern.
The generalizable debugging move is to stop trusting per-tool status output the moment two such tools coexist on the same host, and go straight to sudo nft list ruleset to read the full, layered picture the kernel actually enforces — every table, every chain, every policy, in hook-priority order — rather than asking firewall-cmd, virsh, or docker individually whether their configuration is correct. Each of them will answer honestly and each answer can still be irrelevant to why the packet died.
References
- Docker documentation: packet filtering and firewalls — Docker’s own account of why it manages
FORWARDand whatDOCKER-USERis for - libvirt network XML format: NAT forward mode — reference for how
defaultNAT networks are defined and what libvirt’s forwarding rules are supposed to do - firewalld rich language and zone reference — for the
libvirtzone’smasquerade/forwardsettings referenced above - nftables wiki: hooks and priorities — explains why two independently registered tables can both run at the
forwardhook and how priority ordering between them actually works