Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Docker Compose, ExpressVPN, qBittorrent, Prowlarr, Sonarr, Radarr and LidarrThe original idea is still solid: make selected containers share a VPN container’s network namespace so their traffic cannot casually wander out through your normal WAN connection. The old stack still mostly makes sense, but I’ve rebuilt the recommended version around Gluetun because it handles the firewall/killswitch and DNS side much more cleanly.
This article started life as a giant entertainment stack using polkaned/expressvpn, with qBittorrent and all the *arr containers set to:
network_mode: service:expressvpnThat Docker trick is still completely valid. Docker Compose still supports network_mode: service:<name>, which makes one service use another service’s network stack.
The result is basically:
qBittorrent ─┐
Prowlarr ├── shares VPN network namespace ── ExpressVPN ── Internet
Sonarr │
Radarr │
Lidarr ─┘If the VPN container’s firewall is doing its job, those applications don’t get an independent route straight out through your home connection.
What was wrong with my old stack?
Nothing catastrophically wrong, but a few bits have aged.
polkaned/expressvpnis actually still maintained, so that part didn’t die. Nice.- The current polkaned documentation warns that containers sharing its network namespace need the VPN container’s DNS configuration copied/shared as well to avoid DNS leakage. My old Compose didn’t do that.
- The old VPN container ran
privileged: true. Gluetun can do the job withNET_ADMINand/dev/net/tuninstead. - LinuxServer now documents its images using
lscr.io/linuxserver/.... - The old qBittorrent variable
UMASK_SEThas been replaced byUMASK. - I was using the Prowlarr
developtag for no particularly good reason. Stablelatestis the sensible default now. - The old stack had separate
/tv,/moviesand/downloadsmounts. A single common/datamount makes hardlinks and atomic moves far less cactus.
Recommended 2026 method: Gluetun + ExpressVPN
Gluetun has native ExpressVPN support and can act as the network container for everything else. Its firewall stays active and effectively acts as the VPN killswitch.
One limitation: Gluetun connects to ExpressVPN using ExpressVPN’s manual OpenVPN credentials. ExpressVPN’s own Linux app can use newer protocols such as Lightway, but their manual configuration currently supports OpenVPN.
1. Get the ExpressVPN manual credentials
These are not your normal ExpressVPN account email/password.
In your ExpressVPN account go to the manual configuration area and grab the generated OpenVPN username and password.
I keep mine in an .env file next to the Compose file instead of hard-coding them into the stack:
EXPRESSVPN_USER=replace_me
EXPRESSVPN_PASSWORD=replace_me
EXPRESSVPN_COUNTRY=Australia
PUID=1000
PGID=1000
TZ=Australia/PerthThen lock the file down:
chmod 600 .env2. Folder layout
My current Docker layout is:
/home/richay/docker-compose/ # Compose files + small hand-managed config
/home/richay/docker/ # Persistent container dataFor this stack:
mkdir -p /home/richay/docker-compose/media-vpn
mkdir -p /home/richay/docker/{gluetun,qbittorrent,prowlarr,sonarr,radarr,lidarr}
mkdir -p /mnt/media/{downloads,tv,movies,music}Change /mnt/media to wherever your real media/download storage is mounted.
3. Current Compose stack
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=expressvpn
- VPN_TYPE=openvpn
- OPENVPN_USER=${EXPRESSVPN_USER}
- OPENVPN_PASSWORD=${EXPRESSVPN_PASSWORD}
- SERVER_COUNTRIES=${EXPRESSVPN_COUNTRY:-Australia}
- TZ=${TZ:-Australia/Perth}
volumes:
- /home/richay/docker/gluetun:/gluetun
ports:
- "8080:8080" # qBittorrent WebUI
- "9696:9696" # Prowlarr
- "8989:8989" # Sonarr
- "7878:7878" # Radarr
- "8686:8686" # Lidarr
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
network_mode: service:gluetun
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Australia/Perth}
- WEBUI_PORT=8080
- TORRENTING_PORT=6881
- UMASK=022
volumes:
- /home/richay/docker/qbittorrent:/config
- /mnt/media:/data
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
network_mode: service:gluetun
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Australia/Perth}
volumes:
- /home/richay/docker/prowlarr:/config
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
network_mode: service:gluetun
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Australia/Perth}
volumes:
- /home/richay/docker/sonarr:/config
- /mnt/media:/data
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
network_mode: service:gluetun
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Australia/Perth}
volumes:
- /home/richay/docker/radarr:/config
- /mnt/media:/data
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
network_mode: service:gluetun
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Australia/Perth}
volumes:
- /home/richay/docker/lidarr:/config
- /mnt/media:/data
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
All five application containers share Gluetun’s network namespace. Because they are effectively living in the same network stack, they can also talk to each other over localhost.
For example:
qBittorrent from Sonarr/Radarr: http://127.0.0.1:8080
Prowlarr: http://127.0.0.1:9696
Sonarr: http://127.0.0.1:8989
Radarr: http://127.0.0.1:7878
Lidarr: http://127.0.0.1:8686The ports are published on the Gluetun service because containers using network_mode: service:gluetun cannot publish their own ports.
Do not router-port-forward those WebUI ports. They’re management interfaces, not things you want flapping around on the public internet. If you want remote access, use a VPN/Tailscale and private DNS rather than raw WAN exposure.
If you want the same private hostname setup I use, see my Tailscale Split DNS guide.
4. Bring it up
cd /home/richay/docker-compose/media-vpn
docker compose up -dThen check Gluetun first:
docker logs -f gluetunOnce it reports a healthy VPN connection, check the stack:
docker compose ps
docker exec gluetun wget -qO- https://ifconfig.meThe public IP shown from Gluetun should be the VPN exit IP, not your normal home WAN address.
5. qBittorrent login changed too
Current LinuxServer qBittorrent generates a temporary password for the admin user on startup. Grab it from the logs:
docker logs qbittorrent 2>&1 | grep -i passwordThen log into:
http://DOCKER-HOST-IP:8080and change the password.
About the torrent port
The old stack published TCP/UDP 6881 and it was easy to assume that meant qBittorrent had an externally forwarded VPN port.
It doesn’t.
ExpressVPN does not support port forwarding on its VPN servers. Publishing
6881in Docker only exposes that port on the Docker host. It does not punch an inbound port through the ExpressVPN server.
qBittorrent will still download and seed through outbound connections, but unsolicited inbound peer connections through the VPN aren’t available. That can reduce peer connectivity compared with a VPN provider that offers torrent-friendly port forwarding.
Do Sonarr/Radarr/Lidarr really need the VPN?
Nope.
The stack above keeps everything behind Gluetun because it’s simple and matches the design of my old stack. But the important ones are generally:
- qBittorrent: definitely the one whose traffic I want through the VPN.
- Prowlarr: useful behind the VPN if your ISP blocks tracker/indexer sites.
- Sonarr / Radarr / Lidarr: usually fine on the normal Docker network because they can talk to Prowlarr/qBittorrent locally.
If you want a cleaner split, move Sonarr/Radarr/Lidarr back onto a normal user-defined Docker network and leave only qBittorrent/Prowlarr sharing Gluetun. Slightly more networking to configure, slightly less “everything goes through the magic tunnel”. Pick your poison 🙂
What about polkaned/expressvpn?
It is not dead. I genuinely expected to find a dusty abandoned image here, but Docker Hub shows fresh 2026 builds and the current README supports ExpressVPN 5.x plus protocol selection including Lightway, OpenVPN and WireGuard.
A current minimal container still looks roughly like:
services:
expressvpn:
image: polkaned/expressvpn:latest
container_name: expressvpn
environment:
- ACTIVATION_CODE=${EXPRESSVPN_ACTIVATION_CODE}
- PROTOCOL=wireguard
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
privileged: true
tty: true
command: /bin/bash
restart: unless-stoppedIf you specifically want the real ExpressVPN client/protocols, that remains an option.
However, if other containers share that ExpressVPN container’s network namespace, follow the maintainer’s current DNS-sharing instructions. Their README explicitly warns that the dependent containers need the VPN-side resolv.conf handling to avoid DNS leakage.
That’s why I’m using Gluetun as the recommended fresh install: less janky plumbing, built-in firewall behaviour, and it was designed specifically for this sidecar/network-container use case.
Why Plex is no longer in this stack
The old article bundled Plex and get_iplayer into the same giant Compose file even though they weren’t using the VPN.
These days I keep unrelated services in separate stacks. It makes upgrades, troubleshooting and the inevitable “why the fuck did restarting my VPN also restart Plex?” moment much easier to avoid.
Plex should generally have its own stack/LXC and normal networking. It has absolutely no need to ride out through ExpressVPN just because qBittorrent does.
Old 2024 stack — historical note
HISTORICAL
The original stack used
polkaned/expressvpnwith qBittorrent, Prowlarr, Sonarr, Radarr and Lidarr all sharing the ExpressVPN network namespace. The architecture itself was valid, but the old example lacked the DNS-sharing requirement documented by the current image and used several now-stale image/environment conventions.
So I’m not pretending the original idea was wrong. It just needed its plumbing replaced before it flooded the carpet.
References
- Gluetun VPN client
- Gluetun ExpressVPN provider setup
- polkaned/expressvpn current documentation
- ExpressVPN manual OpenVPN configuration
- LinuxServer qBittorrent
- Docker Compose network_mode
- My Tailscale Split DNS guide
The nice bit is the original trick still survives: one VPN container, everything else borrows its network. The new version just has fewer opportunities to quietly leak DNS or make me wonder why I gave a VPN container the keys to the entire bloody kingdom 🙂
