The request arrives addressed to the tunnel IP and leaves the same container addressed to a neighboring service. Between those two moments DNAT rewrote the destination, and the packet stopped being input for the VPN container. It is now forwarded traffic, judged by a different rule. That shift explains why a firewall rule that looks right for the tunnel address has no effect on the translated connection.

Follow the Packet After Translation

Take a client pool of 10.90.0.0/24 and a tunnel address of 10.90.0.1. Two containers sit on one bridge: 192.0.2.2 runs the VPN and 192.0.2.3 runs the application. A client connects to 10.90.0.1:8080, and the DNAT rule in the VPN container's namespace rewrites that to the application's real address and port. A route then sends the packet across the bridge. Those addresses are illustrative, not a deployment recipe.

The port the application actually listens on can differ from any port published on the host. Read the listener inside the container instead of assuming both numbers match.

Permission Belongs on the Forward Path

The VPN container needs IP forwarding and a forwarding decision scoped to the client pool, the application address and the intended port. Established replies need their own allowance. A rule matching the original tunnel address will not match after DNAT, because the destination has already changed. An INPUT allow is the wrong hook for traffic that is no longer addressed to this host.

Keep the scope narrow: one source range, one destination, one service. A permissive forwarding rule is easier to write and much harder to defend later.

docker exec vpn-container ip route get 192.0.2.3
docker exec vpn-container iptables -t nat -S
docker exec vpn-container iptables -S FORWARD

Docker's Chains Are Not Your Container's Chains

Rules inside the container namespace are not the Docker host's rules. When traffic crosses from the tunnel interface to the bridge, the host's bridge firewall can make another decision. With Docker's iptables backend, the documented forwarding chains and post-DNAT matching points are where that decision appears.

The Docker firewall reference describes those chains and the post-DNAT match. Do not fix a missing accept by disabling Docker's firewall management or making every chain permissive, because that removes the boundary you are trying to use.

Make the Return Path a Deliberate Choice

If the source address is preserved, the application sees 10.90.0.x and must route replies back through the VPN container. Its ordinary Docker gateway will otherwise send them to the host, which does not own the tunnel. The alternative is narrowly scoped source translation at the VPN container, which simplifies the reply but hides the client from the application. Choose deliberately and write down which one you chose.

Test a fresh permitted connection from an ordinary client, then confirm unrelated ports stay closed. When a packet disappears, inspect the VPN namespace, the application namespace and the application's own log. A ping to the bridge gateway is not an acceptance test for the service.

Keep the rules in the container's supported startup configuration, because interactive edits vanish when it is recreated. A host port publication is a different exposure path and is not needed just because the service is reachable through the tunnel.

Pennadoù liammet