One host, two workloads, and a requirement that splits them: a download client that has to emerge from a commercial VPN, and a management interface that has to keep using the host's connection. Installing the VPN client on the host fails the requirement immediately, because both workloads then share one exit. The pattern that does work is to move the tunnel into its own container and let only the workloads that need it join that container's network namespace. Everything else keeps the ordinary Docker path, and the details that follow are consequences of who owns the namespace.

Why a shared namespace changes the rules

A container that joins another's namespace does not get its own network stack. It sees the same interfaces, the same routing table and the same loopback as the container it joined, which is exactly why the pattern is useful: the workload resolves and connects normally, and the tunnel underneath captures the traffic without the application knowing. The cost is that every network-shaped setting now has one owner, and the joining container is not it.

That ownership rule answers most configuration questions before they are asked. If a setting lives in the network stack, it belongs on the sidecar. If it describes the process, it stays with the workload.

Loopback is the part that surprises people. Inside the joining container, localhost resolves to the sidecar's loopback, not to a private one of its own, so a service that binds to 127.0.0.1 expecting isolation is now sharing that address with the tunnel container.

Publish ports on the container that owns the namespace

The port mapping belongs to the namespace owner even when the listening process runs in the other container. Declare the mapping on the sidecar, and let the workload keep its own process configuration untouched. A mapping on the joining service cannot take effect, whether the engine rejects it or quietly ignores it depending on how the container is started, and that is why an interface that was reachable a moment ago disappears once the pattern is applied.

services:
  gluetun:
    image: qmcgaw/gluetun
    cap_add: [NET_ADMIN]
    ports: ["8080:8080"]
  client-vpn:
    image: example/client
    network_mode: "service:gluetun"
  client-direct:
    image: example/client
    ports: ["8081:8080"]

Read that block as three decisions rather than three services. The sidecar owns the tunnel and the published port; the joining client declares no network at all; the direct client keeps a mapping of its own on the default network. The NET_ADMIN capability is what lets the sidecar create interfaces and alter routes, and it is the reason this container needs more privilege than the workloads it serves.

The service that stays direct keeps its own network

The workload that must not be tunneled keeps the default bridge or a user-defined network of its own, with its own published port and its own egress. It is tempting to attach it to the sidecar network so the two containers can talk by name, and that one step silently moves its traffic into the tunnel. If they need to reach each other, build a separate user-defined network, attach both to it, and leave the sidecar as the only outward path.

Keep the two questions apart: which containers can reach each other, and which one exits where. Merging them is the single most common way this design turns back into a host-wide VPN.

A failure matrix for the first hour

Most first attempts fail in one of four ways, and each has a different cause. Check them in this order, because the first two account for nearly every report.

SymptomLikely cause
Web UI unreachable after the changePort mapping declared on the joining container instead of the sidecar
Both containers show the provider's exit addressThe direct service was attached to the sidecar network
Internal names stop resolvingResolver settings live on the host, not in the shared namespace
Health check fails but the app worksThe check probes localhost, which now means the sidecar

The fourth row is the one people misdiagnose as an application bug. A probe that was written against the workload's own loopback is now testing the tunnel container, so it either fails or reports the wrong service entirely.

When a single hostname needs the tunnel

Sometimes a full tunnel is too much: one internal name has to go through a corporate VPN while a local socket or display connection keeps working. A full tunnel inside the container breaks that path and anything addressed to the host, so route only the prefix that needs it. A split-routing wrapper around the client does that, sending the named host through the tunnel and leaving the previous route untouched for everything else.

Choose this when you only need to reach one service and are not trying to move all of the container's traffic. It is a different tool from the sidecar and it answers a different question.

Verify, then make it survive a restart

Ask each container what address it appears to come from. The tunneled one should print the provider's address and the direct one should print the host's exit. Identical answers mean the direct service inherited the wrong namespace or a host-level VPN is still capturing everything, and those two fixes are different. Run the check again after every compose edit, because one copied service block is enough to reintroduce the problem.

Three things tend to break later. DNS follows the namespace owner, so resolver and search domain settings belong on the sidecar rather than the host. A port-forwarded VPN changes its listening port on reconnect, so the provider's forwarded port and the container-side listening port have to be written back into the client configuration or the port mapping, usually by a small updater that reads the provider's API. And any probe written against localhost is now asking the tunnel container rather than the workload.

Write the pattern down, including which container owns the namespace and which ports belong to it. The next person will add a service, copy the wrong block, and reproduce the first failure in this article.

Allakkat attuumassuteqartut