Skip to content

VPN + qBittorrent (gluetun)

qBittorrent has no network of its own. It runs in gluetun's network namespace (network_mode: service:gluetun), so when the tunnel is down it has no route at all. That is the kill switch. gluetun also requests a forwarded port from PIA and pushes it into qBittorrent through a hook script.

Compose

compose/nas/gluetun-qbittorrent.yml
# Portainer stack: gluetun-qbittorrent  (secrets go in the Portainer stack "Environment variables")
# gluetun = PIA WireGuard (custom provider) with PIA port forwarding; qBittorrent shares its network namespace.
services:
  gluetun:
    image: qmcgaw/gluetun:${GLUETUN_TAG}          # pin; bump deliberately
    container_name: gluetun
    cap_add: [NET_ADMIN]
    devices:
      - /dev/net/tun:/dev/net/tun
    volumes:
      - /volume1/docker/gluetun:/gluetun          # holds servers.json, forwarded-port file and the qbt-port hook scripts
    ports:
      - "8080:8080"                               # qBittorrent WebUI (published by gluetun, not by qbittorrent)
    environment:
      - TZ=${TZ}
      - VPN_SERVICE_PROVIDER=custom
      - VPN_TYPE=wireguard
      - WIREGUARD_PRIVATE_KEY=${WG_PRIVATE_KEY}
      - WIREGUARD_ADDRESSES=${WG_ADDRESSES}
      - WIREGUARD_PUBLIC_KEY=${WG_PEER_PUBLIC_KEY}
      - WIREGUARD_ENDPOINT_IP=${WG_ENDPOINT_IP}
      - WIREGUARD_ENDPOINT_PORT=${WG_ENDPOINT_PORT}
      - WIREGUARD_MTU=1320
      - WIREGUARD_IMPLEMENTATION=userspace        # DSM 7.1 kernel (3.10) has no WireGuard module
      - VPN_PORT_FORWARDING=on
      - VPN_PORT_FORWARDING_PROVIDER=private internet access
      - VPN_PORT_FORWARDING_USERNAME=${PIA_USER}
      - VPN_PORT_FORWARDING_PASSWORD=${PIA_PASS}
      - SERVER_NAMES=${PIA_SERVER_CN}             # the PIA server common name that matches WG_ENDPOINT_IP
      - VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh /gluetun/qbt-port.sh {{PORT}} {{VPN_INTERFACE}}
      - VPN_PORT_FORWARDING_DOWN_COMMAND=/bin/sh /gluetun/qbt-port-down.sh
      - FIREWALL_OUTBOUND_SUBNETS=${LAN_SUBNET}   # e.g. 192.168.1.0/24 so the *arrs can reach the WebUI
      - HEALTH_RESTART_VPN=on
    restart: unless-stopped
    logging: { driver: json-file, options: { max-size: 10m } }

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:${QBT_TAG}   # pin
    container_name: qbittorrent
    network_mode: "service:gluetun"               # kill switch: no route out except the tunnel
    depends_on: [gluetun]
    volumes:
      - /volume1/docker/qbittorrent:/config
      - /volume1/data/torrents:/data/torrents
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=${TZ}
      - WEBUI_PORT=8080
      - TORRENTING_PORT=${VPN_FORWARDED_PORT}     # initial value only; the gluetun hook sets the real one
    restart: unless-stopped
    logging: { driver: json-file, options: { max-size: 10m } }
Hook scripts (put them in /volume1/docker/gluetun/)

qbt-port.sh
#!/bin/sh
# /volume1/docker/gluetun/qbt-port.sh  — gluetun VPN_PORT_FORWARDING_UP_COMMAND: qbt-port.sh <port> <iface>
# Requires qBittorrent "Bypass authentication for clients on localhost" = ON.
PORT="$1"; IFACE="${2:-tun0}"; U="http://127.0.0.1:8080/api/v2/app/setPreferences"
[ -n "$PORT" ] || { echo "qbt-port: no port"; exit 1; }
i=0
while [ $i -lt 60 ]; do
  if wget -q -O- --post-data "json={\"listen_port\":$PORT,\"current_network_interface\":\"$IFACE\",\"random_port\":false,\"upnp\":false}" "$U" >/dev/null 2>&1; then
    echo "qbt-port: listen_port set to $PORT on $IFACE"; exit 0; fi
  i=$((i+1)); sleep 5
