Skip to content
Go back

Mullvad VPN Containers via Gluetun: Per-App VPN

By SumGuy 11 min read
Mullvad VPN Containers via Gluetun: Per-App VPN

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 ──▶ Internet

Gluetun 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.

  1. Log in at mullvad.net
  2. Go to WireGuard configuration and generate a new key pair
  3. Download the config for any server — you only need the PrivateKey value 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:

docker-compose.yml
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-stopped

A 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:

.env
MULLVAD_PRIVATE_KEY=your-wireguard-private-key-here
PLEX_CLAIM=claim-xxxxxxxxxxxx

Add .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:

Terminal window
docker compose up -d
docker compose logs gluetun -f

Watch 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:

Terminal window
docker exec qbittorrent curl -s https://ipinfo.io/ip

That 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:

Terminal window
docker exec plex curl -s https://ipinfo.io/ip

That 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=protonvpn

Gluetun 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,quad9

To verify you’re not leaking DNS, exec into a container behind Gluetun and check:

Terminal window
docker exec qbittorrent cat /etc/resolv.conf

It 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:

Terminal window
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:

prometheus.yml
scrape_configs:
- job_name: "gluetun"
static_configs:
- targets: ["gluetun:8000"]
metrics_path: /metrics

Gluetun 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:

docker-compose.multi.yml
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-stopped

One 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 Prowlarr
sonarr → connects to gluetun container IP:9696 for Prowlarr
jellyfin → connects to gluetun container IP:8080 for qBittorrent

Radarr 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
BirdNET-Pi for Self-Hosted Bird Identification

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts