The Problem With “VPN Everything”
You’ve got a home server. You’ve got a Mullvad subscription. And you’ve got that nagging thought: maybe I should run my torrents through the VPN.
So you route everything through Mullvad. Now Plex is buffering because it’s pulling transcoding sessions through a VPN tunnel. Jellyfin can’t phone home for metadata. Your Immich sync crawls. Your Nextcloud app shows a foreign IP and your phone thinks you’re in Amsterdam.
Congratulations, you’ve created more problems than you solved.
The real goal is surgical: qBittorrent’s traffic through Mullvad, everything else through your home WAN. Sounds simple. Then you open the iptables documentation and suddenly it’s 2 AM and nothing works and you’ve banned your own LAN.
Enter Gluetun.
What Gluetun Actually Does
Gluetun (qmcgaw/gluetun) is a VPN client packed into a container. It creates a WireGuard or OpenVPN tunnel to your VPN provider — Mullvad, ProtonVPN, IVPN, PIA, and a dozen others. Then other containers can join Gluetun’s network namespace instead of the host’s.
That one sentence is the whole trick.
When a container declares network_mode: "service:gluetun", it stops having its own network stack and borrows Gluetun’s. All its traffic goes through the tunnel. The container doesn’t know or care — it just sends packets and they exit through whatever interface Gluetun is using.
The architecture looks like this:
[qBittorrent] ──────┐[Prowlarr] ──────┼──▶ [Gluetun container] ──▶ Mullvad VPN ──▶ Internet[FlareSolverr] ─────┘ ↑ kill switch lives here
[Plex] ──────────────────────────────────▶ Home WAN ──▶ Internet[Jellyfin] ──────────────────────────────────▶ Home WAN ──▶ Internet[Nextcloud] ──────────────────────────────────▶ Home WAN ──▶ InternetGluetun handles the tunnel, the kill switch, the DNS leak protection, and the port exposure back to your LAN. The containers behind it are completely isolated from the real internet if the VPN drops.
Getting Mullvad Credentials
Mullvad uses WireGuard by default. You don’t get a username/password — you get a private key tied to your account.
- Log in at mullvad.net
- Go to WireGuard configuration and generate a new key pair
- Download the config for any server — you only need the
PrivateKeyvalue from the[Interface]section
Keep that private key somewhere safe. You’re putting it in an environment variable.
The docker-compose Setup
Here’s a real, working Compose file for Gluetun + qBittorrent, with Prowlarr and FlareSolverr also routing through the VPN:
services:
gluetun: image: qmcgaw/gluetun:v3 container_name: gluetun cap_add: - NET_ADMIN devices: - /dev/net/tun:/dev/net/tun ports: # qBittorrent WebUI (exposed via gluetun) - "8080:8080" # Prowlarr (exposed via gluetun) - "9696:9696" # FlareSolverr (exposed via gluetun) - "8191:8191" # Gluetun control server - "8000:8000" environment: - VPN_SERVICE_PROVIDER=mullvad - VPN_TYPE=wireguard - WIREGUARD_PRIVATE_KEY=${MULLVAD_PRIVATE_KEY} - WIREGUARD_ADDRESSES=10.68.x.x/32 # from your Mullvad config - SERVER_COUNTRIES=Netherlands # Allow LAN access back through the tunnel - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 # DNS leak protection (Mullvad's own DoT servers) - DOT_PROVIDERS=mullvad - FIREWALL=on # Control server for health checks - HTTP_CONTROL_SERVER_ADDRESS=:8000 volumes: - ./gluetun:/gluetun restart: unless-stopped healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/v1/openvpn/status"] interval: 30s timeout: 10s retries: 3 start_period: 30s
qbittorrent: image: lscr.io/linuxserver/qbittorrent:latest container_name: qbittorrent # This is the entire network config — no ports, no networks network_mode: "service:gluetun" depends_on: gluetun: condition: service_healthy environment: - PUID=1000 - PGID=1000 - TZ=Europe/Amsterdam - WEBUI_PORT=8080 volumes: - ./qbittorrent/config:/config - /mnt/data/downloads:/downloads restart: unless-stopped
prowlarr: image: lscr.io/linuxserver/prowlarr:latest container_name: prowlarr network_mode: "service:gluetun" depends_on: gluetun: condition: service_healthy environment: - PUID=1000 - PGID=1000 - TZ=Europe/Amsterdam volumes: - ./prowlarr/config:/config restart: unless-stopped
flaresolverr: image: ghcr.io/flaresolverr/flaresolverr:latest container_name: flaresolverr network_mode: "service:gluetun" depends_on: gluetun: condition: service_healthy environment: - LOG_LEVEL=info - TZ=Europe/Amsterdam restart: unless-stopped
# These containers are NOT behind the VPN plex: image: plexinc/pms-docker:latest container_name: plex network_mode: host # or bridge — whatever you use environment: - PLEX_CLAIM=${PLEX_CLAIM} - TZ=Europe/Amsterdam volumes: - ./plex/config:/config - /mnt/data/media:/data restart: unless-stoppedA few things worth calling out:
cap_add: NET_ADMIN and the tun device — Gluetun needs to create network interfaces. This is non-negotiable. It’s why Gluetun needs elevated capabilities and the host’s /dev/net/tun device.
Ports are declared on Gluetun, not on the dependent containers. When qBittorrent shares Gluetun’s network namespace, qBittorrent’s port 8080 is actually Gluetun’s port 8080 from the host’s perspective. So you open the ports on Gluetun, and you access the qBittorrent WebUI at your-server-ip:8080.
FIREWALL_OUTBOUND_SUBNETS — Without this, your LAN can’t reach the WebUI. The kill switch blocks all outbound traffic except through the VPN. This CIDR lets traffic from your LAN range pass through so you can actually access the services you just tunneled. Set it to your home network subnet.
The .env File
Don’t paste credentials directly into docker-compose. Use an .env file in the same directory:
MULLVAD_PRIVATE_KEY=your-wireguard-private-key-herePLEX_CLAIM=claim-xxxxxxxxxxxxAdd .env to your .gitignore if you’re tracking the compose file in a repo. You already know this, but it bears repeating every single time.
Verifying It Actually Works
Spin it up:
docker compose up -ddocker compose logs gluetun -fWatch the logs. You should see Gluetun connect to the WireGuard server, establish the tunnel, and print something like Connected to Mullvad. If it says Permission denied on /dev/net/tun, your host kernel module isn’t loaded — run sudo modprobe tun.
Once it’s up, verify qBittorrent is actually tunneled:
docker exec qbittorrent curl -s https://ipinfo.io/ipThat IP should be a Mullvad server, not your home IP. If it’s your home IP, check that network_mode: "service:gluetun" is actually on the qBittorrent service.
Compare to a non-tunneled container:
docker exec plex curl -s https://ipinfo.io/ipThat one should return your real WAN IP. Different IPs, job done.
The Kill Switch
Gluetun’s kill switch is on by default (FIREWALL=on). If the VPN tunnel drops — server reboot, Mullvad hiccup, whatever — Gluetun’s firewall blocks all outbound traffic from the shared namespace. qBittorrent can’t reach the internet at all. No VPN, no internet. That’s the correct behavior.
The depends_on + healthcheck combo in the Compose file means qBittorrent won’t even start until Gluetun is healthy. If Gluetun restarts, the dependent containers restart with it. Your downloads don’t leak.
Mullvad Dropped Port Forwarding (And What To Do About It)
Here’s the thing: Mullvad removed port forwarding support in May 2023. If you’re torrenting and actually want to be connectable (better speeds, better peers), Mullvad isn’t ideal for this use case anymore.
ProtonVPN still supports port forwarding via NAT-PMP, and Gluetun supports automatic port-forward negotiation for ProtonVPN. When you enable it, Gluetun writes the forwarded port to a file:
environment: - VPN_SERVICE_PROVIDER=protonvpn - VPN_TYPE=wireguard - WIREGUARD_PRIVATE_KEY=${PROTON_PRIVATE_KEY} - SERVER_COUNTRIES=Netherlands - VPN_PORT_FORWARDING=on - VPN_PORT_FORWARDING_PROVIDER=protonvpnGluetun writes the port to /tmp/gluetun/forwarded_port. You can write a small script to read that file and update qBittorrent’s listening port via its API, or use the community scripts floating around for exactly this.
PIA (Private Internet Access) also supports automatic port forwarding through Gluetun.
If you’re staying on Mullvad specifically, you’re running without port forwarding. Downloads still work — you just won’t be connectable as a seeder. For private trackers, this matters. For public trackers, less so.
DNS Leak Protection
By default Gluetun uses Cloudflare’s DoT (DNS over TLS) servers. You can swap these:
environment: # Use Mullvad's own DNS servers - DOT_PROVIDERS=mullvad # Or use multiple providers # - DOT_PROVIDERS=cloudflare,quad9To verify you’re not leaking DNS, exec into a container behind Gluetun and check:
docker exec qbittorrent cat /etc/resolv.confIt should show Gluetun’s internal DNS IP (typically 127.0.0.1 or the Gluetun container’s internal address), not your router’s DNS or your ISP’s resolver.
Health Checks and Monitoring
The Gluetun control server (HTTP_CONTROL_SERVER_ADDRESS=:8000) exposes a REST API for checking VPN status:
curl http://your-server:8000/v1/openvpn/status# {"status":"running"}
curl http://your-server:8000/v1/publicip/ip# {"public_ip":"185.x.x.x","country":"Netherlands","city":"Amsterdam"}You can wire this into your monitoring stack. A simple Prometheus scrape config:
scrape_configs: - job_name: "gluetun" static_configs: - targets: ["gluetun:8000"] metrics_path: /metricsGluetun exposes metrics at /metrics on the control server. Track tunnel uptime, reconnect events, and bytes transferred. Point Grafana at it, add an alert if the VPN has been disconnected for more than 60 seconds, and sleep better at night.
Running Multiple Gluetun Instances
Nothing stops you from running two (or more) Gluetun instances for different purposes:
services:
gluetun-nl: image: qmcgaw/gluetun:v3 container_name: gluetun-nl cap_add: [NET_ADMIN] devices: - /dev/net/tun:/dev/net/tun ports: - "8080:8080" # qBittorrent - "8001:8000" # NL control server environment: - VPN_SERVICE_PROVIDER=mullvad - VPN_TYPE=wireguard - WIREGUARD_PRIVATE_KEY=${MULLVAD_PRIVATE_KEY_NL} - SERVER_COUNTRIES=Netherlands - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 volumes: - ./gluetun-nl:/gluetun restart: unless-stopped
gluetun-us: image: qmcgaw/gluetun:v3 container_name: gluetun-us cap_add: [NET_ADMIN] devices: - /dev/net/tun:/dev/net/tun ports: - "8888:8080" # Some US-only service WebUI - "8002:8000" # US control server environment: - VPN_SERVICE_PROVIDER=mullvad - VPN_TYPE=wireguard - WIREGUARD_PRIVATE_KEY=${MULLVAD_PRIVATE_KEY_US} - SERVER_COUNTRIES=USA - FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 volumes: - ./gluetun-us:/gluetun restart: unless-stoppedOne Gluetun tunneling through Netherlands for *arr stack and torrenting, another tunneling through USA for a geo-restricted streaming scraper. Each instance has its own port ranges, its own key, its own set of dependent containers. They don’t interfere with each other.
The Full *arr Stack Behind Gluetun
If you’re running the whole media automation stack, here’s what the service dependencies look like:
gluetun ├── qbittorrent (downloads, network_mode: service:gluetun) ├── prowlarr (indexer manager, network_mode: service:gluetun) └── flaresolverr (Cloudflare bypass, network_mode: service:gluetun)
# These talk to qBittorrent and Prowlarr via localhost (they're on the same network ns)# But Radarr/Sonarr themselves don't need to be behind the VPN:radarr → connects to gluetun container IP:9696 for Prowlarrsonarr → connects to gluetun container IP:9696 for Prowlarrjellyfin → connects to gluetun container IP:8080 for qBittorrentRadarr and Sonarr manage your media library. Their traffic (metadata fetching, API calls to TMDB, etc.) can go through your home WAN — there’s nothing sensitive there. Only the actual download traffic and indexer lookups need the VPN. This is a cleaner split than tunneling everything.
Troubleshooting Common Issues
Container can’t reach the internet at all — Check that Gluetun is actually connected. docker logs gluetun should show a successful connection. Also confirm FIREWALL_OUTBOUND_SUBNETS is set to your LAN.
WebUI not accessible from LAN — Make sure the port is declared on gluetun, not the dependent service. And confirm your FIREWALL_OUTBOUND_SUBNETS includes the IP you’re connecting from.
Gluetun keeps reconnecting — Your WireGuard key might be expired or associated with a different server. Regenerate it from the Mullvad portal. Also check that WIREGUARD_ADDRESSES matches the IP in your downloaded Mullvad config.
DNS resolution failing inside containers — Check DOT_PROVIDERS is set. If Gluetun can’t reach the DoT server on startup, it falls back and might leave DNS in a broken state. Try DOT_PROVIDERS=cloudflare as a fallback.
/dev/net/tun not found — Run sudo modprobe tun on the host. If you’re on an LXC container without TUN/TAP enabled, you need to enable it in Proxmox for that container (LXC options → TUN/TAP device).
The Bottom Line
Gluetun is the cleanest solution to per-container VPN routing in Docker. One container holds the tunnel. Other containers borrow it. The kill switch is automatic. DNS leak protection is built in. You don’t touch iptables.
The tradeoff is that Mullvad doesn’t support port forwarding anymore, which matters if you’re a heavy torrenter on private trackers. ProtonVPN is the better choice there if port forwarding matters to you — Gluetun supports it natively.
But for the common home lab use case — “route downloads and indexers through VPN, leave Plex and Jellyfin alone” — this setup is exactly right. The whole thing is under 100 lines of YAML, it restarts cleanly, and your 2 AM self will not be cursing at iptables.
Start with one Gluetun, one qBittorrent, get that working. Then add Prowlarr. Then FlareSolverr. Grow it incrementally. Don’t try to build the full stack in one shot unless you enjoy spending Saturday debugging network namespaces.
Run docker exec qbittorrent curl -s https://ipinfo.io/ip after each change. If it shows a VPN IP, you’re good. If it shows your home IP, something’s wrong. That single command is your ground truth.