done
echo "qbt-port: failed to reach qBittorrent WebUI"; exit 1
qbt-port-down.sh
#!/bin/sh
# /volume1/docker/gluetun/qbt-port-down.sh — gluetun VPN_PORT_FORWARDING_DOWN_COMMAND
wget -q -O- --post-data 'json={"listen_port":0,"current_network_interface":"lo"}' \
  http://127.0.0.1:8080/api/v2/app/setPreferences >/dev/null 2>&1 \
  && echo "qbt-port-down: reset" || echo "qbt-port-down: qBittorrent unreachable"

Getting the WireGuard values from PIA

gluetun's built-in PIA provider only does OpenVPN. For WireGuard, use the custom provider with values from PIA's manual flow (pia-foss/manual-connections):

  1. POST https://www.privateinternetaccess.com/api/client/v2/token with your PIA user and password → token.
  2. Pick a port-forward-capable WireGuard server from https://serverlist.piaservers.net/vpninfo/servers/v6. Note its IP and common name (PIA_SERVER_CN).
  3. wg genkey | tee priv | wg pubkey > pub
  4. GET https://<cn>:1337/addKey?pt=<token>&pubkey=<pub> (using PIA's CA cert) → returns peer_ip (WG_ADDRESSES), server_key (WG_PEER_PUBLIC_KEY) and server_port.
  5. Put them in the Portainer stack env. Redo this if the server is retired or the key stops working.

OpenVPN fallback (untested)

VPN_SERVICE_PROVIDER=private internet access, VPN_TYPE=openvpn, OPENVPN_USER/PASSWORD, SERVER_REGIONS=<region>, PORT_FORWARD_ONLY=true, and drop every WIREGUARD_* and VPN_PORT_FORWARDING_PROVIDER/USERNAME/PASSWORD line.

qBittorrent settings

Area Setting Value
Downloads Default save path /data/torrents
Incomplete path /data/torrents/incomplete (on)
Torrent Management Mode Automatic
Categories tv → /data/torrents/tv, movies → /data/torrents/movies
Connection Listening port set by the hook (= the forwarded port). UPnP off, random port off
Advanced Network interface tun0 (a second kill switch)
Speed Global upload capped (see private runbook). Download: unlimited
Alternative limits lower caps on a schedule during peak Plex hours (values and window: see private runbook)
BitTorrent Share limits ratio 3 or 30 days → see the gotcha below
Queueing 5 active downloads; slow torrents don't count after 1800 s
Web UI Bypass authentication for localhost ON (the port-forward hook needs it)
Bypass for whitelisted subnets OFF (LAN users must log in)

Userspace WireGuard costs CPU

The old DSM kernel has no WireGuard module, so gluetun runs wireguard-go, which costs noticeable CPU on an Atom. The upload cap and the scheduler are there to protect the CPU for Plex, not to save bandwidth.

Known gotchas

  • Saving or updating this stack in Portainer recreates gluetun. qBittorrent restarts with it; check that the listen port matches the forwarded port afterwards.
  • Restart order: gluetun (wait for healthy) → qBittorrent.
  • Port shows closed? Inside gluetun: sh /gluetun/qbt-port-down.sh; sh /gluetun/qbt-port.sh <forwarded_port> tun0.
  • The *arrs warn "qBittorrent is set to remove completed downloads" while the share-limit action is Remove. This is intended: qBittorrent removes torrents itself at ratio 3 or 30 days (the files stay, as hardlinks in the library). Ignore the warning, or switch to Stop if you would rather have the *arrs do the removal.

Verify

docker exec gluetun wget -qO- https://ipinfo.io/ip        # VPN exit IP, NOT your home IP
docker exec gluetun cat /tmp/gluetun/forwarded_port        # forwarded port
docker exec qbittorrent wget -qO- https://ipinfo.io/ip    # same VPN IP
docker stop gluetun && docker exec qbittorrent wget -T5 -qO- https://ipinfo.io/ip  # must FAIL (kill switch)