Gluetun network: how container fit reach host
Container wey dey behind Gluetun no get im own interface. Publish ports on Gluetun, then allow only the subnets e need reach outside the VPN tunnel.
Wetin happen when container join gluetun network
A container wey set network_mode: service:gluetun no get network interface of its own. E join gluetun network namespace, so port publishing and firewall rules no longer belong to that container. Instead, dem become properties of the gluetun service. Every answer below come from that one fact.
A network namespace na private copy of network stack wey kernel get: e get im own interfaces, routing table, firewall rules, and listening sockets. Docker dey give each container one by default. When you write network_mode: service:gluetun, Docker skip that step and put the new container inside the namespace wey gluetun already dey own. The container still keep im own filesystem and im own /etc/hosts file. That second one go matter later.
You fit see am directly.
docker inspect -f '{{.HostConfig.NetworkMode}}' qbittorrentThat go print container: followed by the gluetun container ID, where normal container for print bridge. This guide continue from where routing Docker traffic through VPN with gluetun stop: the tunnel dey work, and now nothing fit talk to the container.
Publish the port for gluetun, no be for the application
Leave one ports: block for the service wey set network_mode, and Docker go refuse to create the container:
Error response from daemon: conflicting options: port publishing and the container type network modeThe reason dey direct. To publish a port mean say you add one NAT (network address translation) rule wey forward host port enter the container own network namespace. But this container no get its own network namespace. Move the mapping go the gluetun service. The port number no change, because the application still dey listen 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 for the dependent service no get any use too. A networks: block for there na hard stop. Compose go report say the service declare mutually exclusive network_mode and networks, and e go refuse to load the file at all.
One problem fit show later. Every container for the namespace dey share one port space. So, if two applications both use 8080 by default, dem go collide. The second one wey start go fail with an address already in use error. Change one of dem for its own configuration. For example, change the WEBUI_PORT variable for the LinuxServer qBittorrent image, then publish the new number on gluetun.
Container wey dey behind gluetun fit reach each other how?
Inside the namespace, dem don already share one loopback interface. Container wey dey behind gluetun fit reach im sibling for 127.0.0.1:<port> without Docker network.
From outside the namespace, container no get name. Docker embedded DNS dey resolve service name to the service address for user-defined network, but this container no get address for any network. So normal container like Sonarr no fit reach torrent client for http://qbittorrent:8080. E go reach am for http://gluetun:8080, because socket dey listen inside gluetun namespace, for gluetun address. This one dey surprise people wey know how Docker Compose networks and service names dey work and expect the usual naming to apply. E still work without publishing anything to the host, because both containers dey the same Compose network.
Check DNS before you debug anything else. Gluetun dey run im own resolver and rewrite /etc/resolv.conf for im own container, but /etc/resolv.conf na per-container file, so the one gluetun write no be the one wey your application dey read.
docker exec qbittorrent cat /etc/resolv.confHow you fit reach service wey dey run for Docker host?
Use host.docker.internal. E need two settings for two different places, because na two different things dey broken.
The name come first. /etc/hosts dey per container, so the extra_hosts entry belong for the application container, no be for gluetun.
prowlarr:
network_mode: "service:gluetun"
extra_hosts:
- "host.docker.internal:host-gateway"host-gateway na special value wey Docker dey replace with internal address of the host itself. For plain Linux Docker install, na address of the docker0 bridge, commonly 172.17.0.1. Confirm your own with ip -4 addr show docker0 for the VPS. Docker Desktop dey resolve this name by itself. Na why guides wey dem write for laptop dey skip the extra_hosts line, and the same file go fail for server.
The route come second. Adding the name only tell the container which address to use. The packet still dey leave through gluetun default route, wey be the tunnel, and gluetun firewall dey drop am. The symptom na connection wey hang, then timeout, instead of connection wey dem refuse. Refusal mean say packet reach there and something answer no. Timeout mean say e never reach there.
gluetun:
environment:
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32Then check say the host service really dey listen on that address. PostgreSQL server wey bind only to 127.0.0.1 no fit receive connection from any container, tunnel or not, because 127.0.0.1 inside the namespace na the namespace own loopback. Bind am to 172.17.0.1 instead: e go accept connection from containers, while e remain off the public interface. Verify with ss -lntp | grep 5432 for the host.
Wetin FIREWALL_OUTBOUND_SUBNETS really dey change
gluetun documentation describe am as the subnets wey gluetun and containers wey dey share im network stack get permission to access. E also talk say e involve firewall and routing changes. Both parts dey important. Gluetun add route for each subnet wey you list through Docker bridge gateway, so packets wey go those addresses go comot through eth0 instead of the tunnel. E still open firewall for dem, because gluetun normally dey drop outbound traffic wey no dey go VPN server.
Write the value without space after the commas.
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,192.168.1.0/24,100.64.7.9/32Two things easy to miss. This setting dey apply for the whole namespace, so e affect every container wey dey behind gluetun, no be only the one wey you get for mind. And e only affect outbound traffic: e control connections wey container start. Connections wey enter through published port dey follow another path, so you no need add dem here.
Reach web UI from one Tailscale peer
Tailscale dey give every machine one address for 100.64.0.0/10, wey be the range reserved for carrier grade NAT. The two directions need different work.
Inbound na the simple one. Publishing 8080:8080 on gluetun dey bind that port for all the host addresses, and the host tailscale0 interface na one of dem. So, peer fit open http://<machine-name>:8080 and reach the container. Gluetun no dey involved for that path, because Docker NAT rule dey on the host, outside the namespace.
To make the UI reachable only through the tailnet, bind the published port to the host Tailscale address instead of every address.
ports:
- "100.101.102.103:8080:8080/tcp"Find that address with tailscale ip -4 for the host. Binding dey give stronger control here than firewall rule, because the port no dey open for the public interface at all. E also avoid the problem wey dey happen for Docker publishing ports straight past ufw.
Outbound na where FIREWALL_OUTBOUND_SUBNETS dey return. If container need call one peer, add that peer address, and prefer one /32 for each peer instead of the whole /10. MagicDNS names no go resolve inside the container, because the container no dey use the host resolver. So use the numeric 100.x address, or pin am with one extra_hosts line. The same thing apply when you run your own Tailscale control server with Headscale.
A complete compose file for the common shape
One download client wey dey behind the VPN, two web UIs wey dey answer only for the tailnet, and one container wey dey read PostgreSQL database wey dey run for 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 to understand the pattern, no be the product names. Both UIs dey publish through gluetun and bind to the host tailnet address, so dem dey answer for Tailscale and nowhere else. Na only Prowlarr get the extra_hosts line, because Prowlarr na the container wey resolve host.docker.internal. FIREWALL_OUTBOUND_SUBNETS name two single addresses: the Docker bridge address of the host, so Prowlarr fit open database connection, and one tailnet peer.
The PostgreSQL server no dey inside the file intentionally. E dey run for the VPS as normal system service, listening on 172.17.0.1:5432. Na the same layering as an arr stack for Docker Compose, but the database don move outside Docker.
Keep the WireGuard private key outside the compose file. ${WIREGUARD_PRIVATE_KEY} dey read from one .env file wey dey beside am, as explained for env files and secrets for Docker Compose. The condition: service_healthy clause dey use the healthcheck wey the gluetun image already ships with, so nothing go start until the tunnel report say e don come up. Compose healthchecks explain the general format.
Publishing on every address instead of only the tailnet
Remove the address prefix and the port binds for 0.0.0.0, wey includes the VPS public IP. Do this only behind firewall wey you control, and read the ufw note above first.
ports:
- "8080:8080/tcp"Konfirm say tunnel still dey carry traffic
Run the same request two times: one time from inside the namespace, and one time from the host. Then compare the results.
docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org
curl -s https://api.ipify.orgThe first one suppose print your VPN provider exit address. The second one suppose print the VPS address. If dem match, e mean say container traffic no dey pass through the tunnel. Until you correct this, every other fix for this guide no go matter.
Routing table dey show which traffic dey go through the tunnel and which one no dey go through am.
docker run --rm --network=container:gluetun alpine:3.22 ip route showThe default route suppose point to the tunnel interface, tun0. Under am, you suppose see one route for every entry inside FIREWALL_OUTBOUND_SUBNETS, and each route suppose point to the Docker bridge gateway. Any other route wey dey leave through eth0 na traffic wey dey bypass the VPN.
Gluetun control server dey report the same public IP for port 8000, at /v1/publicip/ip. Recent versions require you to configure authentication for control server routes, so set am up before you depend on am.
One wrong subnet wey create leak
FIREWALL_OUTBOUND_SUBNETS na hole wey you intentionally open for firewall, so the size of the hole na the size of the risk. These four things fit make am too big:
0.0.0.0/0dey send everything outside the tunnel. The two IP checks above go catch this for the first run, because dem go return the same address.- Range wey pass the target size. Opening
10.0.0.0/8to reach one machine for10.0.1.7also opens every address wey torrent peer fit advertise for that range. Write10.0.1.7/32. - Range wey overlap with the tunnel own addresses. gluetun documentation warn say this go make gluetun send VPN traffic through the bridge instead, and that go break port forwarding. Check your
WIREGUARD_ADDRESSESvalue before you open any private range. 100.64.0.0/10for Tailscale. That one open roughly four million addresses so one peer fit reach. List the peers wey you need as/32entries.
Remember say this setting cover the whole namespace. If you open a subnet so indexer fit reach a host service, the same subnet go open for the torrent client wey dey share that namespace. Run the public IP check again after every change to this variable, because na only that test fit show whether the change do wetin you mean.
Wetin dey break when you restart gluetun
gluetun own the namespace, so gluetun lifecycle na the namespace lifecycle. If you start dependent container while gluetun dey down, e go fail immediately:
Error response from daemon: cannot join network of a non running containerRestarting gluetun in place na the quieter failure. The dependent containers go continue to run while dem rebuild the namespace wey dem attach to underneath dem, so docker ps go report say everything dey healthy, but nothing go answer. After any change to gluetun service, recreate the whole group instead of restarting only one part.
docker compose up -d --force-recreateThe same thing apply to image updates. If you pull new gluetun image and recreate only that service, the other ones go still point to namespace wey no longer exist.
FAQ
Why Docker dey talk say "port publishing and the container type network mode"?
Na because a ports: block still dey for service wey also set network_mode: service:gluetun. When you publish port, e add NAT rule wey forward host port enter container own network namespace, but container for this mode no get one. Delete ports: block from that service, then add the same mapping to gluetun service. The port number remain the same, because the application still dey listen for am inside the shared namespace.
How other containers fit reach service wey dey behind gluetun?
Containers wey dey inside the same namespace fit reach each other through 127.0.0.1. Containers wey dey outside am go use gluetun service name, so http://gluetun:8080 go work where http://qbittorrent:8080 no go work. The application container no get address for any Docker network, so the embedded DNS server get nothing to resolve for its name. You no need publish port for this, as long as both containers share one Compose network.
Wetin I suppose put for FIREWALL_OUTBOUND_SUBNETS?
Na only addresses wey container behind gluetun must start connection to, and you suppose write dem as narrowly as possible. One machine na /32. The two common entries na Docker host for 172.17.0.1/32 and one /32 for every Tailscale peer wey you dey call. Never add 0.0.0.0/0, and never add range wey overlap with your VPN own tunnel addresses. Inbound connections to published port no need entry here.
Why container no fit resolve my Tailscale MagicDNS names?
MagicDNS dey work by pointing host resolver to Tailscale DNS server, but the container no dey use host resolver. E dey use wetin e own /etc/resolv.conf talk, and behind gluetun na gluetun DNS setup e dey use. Confirm am 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 for that container.
How I fit confirm say traffic still dey pass through VPN?
Run one request from inside the namespace and run the same request from host, then compare the answers. docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org suppose return your VPN provider exit address, while curl -s https://api.ipify.org for the VPS suppose return the VPS address. If both answers match, e mean say tunnel no dey carry the container traffic. Run this check again after every change to FIREWALL_OUTBOUND_SUBNETS.