The host browses through the tunnel and the container beside it reaches nothing. Publishing a port from a container that runs its own VPN client is worse: the request arrives and the reply never leaves. Both are routing decisions on the host, and Docker is only the messenger.

Three questions decide which of the two you are looking at, and they take about a minute.

Two failures that present the same way

The first is a return-path problem. A published port is translated into the container, the request arrives, and the reply is routed by the host's default route, which the OpenVPN client has pointed at tun0. The reply leaves from an address the remote peer does not expect, and it gets dropped. The second is a prefix collision: Docker's default bridge owns 172.17.0.0/16 and a corporate tunnel pushes the same range.

One is fixed with a routing table, the other by renumbering a bridge. Editing before you know which is which is how a working container gets broken.

Decide with three questions

Question one: does the failure disappear when you stop the VPN? If everything works with the tunnel down, suspect the return path. Question two: does ip route show table all list the same prefix twice, once on a bridge and once on tun0? That is the collision. Question three: is the affected address a container, a NAT guest or a bridged guest?

The third question matters because each has a different correct fix, and applying the container fix to a libvirt guest wastes an evening.

ip route
ip route show table all | grep -E '172\.1[7-9]|10\.8'
docker network inspect bridge --format '{{.IPAM.Config}}'

Return path: a table for one source address

When the container's traffic must leave through the physical interface while the host itself uses the tunnel, source-based routing is the smallest change. Add a rule matching the container's address and send it to a dedicated table. Nothing else on the host moves.

ip rule add from 172.22.0.3 lookup 10
ip route add default via 172.22.0.1 table 10

Every packet whose source is that address consults table 10 first, and table 10 sends it to the bridge gateway instead of the tunnel.

Confirm with ip route get 1.1.1.1 from 172.22.0.3. The answer must name the bridge interface, not tun0. Rules added this way vanish on reboot, so put them in the unit or network hook that owns the container.

The same pattern works for a WireGuard container, where the reply path also needs an explicit rule.

Prefix collision: renumber the bridge

A route table sends one prefix to one place. If the VPN pushes 172.17.0.0/16 while Docker's bridge is already there, the lower metric wins: Docker installs its connected route at metric 0, the pushed route arrives at 50. The tunnel then looks broken while Docker keeps working, or the reverse, depending on installation order. Renumber the bridge rather than fighting the metric.

{
  "bip": "10.200.0.1/24",
  "default-address-pools": [{"base": "10.201.0.0/16", "size": 24}]
}

Restart Docker after the change and recreate networks built with the old pool. Changing the metric instead only relocates the conflict to the next boot.

VMs need a different answer again

A libvirt guest on the default NAT network inherits the host's routing and stops reaching the outside once the host tunnel owns the default route. Switching the guest to a bridged or macvtap device gives it its own path on the physical network. In virt-manager, add hardware, choose Network, set the source to the macvtap host device, keep virtio as the model, then deactivate the old NAT interface.

A macvtap guest usually cannot reach its own host. That is expected behaviour rather than a fault, and it belongs in the note you leave behind.

For a guest that only needs the host's LAN, the vendor client may have the missing switch: with Mullvad, mullvad lan set allow restores local reachability without touching libvirt. And when a container must be reachable only through the tunnel, bind the published port to the tunnel address, -p 10.8.0.5:8080:8080, instead of publishing on every address.

ບົດຄວາມທີ່ກ່ຽວຂ້ອງ