The container's packet never reaches tun0, and extra capabilities on the container will not change that. Two route lookups stand between the guest and the tunnel: one inside the guest's network namespace, one on the host after the frame lands on the bridge, and the second consults a different table with different match rules. My reference layout is a Linux host running an OpenVPN client that holds 10.8.0.10 on tun0, a Docker bridge on 172.18.0.0/16, and a private address that only the tunnel side can reach. The goal stays narrow: that guest subnet leaves through the tunnel while the host keeps its ordinary uplink.

Running the VPN client inside the container is a different design, not a shortcut. The container then owns its tunnel and credentials, and the work moves into a Dockerfile instead of staying in iptables.

Forwarding picks a route by destination, then NAT rewrites the source

With net.ipv4.ip_forward=1 set, a forwarded packet is routed by destination only, in the main table. The source plays no part in that decision. Only after the route is chosen does the kernel run POSTROUTING, and that is where Docker's own rule sits: -s 172.18.0.0/16 ! -o br-appnet -j MASQUERADE. Anything leaving the bridge for another interface gets translated, and the address it becomes depends on the interface the route sent it to.

That rule explains most of the symptoms I get asked about. If the main table has no route for the private destination, the packet exits the uplink carrying the host's physical address. Your provider drops the destination, or the remote site drops an RFC 1918 source it never expected, and the container times out while the tunnel stays up and healthy. A capture on tun0 shows nothing, which is the tell. If the route does point at tun0, MASQUERADE rewrites the source to 10.8.0.10 and the same container works without you touching anything.

Ask for the lookup that names the guest source. Without from and iif the host answers for its own socket and reports a path the forwarded packet will never take:

ip route get 192.168.11.222 from 172.18.0.5 iif br-appnet

If that reply names the uplink, the tunnel is not in the picture and no tunnel-side setting will help.

Translate the guest onto the tunnel's own address

The smallest working change is an explicit source rewrite on the way out. Insert it rather than append it: Docker already sits at the head of POSTROUTING, so a rule added with -A evaluates behind Docker's and never fires.

iptables -t nat -I POSTROUTING 1 -s 172.18.0.0/16 -o tun0 -j SNAT --to-source 10.8.0.10

Two conditions decide whether that holds. The address must be one the client really holds, so pin it with a per-client entry containing ifconfig-push 10.8.0.10 255.255.255.0; a rule naming an address that was never assigned produces silent drops, not an error. And the far side must accept that source and route replies back, which is why an allowlist there names the tunnel address rather than the guest subnet. When the client address is dynamic, -j MASQUERADE with -o tun0 follows whatever the interface holds, at the cost of remote policy you cannot write against a fixed value.

Filtering is the other half. Docker sets the FORWARD policy to drop and flushes the chains it owns at every daemon restart, so put local policy in DOCKER-USER, which survives:

iptables -I DOCKER-USER -i br-appnet -o tun0 -j ACCEPT
iptables -I DOCKER-USER -i tun0 -o br-appnet -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

The return rule matters: a reply arriving on tun0 for a bridge-opened connection is not accepted just because the outbound direction was.

Keep the host direct with a source rule

When the whole bridge should use the tunnel but nothing else on the host should, skip NAT and give that source its own table. Guest addresses then stay visible end to end.

echo "200 vpnbox" >> /etc/iproute2/rt_tables
ip rule add from 172.18.0.0/16 lookup vpnbox pref 1000
ip route add default dev tun0 table vpnbox

Rules are consulted before the main table, and from matches the source. The tunnel's own outer packets carry the host's physical source, so they never match from 172.18.0.0/16 and the tunnel cannot route itself back into itself. That self-exclusion is what makes the layout stable.

The reply path now needs help. The remote end sees an untranslated 172.18.0.x and has no reason to know where it lives. On the server, a per-client file needs iroute 172.18.0.0 255.255.0.0 so the server's internal table hands that prefix to this client, and a pushed route 172.18.0.0 255.255.0.0 so other clients know it exists.

Two checks before trusting it. Run ip route show default: with redirect-gateway def1 the table holds 0.0.0.0/1 and 128.0.0.0/1 via tun0, and the host is not direct at all, because those half-defaults win without replacing default. Rules added with ip rule add also vanish on reboot, so carry them in an up script.

Packet size is the failure you meet second

A bridge interface at 1500 bytes hands the tunnel more than it can carry once the outer UDP and IP headers are added. Small exchanges pass, so DNS resolves and the TLS handshake completes, then a larger upload hangs with no error anywhere. The sender keeps retrying the same segment size because the ICMP fragmentation-needed message that would tell it to shrink never arrives.

Fix it at the interface, at the MSS, or both. Clamping needs no cooperation from the guest:

ip link set dev tun0 mtu 1400
iptables -t mangle -A FORWARD -o tun0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

The clamp only rewrites TCP. UDP, QUIC included, needs the guest interface lowered instead, with --opt com.docker.network.driver.mtu=1400 at network creation or the matching compose key.

DNS and the checks worth repeating

Name resolution follows the same path. Docker's embedded resolver at 127.0.0.11 forwards to the host's configured servers, so a container inherits whatever the host uses. When the private zone is answered only by a resolver reachable through the tunnel, point the guest at that address, and test with getent hosts inside the container rather than on the host.

  • Run the failing request inside the guest; a host-side test uses a different source address and hides the problem.
  • Read the counters on the NAT and filter rules you added. A rule with zero matches is a routing mistake, not a policy mistake.
  • Confirm the tunnel address in your SNAT rule is one the client actually holds.
  • Lower the guest MTU or clamp MSS before blaming the server for a stall.

Work down that list and stop at the first failure. That layer is the one to fix; anything added further down only hides the boundary that is breaking.

Pennadoù liammet