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¶
# 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/)
#!/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
#!/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):
POST https://www.privateinternetaccess.com/api/client/v2/tokenwith your PIA user and password → token.- Pick a port-forward-capable WireGuard server from
https://serverlist.piaservers.net/vpninfo/servers/v6. Note its IP and common name (PIA_SERVER_CN). wg genkey | tee priv | wg pubkey > pubGET https://<cn>:1337/addKey?pt=<token>&pubkey=<pub>(using PIA's CA cert) → returnspeer_ip(WG_ADDRESSES),server_key(WG_PEER_PUBLIC_KEY) andserver_port.- 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)