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 🙂

ExpressVPN – Hourly random smart server reconnect (Linux)

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Current ExpressVPN Linux app / CLI using expressvpnctl

The original idea still works, but ExpressVPN replaced its old Linux CLI in late 2025. Commands such as expressvpn list all, expressvpn refresh and expressvpn connect belong to the old client. The current CLI is expressvpnctl.

The original version of this article forced ExpressVPN to disconnect, choose another server and reconnect every hour.

I originally borrowed the idea from an Ubuntu 101 article and then tweaked it with suggestions from commenters. The old method did the job, but the ExpressVPN Linux app has changed enough that copying the old script today will mostly result in Linux staring back at you like you’ve personally offended it.

Do you actually need to change VPN server every hour?

Probably not.

Changing servers every hour doesn’t magically make the VPN more private. It also interrupts existing connections, changes your public IP and can annoy websites/services that suddenly see you teleport from one location to another.

But there are still legitimate reasons to automate a reconnect — for example, working around a server that gets shitty after running for a long time, periodically refreshing the public IP, or because you simply enjoy making networking unnecessarily complicated. I can’t judge; look at the rest of this website 🙂

What changed in ExpressVPN for Linux?

ExpressVPN introduced a redesigned Linux app in version 5.0.0 in November 2025 and replaced the old command-line interface with a new CLI.

The command now starts with:

expressvpnctl


The current app also has built-in:

  • Smart Location
  • Auto-connect rules
  • Network Lock / kill switch
  • Split tunnelling
  • Background mode for headless CLI use
  • Automatic protocol selection

So we don’t need the old @reboot expressvpn connect cron bodge anymore.

1. Enable ExpressVPN background mode

Current ExpressVPN requires either the GUI to be running or background mode to be enabled before CLI connection commands can work unattended.

For a headless/server-style setup:

expressvpnctl background enable


Check the current CLI options at any time with:

expressvpnctl -h


2. Test Smart Location

ExpressVPN’s current Smart Location chooses a location based on things such as speed and proximity.

Connect explicitly to Smart Location with:

expressvpnctl connect smart


To see the currently available location names:

expressvpnctl get regions


And if you want to manually choose one:

expressvpnctl connect "Australia - Sydney"


Use the exact location name shown by expressvpnctl get regions. Server/location names can change, so don’t assume my example is immortal.

3. Create the hourly Smart Location script

I now keep little user scripts under ~/.local/bin rather than dumping homemade scripts into /usr/sbin.

mkdir -p ~/.local/bin
nano ~/.local/bin/expressvpn-smart-reconnect.sh


Paste:

#!/usr/bin/env bash
set -euo pipefail

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Ask ExpressVPN to select its current Smart Location.
# No explicit disconnect first - just switch/reconnect.
expressvpnctl connect smart


Then make it executable:

chmod +x ~/.local/bin/expressvpn-smart-reconnect.sh


Test it manually:

~/.local/bin/expressvpn-smart-reconnect.sh


Why I don’t disconnect first anymore: the old script deliberately ran disconnect before reconnecting. The current CLI can connect/switch directly to another location. Avoiding a deliberate disconnected window is cleaner, especially on a machine carrying traffic you actually care about.

4. Use a systemd user timer instead of cron

Cron would still work, but systemd timers give better logging and are easier to inspect when something goes cactus.

Create the user service:

mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/expressvpn-smart-reconnect.service


Paste:

[Unit]
Description=Reconnect ExpressVPN using Smart Location

[Service]
Type=oneshot
ExecStart=%h/.local/bin/expressvpn-smart-reconnect.sh


Now create the timer:

nano ~/.config/systemd/user/expressvpn-smart-reconnect.timer


Paste:

[Unit]
Description=Reconnect ExpressVPN every hour

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target


Reload systemd and enable the timer:

systemctl --user daemon-reload
systemctl --user enable --now expressvpn-smart-reconnect.timer


Check it:

systemctl --user status expressvpn-smart-reconnect.timer
systemctl --user list-timers --all | grep expressvpn


And check the reconnect log after it runs:

journalctl --user -u expressvpn-smart-reconnect.service --no-pager -n 50


If this is a headless machine and you want the user timer to keep running when you’re not logged in, enable lingering for that Linux user:

sudo loginctl enable-linger "$USER"


Optional: actually choose a random location

Smart Location is not random. It deliberately chooses what ExpressVPN thinks is the best location. It may happily choose the same location again.

If you genuinely want random rotation, I prefer keeping a small list of locations I am happy to use rather than scraping whatever format expressvpnctl get regions happens to print this month.

First get the current valid names:

expressvpnctl get regions


Then create:

nano ~/.local/bin/expressvpn-random-reconnect.sh


Example:

#!/usr/bin/env bash
set -euo pipefail

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Pick locations YOU are happy to use.
# Confirm current names first with: expressvpnctl get regions
LOCATIONS=(
  "Australia - Sydney"
  "Australia - Melbourne"
)

LOCATION="${LOCATIONS[RANDOM % ${#LOCATIONS[@]}]}"

printf 'Switching ExpressVPN to: %s\n' "$LOCATION"
expressvpnctl connect "$LOCATION"


Make it executable:

chmod +x ~/.local/bin/expressvpn-random-reconnect.sh


If you prefer this mode, change the systemd service’s ExecStart to:

ExecStart=%h/.local/bin/expressvpn-random-reconnect.sh


Then reload it:

systemctl --user daemon-reload
systemctl --user restart expressvpn-smart-reconnect.timer


Yeah, the timer name still says “smart” if you do that. Rename it too if that sort of thing keeps you awake at night. My brain would absolutely notice it six months later and become irrationally annoyed.

Network Lock / kill switch

ExpressVPN’s current Linux CLI has Network Lock enabled by default. It is designed to block traffic when the VPN connection unexpectedly drops.

You can explicitly enable it with:

expressvpnctl set networklock true


For the best experience ExpressVPN recommends leaving the VPN protocol on Automatic unless you have a reason to force something else:

expressvpnctl set protocol auto


Don’t confuse “hourly server switching” with a kill switch. The timer changes location. Network Lock is what protects traffic when the VPN unexpectedly disappears. They solve completely different problems.

Checking your public IP

The old article used expressvpn status. With the new client I prefer simply confirming the outside world sees a different IP:

curl -4 https://ifconfig.me ; echo


Run it before and after a reconnect. If the location/server actually changed, the public IP will normally change too.

Old method — archived

ARCHIVED — OLD EXPRESSVPN LINUX CLI

The original article used the pre-5.x Linux CLI. These commands are preserved so old search results and installations make sense, but don’t use them with the current client.

The original connection-at-boot cron entry was:

MAILTO=""
@reboot expressvpn connect


And the old randomisation script did roughly this:

expressvpn disconnect
expressvpn refresh

VPN=$(expressvpn list all | tail -n +4 | awk '{ print $1 }' | shuf -n 1)

expressvpn connect "$VPN" 


Finally it ran every hour with:

0 */1 * * * /usr/sbin/smartexpressvpn.sh


The cron concept wasn’t wrong. The commands underneath it simply got replaced when ExpressVPN rebuilt the Linux app.

A note about the original random script

The old script parsed human-readable command output using tail, awk, grep and shuf. It even relied on specific column positions.

That kind of script is fine until the application changes one heading or adds one bloody space and suddenly your “VPN location” variable contains a decorative column title.

The current version either asks ExpressVPN directly for smart, or chooses from a small explicit list of valid locations. Much less clever. Much less likely to fuck itself.

References

The idea survived; the commands didn’t. Which is pretty much the lifecycle of every Linux how-to eventually 🙂