Your tunnel container works, docker compose exec vpn curl -s https://ifconfig.me answers with the provider's address, and the browser on your desktop still leaves through your ISP. That gap is expected. A container that runs a VPN client holds a network namespace, routes and a tunnel interface, and nothing that speaks HTTP or SOCKS; publishing a port on it cannot create a listener that was never there.

Where the client runs decides the design. A sibling container can move into the tunnel's namespace, a desktop application cannot, and a router needs routes rather than a proxy field. Get the mapping wrong and the symptom is the same: connection refused, or traffic that quietly takes the old path.

Match the arrangement to where the client runs

Ask one question before editing Compose: can the client be started inside the VPN container's network namespace? Anything that already runs as a Linux container can. A browser, an IDE or a CLI on your desk cannot, because its proxy setting expects a protocol service, not a namespace.

ClientArrangementWhat must already exist
Another Compose serviceShares the vpn namespace via network_mode: service:vpnA working tunnel and correct start order
Host browser, IDE or CLIProxy listener inside the namespace, published to host loopbackA configured SOCKS or HTTP proxy and its access rules
Router, VM or phoneThe container as a layer-3 gatewayForwarding, NAT and a return route

These three do not mix. An HTTP proxy field pointed at the container's bridge address reaches nothing unless a proxy listens there, and a proxy listener does not route a router's traffic. Decide first, then follow the matching section.

Share the tunnel namespace with a sibling container

This is the cheapest option because no proxy protocol is involved. The app joins the vpn service's namespace and inherits its interfaces, routes and port space:

services:
  vpn:
    image: your-vpn-client
    cap_add:
      - NET_ADMIN
    healthcheck:
      test: ["CMD-SHELL", "ip link show tun0 >/dev/null"]
      interval: 10s
      retries: 5
  app:
    image: your-app
    network_mode: service:vpn
    depends_on:
      vpn:
        condition: service_healthy

Three restrictions follow from the namespace belonging to another service. The app gets no networks key of its own, because it has no namespace to attach. Port publication belongs on vpn, since that is where the namespace lives. And recreating vpn destroys the namespace the app joined, so docker compose up -d --force-recreate vpn leaves you to recreate app as well; a plain restart is not always enough. The Compose service reference covers this arrangement and its port-publication restriction.

Do not assume the resolver comes along. /etc/resolv.conf is a per-container file rather than part of the network namespace, so the app can still query the host's resolver while its packets leave through the tunnel. Read it from inside the container and set dns: on the app when private names must resolve through the tunnel.

docker compose exec app ip route
docker compose exec app cat /etc/resolv.conf
docker compose exec app curl -s https://ifconfig.me

If the route list contains the tunnel interface and the address is the provider's, the app is inside the tunnel. If the interface is missing, app started before vpn had a tunnel, so recreate app once vpn reports healthy.

Put a real proxy listener in the namespace for host clients

A desktop browser cannot join a namespace, so the tunnel has to be exposed as a service. Run an actual SOCKS or HTTP proxy inside the vpn namespace and publish only its port:

services:
  vpn:
    ports:
      - "127.0.0.1:1080:1080"
  proxy:
    image: your-proxy
    network_mode: service:vpn
    command: ["--socks", "0.0.0.0:1080"]

The host-side address is the important half of that mapping. 127.0.0.1:1080 on the left keeps the listener off your LAN; a bare "1080:1080" publishes an open proxy to every device that can reach your machine.

The container-side half has its own trap. Published ports are delivered to the container's address on the bridge, not to its loopback, so a proxy started with --socks 127.0.0.1:1080 inside the namespace accepts connections from siblings and refuses everything arriving through the published port. Bind it to 0.0.0.0 and rely on the host-side loopback bind for isolation. The same rule applies to the listen address in a proxy config file.

Then decide where names are resolved. In curl, --socks5 resolves the hostname locally and --socks5-hostname sends the name to the proxy; browsers expose the same switch under a different label. Local resolution leaks the lookup even when the page loads through the tunnel.

Treat the container as a gateway only with a route back

Routers, VMs and phones will not use an HTTP proxy field, and some applications ignore proxy variables entirely. Routing their traffic means layer 3: the container forwards packets and translates addresses on the tunnel interface.

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE

Two things are easy to miss. The client needs a route that points its default gateway at this host, and replies must find their way back, either through a static route on the upstream router or through NAT on the interface facing the clients. Without one of those, the tunnel carries the request out and the reply disappears.

It also has the widest blast radius. A host acting as gateway for the whole LAN sends every misconfigured client through your VPN account, and a tunnel drop either blackholes the LAN or falls back to the ISP depending on the kill switch.

Verify the exit path, then roll back in order

Check in this order and stop at the first failure: exit address inside the namespace, exit address from the host through the proxy, then name resolution for a private host.

docker compose exec vpn curl -s https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 https://ifconfig.me
curl -s -x socks5h://127.0.0.1:1080 http://internal.example/health

The first two addresses should match the provider, and the third should resolve through the proxy instead of failing with a name error. A matching first line and a failing second line means the listener or the published port is wrong, not the tunnel.

Rolling back is the reverse of the setup. Remove the proxy environment variables from the shell profile and from any systemd unit, then bring the stack down so the shared namespace disappears with its owner. Delete the published port before deleting the proxy service, or the host keeps a loopback listener that nothing reads.

Before you move on, confirm four things: the client you care about actually reaches the tunnel, only the namespace owner publishes ports, private names resolve where you intended, and nothing listens on a non-loopback host address. Each of those fails silently in production, and each is a one-line check above.

Barruulee wal qabatan