Your host browser shows the VPN exit address, the container reports your home connection, and someone calls it a leak. Nothing leaked. A host tunnel is a bundle of interfaces, routes and rules that belong to the host network namespace, and a container on a bridge is a different namespace with its own default route. I have sat on a Compose stack where the VPN client had been connected for weeks and not one container packet had ever entered it.

Check the namespace before the tray icon

Namespaces are the whole argument. When Docker starts a container on a bridge network it creates a network namespace, attaches one end of a veth pair inside it, and gives that namespace an address and a default route. The tunnel interface, whatever it is called, lives in the host namespace. Traffic inside the container cannot see it, and no client setting changes that from the outside.

Two supported moves get you out of the isolation. --network host places the container inside the host namespace, so it inherits the host routes and therefore the tunnel. --network container:vpn shares another container's namespace, which is how most sidecar designs send a workload through a VPN container. Both are explicit choices; only one of them is usually what the person deploying meant.

Host mode is not a free upgrade. The container stops having its own address, port publishing stops applying, and a process holding NET_ADMIN there can rewrite host routing. Accepting that can be the right call. Assuming it is already on is how people spend an evening debugging a tunnel that was never in the path.

Follow the packet off the bridge

A bridge container sends its packet to its default gateway, which is the bridge interface on the host. The host then forwards the packet and, in the default configuration, translates the source address to its own before the packet leaves. That translation step is what quietly defeats policy.

By the time the packet reaches the tunnel endpoint it carries the host address. Any rule, policy route or split-tunnel decision keyed on the container's original address stops matching, and two containers meant to leave through different exits cannot be told apart by source alone. If you need per-container routing, the decision has to happen before the translation or inside a namespace you control.

Docker maintains these rules itself, through whichever firewall backend the daemon was configured with. Hand-written rules outside the supported hook for forwarded traffic, DOCKER-USER, can be reordered or dropped when the daemon restarts or the host upgrades. The Docker packet-filtering documentation shows the chain order; the practical point is that every rule needs an owner and a reload path you have actually tested.

Test the two systems separately

Evidence from one namespace says nothing about the other. A request run inside the VPN container proves the tunnel works for that container. It is not evidence about the host, and the same command run from a --network host container is not evidence about the bridge.

# host namespace
ip route get 1.1.1.1
ip -brief addr show

# a fresh container on the same bridge network
docker run --rm --network appnet alpine sh -c 'ip route; cat /etc/resolv.conf; ip -6 route'

Then repeat the test inside the real application container rather than a similar image. The app may carry an extra route, a proxy environment variable or a different resolver, and each of those changes the answer. Check name resolution and the second address family every time: containers on user-defined bridges resolve through Docker's embedded resolver at 127.0.0.11, and an IPv6 default route on the bridge can carry traffic out while you are busy testing an IPv4-only tunnel.

Read the pattern before you touch credentials

When the host works and the container does not, the cause is structural almost every time, and rotating keys or reinstalling the client wastes an afternoon. Sort the symptom first and the fix usually names itself.

What you seeUsual causeFirst check
Host exits through the VPN, container shows the ISP addressThe container never entered the tunnelDefault route inside the container
Container works until the daemon restarts, then stopsRules outside the supported hook, or a changed backendDaemon's reported firewall backend and DOCKER-USER
Container loses internal hosts after the tunnel comes upVPN prefix overlaps the Docker bridge subnetip route on the host, then docker network inspect
Small replies arrive, large ones hangTunnel MTU or missing MSS clampingLink MTU on both ends of the tunnel
Container falls back to the ISP address when the tunnel dropsNothing enforces the kill switch for forwarded trafficStop the tunnel with a request in flight

The overlap row deserves a sentence of its own. A VPN profile that pushes a route for 172.17.0.0/16, or for a 10.0.0.0/8 that happens to contain your bridge prefix, makes the tunnel claim the container subnet, and replies to translated addresses will head in the wrong direction or vanish. Renumbering the Docker network is usually cleaner than accumulating exceptions.

Make coverage explicit, then break it on purpose

Two designs hold up under review. Share the VPN container's namespace and every covered workload uses one tunnel with one restart boundary, which is simple until one service needs a different exit or its own DNS path. Or route the container network to a VPN gateway on the host: forwarding enabled, the bridge prefix pointed at the tunnel, return traffic handled explicitly. That buys per-network control and costs you a rule set to maintain across daemon and client upgrades.

Either way, the test that matters is the failure case. Stop the tunnel with a container request in flight and watch where the packet goes. If it falls back to the direct path, coverage was never enforced, because a client kill switch governs the client's own traffic and not forwarded packets from a bridge. Restart the daemon and the host afterwards and confirm the result a second time; that is the moment hand-written rules disappear.

Before calling it done, confirm four things: the tunnel interface sits in the same namespace as the traffic, the container test ran inside the real container, name resolution and both address families were checked, and the tunnel-failure test still fails closed. Then write down the firewall backend, the client version and the owner of each rule. That record is what keeps the next upgrade from leaving an undocumented mix of old assumptions in place.

ተዛመድቲ ጽሑፋት