Route Docker containers through a VPN
Put a container behind a Gluetun sidecar and its ports vanish. Here is why the shared network namespace does that, and the compose file that works.
Why the ports disappear when you route Docker containers through a VPN
To route Docker containers through a VPN you give one container the tunnel, then attach the others to its network namespace with network_mode: "service:gluetun". That attachment is the part that surprises people. The attached container no longer has a network of its own, so its published ports and its Docker service name go away with it. Publish the ports on the VPN container instead, and other containers reach the app at the VPN container's name.
Leave a ports: block on the attached container and Docker refuses to create it at all:
Error response from daemon: conflicting options: port publishing and the container type network modeThe tool here is Gluetun, a container that connects to a commercial VPN (virtual private network) provider over WireGuard or OpenVPN and carries its own firewall. Release v3.41.3 is current as of October 2026. The examples use Mullvad with WireGuard, so you need an account and a key from your provider. If you would rather terminate the tunnel on hardware you own, running your own WireGuard server on a VPS builds the other end, and wg-easy in Docker wraps that in a web interface.
What network_mode: "service:gluetun" actually does
Every Docker container normally gets its own network namespace: its own interfaces, routing table, firewall rules and listening sockets. service: mode skips that step and starts the container inside gluetun's namespace. One namespace means one IP address, and that changes six things.
- The app has no address of its own. Its address is gluetun's address.
- The app is attached to no Docker network, so its service name is never registered and never resolves. Other containers must use
gluetun. - Containers inside the namespace reach each other over
localhost. - Two containers in one namespace cannot listen on the same port. The Gluetun documentation is blunt about this: there is no workaround.
- Capabilities belong to a container, not to a namespace. Gluetun holds
NET_ADMINand/dev/net/tunbecause it creates the tunnel interface. The attached container does not inherit them. - Compose rejects any file where one service sets both
network_modeandnetworks. Attach gluetun to your networks, and the app rides along.
Restarting gluetun disconnects everything attached to it. That is documented behaviour, and it is the reason gluetun restarts the VPN process inside the container instead of exiting when the connection fails. After you restart or recreate gluetun yourself, restart the containers attached to it.
The compose file that works
services:
gluetun:
image: qmcgaw/gluetun:v3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- SERVER_CITIES=Amsterdam
- TZ=Europe/Amsterdam
env_file:
- ./gluetun.env
volumes:
- ./gluetun:/gluetun
ports:
- 127.0.0.1:8080:8080/tcp
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- ./downloads:/downloads
depends_on:
gluetun:
condition: service_healthy
restart: unless-stoppedThe :v3 tag is the newest stable release in the v3 series. The :latest tag points at the last commit of the master branch, which is the development edge, so pin :v3 on a machine you do not want to debug on a Tuesday.
WEBUI_PORT=8080 has to match the published port, because qBittorrent binds inside gluetun's namespace and the publish rule sends host traffic to port 8080 there. Change one number without the other and the port answers nothing. 127.0.0.1:8080:8080 keeps the web interface on the host loopback address. A bare 8080:8080 publishes on every interface and writes its own firewall rule, which is how Docker published ports slip straight past ufw.
Bring it up, then check it in this order:
docker compose up -d
docker compose ps
docker compose logs gluetun | tail -30docker compose ps should show gluetun as healthy and qbittorrent as running. Then confirm the exit address from inside the namespace, which is the check that decides everything else:
docker run --rm --network=container:gluetun alpine:3.22 sh -c "apk add wget && wget -qO- https://ipinfo.io"The ip field in that JSON should be your VPN provider's address. If it is your server's own address, the app is not in the tunnel, and nothing below will behave as described.
Keep the keys out of the compose file
gluetun.env holds the credentials, and it stays out of git:
WIREGUARD_PRIVATE_KEY=wOEI9rqqbDwnN8/Bpp22sVz48T71vJ4fYmFWujulwUU=
WIREGUARD_ADDRESSES=10.64.222.21/32Both values come from a WireGuard configuration file you generate in your provider's account area. Set the file to mode 600. Be honest about what this buys you: the key stays out of your repository, but docker inspect gluetun still prints every environment variable to anyone who can reach the Docker socket. Environment files and secrets in Docker Compose covers the stronger options.
How a container outside the tunnel talks to one inside it
Both directions work, and each uses a different name. The two containers need a shared Docker network, which means gluetun's network, since the attached container has none of its own. How Docker Compose networks are wired covers the defaults.
From outside to inside, use gluetun's name and the port the app listens on. A reverse proxy container reaches the qBittorrent web interface at gluetun:8080. No ports: entry is needed for that, because container to container traffic stays on the Docker network and never touches a host port.
From inside to outside, use the other container's service name, postgres:5432 for example. Gluetun has resolved other container names from inside its namespace since v3.41, so pin that version or newer if a name refuses to resolve.
Gluetun's firewall decides who may open a connection to it. Traffic from gluetun's own Docker network is allowed. A client on a different subnet, a laptop on your LAN or a container on a separate bridge network, needs a return path allowed by gluetun's firewall and routing:
FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24The documented meaning is exact: comma separated subnets that Gluetun and the containers sharing its network stack are allowed to access.
Inbound connections from the internet are a separate problem. A torrent client's peers arrive from the VPN side, so publishing port 6881 on the host does nothing for them. You need a forwarded port from your provider, and that port listed in FIREWALL_VPN_INPUT_PORTS, which allows ports from the VPN server side. Mullvad, used in this example, does not support port forwarding, so this requires a provider that does. This is the piece most media stacks built with Docker Compose leave broken.
The kill switch: what happens when the tunnel drops
This pattern earns its complexity on failure. The attached container shares Gluetun's interfaces and routing table. Gluetun's firewall blocks ordinary internet traffic outside the tunnel, while allowing the VPN server endpoint, local Docker traffic and configured exceptions. That firewall is what prevents fallback to the plain interface when the tunnel drops. There is no window where packets leak out of the plain interface while a client reconnects.
Gluetun watches its own connection. Every minute it sends an ICMP echo (a ping) to the addresses in HEALTH_ICMP_TARGET_IPS, which default to 1.1.1.1,8.8.8.8. If ICMP is not allowed, that small check falls back to a plaintext UDP DNS query instead. Every five minutes it makes a full TCP and TLS (transport layer security) dial to HEALTH_TARGET_ADDRESSES, default cloudflare.com:443,github.com:443. When those fail it restarts the VPN inside the container and logs it:
WARN [vpn] restarting VPN because it failed to pass the healthcheck: periodic check: dialing: dial tcp4: lookup cloudflare.com: i/o timeoutRead the attached container's logs with that order in mind. Lines like connection refused, operation not permitted and i/o timeout inside the app are consequences of a dead tunnel, not causes. The Gluetun documentation says this outright, because people report the consequence and chase it for hours.
HEALTH_RESTART_VPN=on is the default and should stay on. Turn it off only while you are debugging one specific failure, because with it off a dead tunnel stays dead.
Ordering: stop the stack starting before the tunnel is up
The image ships a Docker healthcheck:
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheckThat command runs a second, short lived copy of gluetun which queries the health server of the running one at http://127.0.0.1:9999/. A working tunnel answers 200 OK. A broken one answers 500 Internal server error with an error string, and Docker marks the container unhealthy after three consecutive failed checks.
condition: service_healthy is what waits for that. Plain depends_on: [gluetun] waits only for the container to start, which happens several seconds before the handshake completes, so the app starts into a dead network and often gives up on its first connection attempt. Healthchecks in Docker Compose goes through the syntax and the timing fields.
One limit catches people out. Compose evaluates that condition when it starts the stack. It does not stop or restart the app later if gluetun turns unhealthy. Gluetun's internal auto-healing covers that case instead, which is why it restarts the VPN process rather than the container.
Check for a DNS leak before you trust the setup
DNS (domain name system) is the leak that survives a correct tunnel. Gluetun runs its own resolver inside the namespace and forwards queries over DoT (DNS over TLS) to Cloudflare by default: DNS_UPSTREAM_RESOLVER_TYPE=dot and DNS_UPSTREAM_RESOLVERS=cloudflare. Leave both alone and your lookups are encrypted and travel through the tunnel.
The setting that breaks this when pointed at a local resolver is DNS_UPSTREAM_PLAIN_ADDRESSES. People reach for it when a name fails to resolve and they want their router or their provider's resolver to answer instead. The Gluetun documentation warns that queries sent to a resolver on your Docker bridge or LAN leak outside the VPN tunnel. A public plaintext resolver still receives queries through the tunnel, but the VPN provider can read and modify them. The WireGuard version of the same mistake is covered in DNS that stops resolving over a WireGuard tunnel.
To test it, set SHADOWSOCKS=on and SHADOWSOCKS_PASSWORD on gluetun, publish 8388:8388/tcp and 8388:8388/udp, then connect with a Shadowsocks client configured to tunnel UDP DNS and load a DNS leak test. The result should name your provider or Cloudflare, never your home router. Gluetun's own documentation warns that some leak tests report odd results, because the resolver inside the namespace is a local caching intermediary rather than the server that finally answers. Treat a wrong country or your own ISP's resolver as the real signal.
Adding Tailscale next to the VPN sidecar, and which one wins
Tailscale is an overlay network built on WireGuard for reaching your own machines, and people run it beside a provider VPN to keep an admin path into the stack. Sharing a namespace also means sharing firewall rules, so the two need compatible firewall and routing settings. Tailscale's documentation states the default: it acts as an overlay network, it only routes traffic between devices running Tailscale, and it does not touch your public internet traffic.
So the answer depends on one setting.
- Tailscale in its own container, default configuration: it never sees the app's outbound traffic. Gluetun carries all of it. A process in the Tailscale container reaches the app at
gluetun:8080over a shared Docker network; exposing that connection to tailnet clients needs forwarding or a proxy. - Tailscale attached to gluetun's namespace with
network_mode: "service:gluetun": in the default userspace networking mode,TS_USERSPACEis on, tailscaled creates no interface at all and works as a SOCKS5 or HTTP proxy, so it cannot change routing. Gluetun still carries ordinary outbound traffic unless an app explicitly uses that proxy. - The same, with
TS_USERSPACE=false: Tailscale needs its own/dev/net/tundevice and capabilities because those do not come with the namespace. Tailscaled creates a tunnel device and, on Linux, normally installs individual IPv4 peer /32 routes rather than one route for100.64.0.0/10. SettingTS_USERSPACE=falsealone does not enable the aggregate route. It installs other nodes' subnet routes only if you pass--accept-routesthroughTS_EXTRA_ARGS.TS_ROUTESworks in the other direction: it advertises subnets this container can reach to the rest of your tailnet, the equivalent oftailscale set --advertise-routes=, and adds no routes here. Public traffic still leaves through gluetun. - Kernel networking in gluetun's namespace with an exit node selected,
sudo tailscale set --exit-node=<exit-node-ip>: Tailscale installs default routes in that namespace, but Gluetun's firewall can block that path. Selecting an exit node in a separate container does not change the app's routes, and userspace mode only carries traffic explicitly sent through its proxy. Do not combine competing default routes in gluetun's namespace. One default route, one owner.
If those advertised routes are the point, and you want the whole private network behind the box reachable rather than the box alone, running a Tailscale subnet router on a VPS covers the route approval, the IP forwarding and the client side flag that TS_ROUTES on its own leaves undone.
If Tailscale is there to give you an admin URL rather than a route, tailscale serve and tailscale funnel put HTTPS in front of a local HTTP service, with only funnel opening it to the public internet. In Tailscale v1.104.5, their HTTP reverse proxies accept http://gluetun:8080 directly when the Tailscale container can reach it over a shared Docker network. Include the http:// scheme for a non-localhost target. Sharing gluetun's network namespace and targeting http://127.0.0.1:8080 also works.
One side effect is visible when Tailscale runs inside the tunnel. Its peers see the VPN provider's address, so expect it to fall back to relays more often. tailscale status shows relay "..." beside a peer instead of direct when that has happened. The connection works and it is slower. If the overlay is the only thing you actually needed, the difference between plain WireGuard and Tailscale is the better starting point.
What breaks, and the message you will see
Docker refuses to create the app container. Error response from daemon: conflicting options: port publishing and the container type network mode means a ports: block is still sitting on the attached service. Move it to gluetun.
Compose refuses the whole file. A service cannot set both network_mode and networks. Put the networks on gluetun.
Another container cannot resolve the app. curl: (6) Could not resolve host: qbittorrent is correct behaviour, because the attached container joined no network and registered no name. Use gluetun and the port.
The second attached container will not start. Two processes in one namespace cannot bind the same port, and the loser reports that the address is already in use. Change the app's internal port, or run a second gluetun.
The app has no network after you touched gluetun. Restarting or recreating gluetun drops connectivity for everything attached to it. Restart those containers.
Small pages load and large ones hang. That is MTU (maximum transmission unit). The tunnel adds overhead, and something in the path drops the oversized packets without sending an error back. Check WIREGUARD_MTU, whose default in v3.41.3 is 1320, and lower it if the path requires smaller packets. The development image uses path MTU discovery when this setting is unset.
Gluetun never becomes healthy. The startup check names the first suspects: WARN [vpn] restarting VPN because it failed to pass the healthcheck: startup check: dialing: dial tcp4: lookup cloudflare.com: i/o timeout. Check whether the key has expired, then whether the server list is stale, then whether your host firewall blocks outbound UDP.
FAQ
Why did my container's published ports stop working behind Gluetun?
Because network_mode: "service:gluetun" puts the container inside gluetun's network namespace, and a namespace has one IP address and one set of listening ports. The app keeps listening, but the publish rule has to live on the container that owns the namespace. Move the ports: list to the gluetun service. If you left it on the attached service, Docker will not even create it: Error response from daemon: conflicting options: port publishing and the container type network mode.
How do I reach a container inside the VPN tunnel from a container outside it?
Use gluetun's service name and the port the app listens on, for example gluetun:8080. The attached container is on no Docker network of its own, so its own name never resolves. Nothing needs publishing for container to container traffic. Going the other way, a container inside the namespace reaches an outside container by its service name, such as postgres:5432, on Gluetun v3.41 and newer. A client on a different subnet, such as a laptop on your LAN, needs a return path allowed by gluetun's firewall and routing; add that subnet to FIREWALL_OUTBOUND_SUBNETS.
Does Gluetun work as a kill switch when the VPN drops?
Yes, with its firewall enabled. The attached container shares Gluetun's interfaces and routes, and the firewall blocks ordinary internet traffic outside the tunnel while allowing the VPN server endpoint, local Docker traffic and configured exceptions. Gluetun then restarts the VPN internally, logging WARN [vpn] restarting VPN because it failed to pass the healthcheck, rather than exiting, because every attached container loses its network when gluetun itself restarts.
Tailscale and Gluetun in the same stack: which one carries outbound traffic?
Gluetun carries ordinary outbound traffic unless you explicitly send it through Tailscale. Tailscale only routes traffic between devices in your tailnet by default and leaves public traffic alone. In the container image's default userspace mode it creates no interface at all, so it cannot affect routing. With TS_USERSPACE=false, Linux normally gets individual IPv4 peer /32 routes rather than an aggregate 100.64.0.0/10 route, plus other nodes' subnet routes only if you add --accept-routes through TS_EXTRA_ARGS. TS_ROUTES advertises this container's subnets to your tailnet and adds no local routes. Selecting an exit node with sudo tailscale set --exit-node=<exit-node-ip> installs default routes only in the namespace using Tailscale's kernel networking; a separate container does not change the app's routes, and userspace mode only carries traffic sent through its proxy. Choose one product to own the default route rather than stacking both.