How to Route a Container Through a VPN with Gluetun (Privacy for One App, Not the Whole Server)

Sometimes one app should go out through a commercial VPN — a scraper or changedetection watch (tutorial 135) a site keeps blocking, an RSS fetcher, a download client, anything whose traffic you'd rather not tie to your home IP — while the rest of the server (Pi-hole, backups, Jellyfin) stays on the normal connection. Gluetun is a container that connects to your VPN provider; any other container you attach to it sends all its traffic through that tunnel, with a kill switch so nothing leaks if the VPN drops.
Quick reference
| Task | What |
|---|---|
| Container | qmcgaw/gluetun with NET_ADMIN and /dev/net/tun |
| Provider | VPN_SERVICE_PROVIDER (mullvad, protonvpn, airvpn, nordvpn, surfshark, custom…) |
| Protocol | VPN_TYPE: wireguard (fast) or openvpn |
| Attach an app | network_mode: "service:gluetun" on the app |
| Reach the app's UI | publish its port on gluetun, not the app |
| Check | docker compose exec gluetun wget -qO- https://ipinfo.io |
1. Get your VPN details
From your provider's account page, generate a WireGuard configuration (Mullvad, Proton, AirVPN, Surfshark, NordVPN and most others offer one). You need the private key, the address, and for some providers a preshared key. Gluetun's wiki lists exact variables per provider.
2. The compose file
mkdir -p ~/docker/vpn && cd ~/docker/vpn
nano compose.yaml
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:5001:5000" # ports of ATTACHED apps are published here
environment:
VPN_SERVICE_PROVIDER: mullvad
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: ${WG_PRIVATE_KEY}
WIREGUARD_ADDRESSES: 10.64.0.2/32
SERVER_COUNTRIES: Netherlands
TZ: Europe/London
FIREWALL_OUTBOUND_SUBNETS: 192.168.1.0/24
HEALTH_VPN_DURATION_INITIAL: 30s
restart: unless-stopped
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io:latest
container_name: changedetection-vpn
network_mode: "service:gluetun"
volumes:
- ./cd-data:/datastore
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
The attached app has no ports: and no networks: — it shares Gluetun's network entirely. Its web port is published on the Gluetun service instead. FIREWALL_OUTBOUND_SUBNETS lets the attached app still talk to your LAN (for ntfy at home, say); without it, only the tunnel is allowed.
docker compose up -d
docker compose logs gluetun | tail
"Initialization sequence completed" / "healthy" means the tunnel is up.
3. Prove it
docker compose exec gluetun wget -qO- https://ipinfo.io
docker compose exec changedetection-vpn wget -qO- https://ipinfo.io
Both should show the VPN's address and country, not yours. Then on the host, curl ifconfig.me still shows your home IP — the rest of the server is unaffected.
4. The kill switch
Gluetun's firewall allows traffic only through the tunnel. If the VPN drops, attached containers lose the internet rather than leaking; Gluetun reconnects and they resume. docker compose exec gluetun wget -qO- https://ipinfo.io during a reconnect simply fails, which is the behaviour you want.
5. More than one app
Any number of services can use network_mode: "service:gluetun". Each one's port is published on Gluetun's ports: list. They see each other on localhost (they share a network namespace), so an app and its own database both attached can use localhost:5432.
6. Extras
- Port forwarding from the provider (VPN_PORT_FORWARDING: on) for apps that need an incoming port — Proton and AirVPN support it.
- HTTP proxy (HTTPPROXY: on, port 8888) so unattached containers can opt in per request — changedetection's proxy setting, say.
- Server switching: SERVER_CITIES, SERVER_HOSTNAMES, or UPDATER_PERIOD: 24h to refresh the provider's server list.
- Health: Gluetun exposes a health endpoint; Uptime Kuma (35) can watch http://127.0.0.1:9999 if you publish it.
Troubleshooting
Attached container can't start: "network_mode: service:gluetun" error. Gluetun must be in the same compose file (or use container:gluetun), and it must be healthy first — the depends_on condition handles ordering.
UI port doesn't respond. You published it on the app, not on Gluetun. Move it.
Attached app can't reach ntfy/Pi-hole on the LAN. FIREWALL_OUTBOUND_SUBNETS with your LAN range.
DNS inside attached containers fails. Gluetun uses the provider's DNS over the tunnel by default (DOT: on); if the provider blocks it, set DNS_ADDRESS to 1.1.1.1.
Changed provider details, still the old server. Recreate: docker compose up -d --force-recreate gluetun.
Wrapping up
One Gluetun container with the provider's WireGuard details, network_mode: "service:gluetun" on the apps that need privacy, their ports published on Gluetun. Those apps leave through the VPN with a kill switch; Pi-hole, backups and everything else carry on exactly as before.
