Docker Stack with ExpressVPN

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Docker Compose, ExpressVPN, qBittorrent, Prowlarr, Sonarr, Radarr and Lidarr

The 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:expressvpn


That 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/expressvpn is 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 with NET_ADMIN and /dev/net/tun instead.
  • LinuxServer now documents its images using lscr.io/linuxserver/....
  • The old qBittorrent variable UMASK_SET has been replaced by UMASK.
  • I was using the Prowlarr develop tag for no particularly good reason. Stable latest is the sensible default now.
  • The old stack had separate /tv, /movies and /downloads mounts. A single common /data mount 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/Perth


Then lock the file down:

chmod 600 .env


2. Folder layout

My current Docker layout is:

/home/richay/docker-compose/   # Compose files + small hand-managed config
/home/richay/docker/           # Persistent container data


For 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:8686


The 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 -d


Then check Gluetun first:

docker logs -f gluetun


Once it reports a healthy VPN connection, check the stack:

docker compose ps
docker exec gluetun wget -qO- https://ifconfig.me


The 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 password


Then log into:

http://DOCKER-HOST-IP:8080


and 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 6881 in 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-stopped


If 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/expressvpn with 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

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 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *