SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

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 mode

The 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 August 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_ADMIN and /dev/net/tun because it creates the tunnel interface. The attached container does not inherit them.
  • Compose rejects any file where one service sets both network_mode and networks. 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-stopped

The :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 -30

docker 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/32

Both 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, is dropped until you name that subnet:

FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24

The 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. 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 has no second route. Its only path off the machine is the namespace it shares, so when the tunnel is down there is nothing to fall back to. Gluetun's firewall enforces the same rule from the other side: outbound traffic goes through the tunnel or to the VPN server endpoint, and everything else is dropped. 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. 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 timeout

Read 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=1 CMD /gluetun-entrypoint healthcheck

That 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 the container is marked unhealthy after a single failure.

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 once, when it creates the container. 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 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 states the cost plainly: all DNS traffic will not go through the VPN tunnel and will leak out of it. Your traffic stays private. Your list of hostnames does not. The WireGuard version of the same mistake is covered in DNS that stops resolving over a WireGuard tunnel.

To test it, set HTTPPROXY=on on gluetun and publish 8888:8888/tcp, then point a browser at that proxy 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. The two rarely fight, for a reason worth understanding. 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. Tailscale reaches the app at gluetun:8080, exactly like any other outside container.
  • Tailscale attached to gluetun's namespace with network_mode: "service:gluetun": it needs its own cap_add of net_admin and net_raw, because capabilities do not come with the namespace. In the default userspace networking mode, TS_USERSPACE is on, tailscaled creates no interface at all and works as a SOCKS5 or HTTP proxy, so it cannot change routing. Gluetun still carries everything.
  • The same, with TS_USERSPACE=false: tailscaled creates a tunnel device and installs routes, but only for the tailnet range 100.64.0.0/10 plus any subnet routes you advertise with TS_ROUTES. Public traffic still leaves through gluetun.
  • Any of the above with an exit node selected, sudo tailscale set --exit-node=<exit-node-ip>: Tailscale claims the default route and wins. Do not combine that with gluetun. One default route, one owner.

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. Lower WIREGUARD_MTU, try 1400, then 1320.

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, is dropped by gluetun's firewall until you add that subnet to FIREWALL_OUTBOUND_SUBNETS.

Does Gluetun work as a kill switch when the VPN drops?

Yes, and for two reasons at once. The attached container has no route except the one in the shared namespace, so a dead tunnel leaves it with no path off the machine. Gluetun's firewall also allows outbound traffic only through the tunnel and to the VPN server endpoint. 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, in every configuration except one. 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 it installs routes for 100.64.0.0/10 and your advertised subnets only. The exception is an exit node: sudo tailscale set --exit-node=<exit-node-ip> makes Tailscale the default route, and then it wins. Choose one product to own the default route rather than stacking both.