Gluetun: reach the host and other containers
A container behind Gluetun has no interfaces of its own. Publish its ports on Gluetun, and open only the subnets it must reach outside the tunnel.
What happens when a container joins gluetun's network
A container that sets network_mode: service:gluetun has no network interfaces of its own. It joins gluetun's network namespace, so port publishing and firewall rules stop being properties of that container and become properties of the gluetun service. Every answer below follows from that one fact.
A network namespace is the kernel's private copy of a network stack: its own interfaces, its own routing table, its own firewall rules and its own listening sockets. Docker gives each container one by default. When you write network_mode: service:gluetun, Docker skips that step and puts the new container inside the namespace gluetun already owns. The container keeps its own filesystem and its own /etc/hosts file, and that second one matters later.
You can see it directly.
docker inspect -f '{{.HostConfig.NetworkMode}}' qbittorrentThat prints container: followed by the gluetun container ID, where a normal container would print bridge. This guide picks up where routing Docker traffic through a VPN with gluetun leaves off: the tunnel works, and now nothing can talk to the container.
Publish the port on gluetun, not on the application
Leave a ports: block on the service that sets network_mode and Docker refuses to create the container:
Error response from daemon: conflicting options: port publishing and the container type network modeThe reason is direct. Publishing a port means adding a NAT (network address translation) rule that forwards a host port into a container's own network namespace, and this container does not have one. Move the mapping to the gluetun service. The port number does not change, because the application is still listening on that port inside the shared namespace.
services:
gluetun:
ports:
- "8080:8080/tcp" # qBittorrent web UI
qbittorrent:
network_mode: "service:gluetun"
# no ports: block hereAn expose: block on the dependent service is equally pointless, and a networks: block there is a hard stop: Compose reports that the service declares mutually exclusive network_mode and networks, and refuses to load the file at all.
One consequence bites later. Every container in the namespace shares a single port space, so two applications that both default to 8080 collide, and the second one to start fails with an address already in use error. Change one of them in its own configuration, for example the WEBUI_PORT variable on the LinuxServer qBittorrent image, then publish the new number on gluetun.
How do containers behind gluetun reach each other?
Inside the namespace they already share a loopback interface. A container behind gluetun reaches its sibling at 127.0.0.1:<port> with no Docker network involved.
From outside the namespace, the container has no name. Docker's embedded DNS resolves a service name to that service's address on a user defined network, and this container has no address on any network. So a normal container such as Sonarr does not reach the torrent client at http://qbittorrent:8080. It reaches it at http://gluetun:8080, because the socket is listening in gluetun's namespace, on gluetun's address. That surprises people who know how Docker Compose networks and service names work and expect the usual naming to apply. It also works without publishing anything to the host, since both containers are on the same Compose network.
Check DNS before you debug anything else. Gluetun runs its own resolver and rewrites /etc/resolv.conf in its own container, but /etc/resolv.conf is a per container file, so the one gluetun wrote is not the one your application reads.
docker exec qbittorrent cat /etc/resolv.confHow do I reach a service running on the Docker host?
Use host.docker.internal. It takes two settings in two different places, because two different things are broken.
The name comes first. /etc/hosts is per container, so the extra_hosts entry belongs on the application container, not on gluetun.
prowlarr:
network_mode: "service:gluetun"
extra_hosts:
- "host.docker.internal:host-gateway"host-gateway is a special value that Docker replaces with an internal address of the host itself. On a plain Linux Docker install that is the address of the docker0 bridge, commonly 172.17.0.1. Confirm yours with ip -4 addr show docker0 on the VPS. Docker Desktop resolves this name on its own, which is why guides written on a laptop skip the extra_hosts line and the same file then fails on a server.
The route comes second. Adding the name only tells the container which address to use. The packet still leaves through gluetun's default route, which is the tunnel, and gluetun's firewall drops it. The symptom is a connection that hangs and then times out, rather than one that is refused. A refusal means the packet arrived and something answered no. A timeout means it never arrived.
gluetun:
environment:
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32Then check that the host service is actually listening on that address. A PostgreSQL server bound only to 127.0.0.1 is unreachable from any container, tunnel or not, because 127.0.0.1 inside the namespace is the namespace's own loopback. Bind it to 172.17.0.1 instead: it accepts connections from containers while staying off the public interface. Verify with ss -lntp | grep 5432 on the host.
What FIREWALL_OUTBOUND_SUBNETS actually changes
The gluetun documentation describes it as the comma separated subnets that gluetun and the containers sharing its network stack are allowed to access, and notes that it involves firewall and routing changes. Both halves matter. Gluetun adds a route for each listed subnet through the Docker bridge gateway, so packets for those addresses leave on eth0 instead of the tunnel. It also opens the firewall for them, because gluetun otherwise drops outbound traffic that is not headed for the VPN server.
Write the value with no spaces after the commas.
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,192.168.1.0/24,100.64.7.9/32Two properties are easy to miss. This is a namespace level setting, so it applies to every container behind gluetun, not only to the one you had in mind. And it is outbound only: it governs connections a container starts. Connections that arrive at a published port travel a different path and need no entry here.
Reaching the web UI from a Tailscale peer
Tailscale gives every machine an address in 100.64.0.0/10, the range reserved for carrier grade NAT. The two directions need different work.
Inbound is the simple one. Publishing 8080:8080 on gluetun binds that port on all of the host's addresses, and the host's tailscale0 interface is one of them, so a peer opens http://<machine-name>:8080 and reaches the container. Gluetun plays no part in that path, because Docker's NAT rule sits on the host, outside the namespace.
To make the UI reachable only over the tailnet, bind the published port to the host's Tailscale address rather than to every address.
ports:
- "100.101.102.103:8080:8080/tcp"Find that address with tailscale ip -4 on the host. Binding is a stronger control than a firewall rule here, because the port is never opened on the public interface at all. It also sidesteps the problem in Docker publishing ports straight past ufw.
Outbound is where FIREWALL_OUTBOUND_SUBNETS returns. If a container has to call a peer, add that peer's address, and prefer a /32 per peer over the whole /10. MagicDNS names will not resolve inside the container, because the container does not use the host's resolver, so use the numeric 100.x address or pin it with an extra_hosts line. The same applies when you run your own Tailscale control server with Headscale.
A complete compose file for the common shape
A download client behind the VPN, two web UIs answering only on the tailnet, and one container that reads a PostgreSQL database running on the host.
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- "127.0.0.1:8000:8000/tcp" # gluetun control server, host only
- "100.101.102.103:8080:8080/tcp" # qBittorrent UI, tailnet only
- "100.101.102.103:9696:9696/tcp" # Prowlarr UI, tailnet only
volumes:
- ./gluetun:/gluetun
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
- SERVER_CITIES=Amsterdam
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,100.64.7.9/32
- TZ=Europe/Amsterdam
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- /srv/downloads:/downloads
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
network_mode: "service:gluetun"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- PROWLARR__POSTGRES__HOST=host.docker.internal
- PROWLARR__POSTGRES__PORT=5432
- PROWLARR__POSTGRES__USER=prowlarr
- PROWLARR__POSTGRES__PASSWORD=${PROWLARR_DB_PASSWORD}
- PROWLARR__POSTGRES__MAINDB=prowlarr-main
- PROWLARR__POSTGRES__LOGDB=prowlarr-log
volumes:
- ./prowlarr:/config
depends_on:
gluetun:
condition: service_healthy
restart: unless-stoppedRead the file for the pattern rather than the product names. Both UIs are published on gluetun and bound to the host's tailnet address, so they answer on Tailscale and nowhere else. Only Prowlarr carries the extra_hosts line, because Prowlarr is the container that resolves host.docker.internal. FIREWALL_OUTBOUND_SUBNETS names two single addresses: the Docker bridge address of the host, so Prowlarr can open a database connection, and one tailnet peer.
The PostgreSQL server is deliberately absent from the file. It runs on the VPS as an ordinary system service listening on 172.17.0.1:5432. That is the same layering as an arr stack on Docker Compose, with the database moved outside Docker.
Keep the WireGuard private key out of the compose file. ${WIREGUARD_PRIVATE_KEY} reads from a .env file beside it, the pattern covered in env files and secrets for Docker Compose. The condition: service_healthy clause uses the healthcheck the gluetun image already ships, so nothing starts until the tunnel reports itself up. Compose healthchecks explain the general form.
Publishing on every address instead of only the tailnet
Drop the address prefix and the port binds on 0.0.0.0, which includes the VPS public IP. Do this only behind a firewall you control, and read the ufw note above first.
ports:
- "8080:8080/tcp"Confirm the tunnel is still carrying traffic
Run the same request twice, once from inside the namespace and once from the host, and compare.
docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org
curl -s https://api.ipify.orgThe first should print your VPN provider's exit address. The second should print the VPS address. If they match, the container's traffic is not going through the tunnel, and every fix in this guide is beside the point until that is corrected.
The routing table shows what is off the tunnel and what is not.
docker run --rm --network=container:gluetun alpine:3.22 ip route showThe default route should point at the tunnel interface, tun0. Below it you should see one route per entry in FIREWALL_OUTBOUND_SUBNETS, pointing at the Docker bridge gateway. Any other route leaving through eth0 is traffic that skips the VPN.
Gluetun's control server reports the same public IP on port 8000, at /v1/publicip/ip. Recent versions require you to configure authentication for control server routes, so set that up before depending on it.
The leak one wrong subnet creates
FIREWALL_OUTBOUND_SUBNETS is a hole you punch in the firewall on purpose, so the size of the hole is the size of the risk. Four ways to make it too big:
0.0.0.0/0sends everything outside the tunnel. The two IP checks above catch this on the first run, because they will return the same address.- A range wider than the target. Opening
10.0.0.0/8to reach one machine at10.0.1.7also opens every address a torrent peer might advertise in that range. Write10.0.1.7/32. - A range that overlaps the tunnel's own addresses. The gluetun documentation warns that this makes gluetun send VPN traffic out through the bridge instead, which breaks port forwarding. Check your
WIREGUARD_ADDRESSESvalue before opening any private range. 100.64.0.0/10for Tailscale. That is roughly four million addresses opened so one peer can be reached. List the peers you need as/32entries.
Remember that the setting covers the whole namespace. Opening a subnet so an indexer can reach a host service opens the same subnet for the torrent client sharing that namespace. Re-run the public IP check after every change to this variable, because it is the only test that shows whether the change did what you meant.
What breaks when you restart gluetun
Gluetun owns the namespace, so gluetun's lifecycle is the namespace's lifecycle. Starting a dependent container while gluetun is down fails immediately:
Error response from daemon: cannot join network of a non running containerRestarting gluetun in place is the quieter failure. The dependent containers keep running while the namespace they attached to is rebuilt underneath them, so docker ps reports everything healthy and nothing answers. After any change to the gluetun service, recreate the whole group rather than restarting one part of it.
docker compose up -d --force-recreateThe same applies to image updates. Pulling a new gluetun image and recreating that one service leaves the others pointing at a namespace that no longer exists.
FAQ
Why does Docker say "port publishing and the container type network mode"?
Because a ports: block is still on a service that also sets network_mode: service:gluetun. Publishing a port adds a NAT rule that forwards a host port into a container's own network namespace, and a container in this mode does not have one. Delete the ports: block from that service and add the identical mapping to the gluetun service. The port number stays the same, since the application is still listening on it inside the shared namespace.
How do other containers reach a service that sits behind gluetun?
Containers inside the same namespace reach each other at 127.0.0.1. Containers outside it use the gluetun service name, so http://gluetun:8080 works where http://qbittorrent:8080 does not. The application container holds no address on any Docker network, so the embedded DNS server has nothing to resolve for its name. No port publishing is needed for this, as long as both containers share a Compose network.
What should I put in FIREWALL_OUTBOUND_SUBNETS?
Only the addresses a container behind gluetun must start a connection to, written as narrowly as possible. A single machine is a /32. The two common entries are the Docker host at 172.17.0.1/32 and one /32 for each Tailscale peer you call. Never add 0.0.0.0/0, and never add a range that overlaps your VPN's own tunnel addresses. Inbound connections to a published port need no entry here.
Why can the container not resolve my Tailscale MagicDNS names?
MagicDNS works by pointing the host's resolver at Tailscale's DNS server, and the container does not use the host's resolver. It uses whatever its own /etc/resolv.conf says, which behind gluetun is gluetun's DNS setup. Confirm with docker exec <container> cat /etc/resolv.conf. Use the numeric 100.x address of the peer, or pin the name with an extra_hosts entry on that container.
How do I confirm traffic is still going through the VPN?
Run one request from inside the namespace and the same request from the host, then compare the answers. docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org should return your VPN provider's exit address, while curl -s https://api.ipify.org on the VPS returns the VPS address. Two matching answers mean the tunnel is not carrying the container's traffic. Run this check again after every change to FIREWALL_OUTBOUND_SUBNETS.