I spent most of a morning this spring chasing a container that could not resolve a single internal name while the browser next to it opened the wiki without complaint. The tunnel was up, the browser proved it, and the container sat in a different network namespace with a different route table and a different resolver. What follows is the order I check now: routing tables first, resolution second, forwarding third, and the host platform last. Stop at the first step that already fails, because adding rules further down the stack hides the boundary instead of moving it.

Compare the three route tables before you change a rule

A container on Docker's default bridge has one default route pointing at the docker0 gateway, and no idea that tun0 exists. The host has tun0, the routes the VPN pushed, and possibly a policy rule that sends probe traffic out a different interface than you expect. WSL adds a third table behind its own virtual adapter. Print all three before editing any of them.

ContextCommandWhat a wrong answer looks like
Hostip route get 10.8.0.20Leaves a physical interface instead of tun0
Containerip routeOnly 172.17.0.0/16 via 172.17.0.1
Container DNScat /etc/resolv.confA public resolver, or an upstream the tunnel never pushed
WSLip addr; ip routeOnly the WSL subnet, no Windows VPN adapter

If the container's only route is the bridge and tun0 never appears in its namespace, nothing below will help: no nameserver change creates a route, and no route creates a tunnel identity the peer will accept. Order matters because a DNS failure and a route failure look identical from inside a shell.

Point the resolver at the tunnel instead of a public one

On the host, resolvectl status lists DNS servers per link, and readlink -f /etc/resolv.conf tells you whether the file is the systemd stub, a NetworkManager file, or something a vendor client rewrote. Inside a container on a user-defined network, /etc/resolv.conf names 127.0.0.11, Docker's embedded resolver, which forwards to whatever upstream the daemon was configured with. A container on the default bridge instead got a copy of the host file when it was created, and keeps it.

That copy is why a tunnel that comes up after the container starts leaves the container querying the old upstream. Either pass the VPN resolver explicitly with docker run --dns 10.8.0.53, or set "dns": ["10.8.0.53"] in /etc/docker/daemon.json and restart the daemon, or recreate the container once the tunnel is up. Recreating is usually cheaper than debugging a resolver that was correct an hour ago.

Swapping in a public resolver is the repair that keeps the symptom. It resolves the internet and fails every private zone, and it can quietly bypass the filtering the tunnel exists to enforce. If you try to write inside a running container's /etc/resolv.conf, expect Device or resource busy: Docker bind-mounts that file, so the edit needs an unmount first. Setting --dns at creation is the sequence that survives a restart.

Forward and translate when containers borrow the tunnel

With the default bridge, the host is a router between docker0 and tun0, and it does neither job by default. Turn on forwarding, and translate the source address so the peer sees a host tunnel address rather than one it has no route back to (replace tun0 with whatever your client names its interface):

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
iptables -A FORWARD -i docker0 -o tun0 -j ACCEPT
iptables -A FORWARD -i tun0 -o docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

The FORWARD rules matter on hosts whose forward policy is DROP. Docker inserts its own chains, so test with a packet rather than by reading iptables -S: from the container, ip route get 10.8.0.20 should name the bridge gateway, and nc -vz 10.8.0.20 443 should connect or time out at the tunnel rather than at the resolver.

Without MASQUERADE the peer needs a return route to 172.17.0.0/16, which most remote-access VPNs will not push. This is also where a policy-based client breaks the plan: if it only captures traffic from specific UIDs or cgroups, your forward rules never see the packet inside the tunnel, and the fix belongs in the client's routing policy rather than in iptables. Rules added by hand vanish on reboot, so put them in a systemd unit or a netfilter-persistent table and re-test after a reboot, since a tunnel that comes up late can flush them.

Put the tunnel inside the container when isolation is the goal

If one container has to exit through a specific node, run the client in that container. It needs --cap-add NET_ADMIN and --device /dev/net/tun, and then it owns its own tun0, routes, and firewall. If other containers pass through it, it also needs forwarding and a MASQUERADE rule inside the container, and those peers need an explicit route or proxy pointing at it; Docker's default bridge does not add that route for you.

Host networking, docker run --network host, is the cheap version: the container joins the host namespace and sees the tunnel routes directly. It also drops network isolation, invites port collisions, and is a Linux option in practice. A published port is not a substitute. -p 8080:80 makes a service reachable from the host; it does not make internal 10.x addresses reachable from inside the container, and conflating the two is how a routing problem turns into an exposed database port.

Tell a name failure apart from an overlapping subnet

Docker's default bridge is 172.17.0.0/16, and plenty of corporate VPN pools sit inside 172.16.0.0/12. Once the tunnel's owns that range, traffic meant for a container can be routed into the tunnel instead. Names fail, the error looks like DNS, and the addresses are the problem. Check the destination, not the resolver: ip route get 172.17.0.5 on the host, then compare the bridge prefix with the VPN pool and with docker network inspect bridge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'.

The repair is to move one of the ranges, either a different default address pool for Docker in daemon.json or a different pool on the concentrator. Adding another nameserver does nothing here, and neither does ssh -b: binding the client to an interface changes the source address, not a destination route that points into the tunnel. Quick division of labour: a name that resolves on the host but not in the container is a resolver problem, while a direct IP connection that hangs in the container but works on the host is a route or forward-chain problem.

WSL and Docker Desktop do not follow the Linux rules

In its default NAT mode, WSL sits behind its own virtual adapter and does not receive the Windows VPN's internal routes or DNS suffixes, which is why the browser works and ping inside the distribution does not. Mirrored networking, available on recent Windows 11 builds, shares the host interfaces instead, but it has to be enabled in .wslconfig and the distribution restarted with wsl --shutdown. Confirm the active mode with wslinfo --networking-mode before applying a fix, because the NAT answer is wrong for mirrored mode and the reverse.

Docker Desktop adds another layer. Linux containers on Windows sit behind a NAT implementation historically called vpnkit, and their path differs from a native Linux host. Windows containers attach through a Hyper-V virtual switch, and a corporate client can block traffic from that switch, so Linux containers on the same machine may reach the network while Windows containers have none. An external switch bound to the physical adapter is the usual workaround, and it changes the container's network identity in ways policy may not allow.

Before you write another rule, walk the four layers in order and stop where one of them is wrong:

  • Inside the container: does ip route reach the private prefix, and does /etc/resolv.conf name an upstream that resolves it?
  • On the host: is forwarding on, is NAT bound to the tunnel interface, and does the forward chain accept bridge to tunnel?
  • On the peer: does it know a return path, or are you relying on NAT to hide the container subnet?
  • On the platform: is WSL in NAT or mirrored mode, and is the container Linux or Windows?

Re-run every test in a fresh container or shell. A long-running process with a cached answer or an established connection will keep failing after the configuration is correct, and that failure looks exactly like a fix that did not work.

Erthyglow kevrennek