Gluetun - ProtonVPN + Qbittorrent¶
Gluetun is a VPN client in a thin Docker container for multiple VPN providers, written in Go, and using OpenVPN or Wireguard, DNS over TLS, with a few proxy servers built-in.
qBittorrent runs with network_mode: "service:gluetun", so it has no network of its own: it shares Gluetun's network stack. Everything it sends goes through the tunnel, and Gluetun's firewall (the killswitch) drops anything that tries to leave any other way, including when the VPN is down.
Gluetun Configuration for ProtonVPN + QBittorrent¶
For this I'm using a Wireguard setup instead of OpenVPN.
- It is lighter, faster, and easier to configure.
In ProtonVPN webpage:
- go into the Downloads section and create a new WireGuard configuration.
- Select Router, no filtering, and "NAT-PMP (Port Forwarding)".
- Deselect VPN accelerator.
- When you click Create, a popup of the config will display.
- Copy the PrivateKey.
OpenVPN instead?
Set VPN_TYPE=openvpn and fill in the OpenVPN username/password from your ProtonVPN account. Append +pmp to the username: that is what turns on port forwarding for OpenVPN.
Use this compose.yaml to setup the gluetun container for ProtonVPN and bind it to QB. The .env is below it. No need to configure the compose.yaml. It can all be done via the .env. This is the same block as in the full stack compose.
services:
### -------------------------------------- ###
### ----------- Download W/ VPN ---------- ###
### -------------------------------------- ###
### --- Qbittorrent: Torrent Client --- ###
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:5.2.4_v2.0.15-ls479
container_name: qbittorrent
depends_on:
gluetun:
condition: service_healthy
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=8080
# This torrenting port is enabled by default. Gluetun handles this here, so it is disabled.
# - TORRENTING_PORT=8694
volumes:
- ${MEDIA_DATA}:/data
- ${APPDATA_PATH}/qbittorrent/config:/config
restart: always
network_mode: "service:gluetun" # all traffic goes through the VPN; the web UI port is published on gluetun
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080 >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- autoheal=true # see the Autoheal page
### --- Gluetun: VPN for Downloading --- ###
# https://www.reddit.com/r/gluetun/comments/1kpbfs2/the_definitive_howto_for_setting_up_protonvpn/
gluetun:
image: qmcgaw/gluetun:v3.41.3
container_name: gluetun
cap_add:
- NET_ADMIN # required to create the tunnel and firewall rules
devices:
- /dev/net/tun:/dev/net/tun
ports:
- 8080:8080/tcp # qbittorrent
environment:
- TZ=${TZ}
- UPDATER_PERIOD=168h # weekly server-list refresh
- VPN_SERVICE_PROVIDER=protonvpn
- VPN_TYPE=${VPN_TYPE}
- BLOCK_MALICIOUS=off
- OPENVPN_USER=${OPENVPN_USER}
- OPENVPN_PASSWORD=${OPENVPN_PASSWORD}
- OPENVPN_CIPHERS=AES-256-GCM
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- PORT_FORWARD_ONLY=on
- VPN_PORT_FORWARDING=on
# Push the forwarded port into qBittorrent and bind it to ANY interface ("", not tun0).
- VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'sleep 90 && wget -O- --retry-connrefused --post-data "json={\"listen_port\":{{PORTS}},\"current_network_interface\":\"\"}" http://127.0.0.1:8080/api/v2/app/setPreferences 2>&1'
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
# Health target by IP, so a DNS hiccup doesn't fail the health check and restart the tunnel
- HEALTH_TARGET_ADDRESSES=1.1.1.1:443,8.8.8.8:443
- HEALTH_VPN_DURATION_INITIAL=30s # obsolete since v3.40; Gluetun ignores it
volumes:
- ${APPDATA_PATH}/gluetun/config:/gluetun
healthcheck:
# Same probe as the image default, with a 120s start_period (image default: 10s) so a slow
# first connection doesn't mark gluetun unhealthy and abort qbittorrent's depends_on.
test: ["CMD", "/gluetun-entrypoint", "healthcheck"]
interval: 5s
timeout: 5s
start_period: 120s
start_interval: 5s
retries: 3
restart: always
.env
# Fill in either the OpenVPN or WireGuard section. VPN_TYPE picks one ('wireguard' or 'openvpn'); the other is ignored.
# Run 'docker run --rm -v eraseme:/gluetun qmcgaw/gluetun format-servers -protonvpn' to list server countries.
# Base config
TZ=<TZ> # e.g. Etc/UTC
PUID=<PUID> # e.g. 1000
PGID=<PGID> # e.g. 1000
APPDATA_PATH=/path/to/appdata
MEDIA_DATA=/mnt/storage/data
# Gluetun config
VPN_TYPE=wireguard # 'wireguard' or 'openvpn'
SERVER_COUNTRIES=Netherlands # comma-separated list
# OpenVPN config
OPENVPN_USER=<YOUR_USER>+pmp
OPENVPN_PASSWORD=<YOUR_PASSWORD>
# Wireguard config
WIREGUARD_PRIVATE_KEY=<YOUR_VPN_PRIVATE_KEY>
Port forwarding into qBittorrent¶
ProtonVPN forwards one random port per connection, and the port changes when the tunnel reconnects. To seed well, qBittorrent has to listen on exactly that port. Gluetun runs VPN_PORT_FORWARDING_UP_COMMAND every time it gets a port, and the command pushes it into qBittorrent through its Web API. {{PORTS}} is replaced with the forwarded port.
The command here differs from the upstream example in three ways. Each one is there because the upstream version broke something in this stack:
| Setting | Upstream example | Here | Why |
|---|---|---|---|
current_network_interface |
{{VPN_INTERFACE}} (= tun0) |
"" (any interface) |
See below: binding to tun0 breaks tracker DNS. |
sleep 90 && |
none | 90 s delay | qBittorrent only starts after Gluetun is healthy, so on the first connect its API isn't up yet. Without the delay the first port update fails (the old "this WILL fail on first run" problem). |
VPN_PORT_FORWARDING_DOWN_COMMAND |
sets the port to 0 and the interface to lo |
not set | Gluetun's killswitch already blocks all traffic while the tunnel is down. The lo binding only adds a way to get stuck (below). |
Why the interface is "" and not tun0¶
Binding qBittorrent to tun0 looks like the safest choice, but it breaks name resolution:
- Gluetun's DNS resolver listens on
127.0.0.1, which is only reachable over the loopback interface (lo). - Bound to
tun0, libtorrent can't reach it. Every tracker announce fails with "Host not found (non-authoritative)", DHT stays at 0 nodes and qBittorrent shows firewalled. - Confusingly,
getent hostsfrom inside the container still resolves fine, because it isn't bound to an interface.
An empty value means "any interface", and it does not leak. Gluetun's policy routing sends all unbound traffic out through the tunnel, and its firewall blocks the container's normal network interface. With the interface unset, the egress IP stays the VPN IP, trackers report working and the connection status is connected.
Setting the key explicitly (instead of leaving it out) also matters. qBittorrent saves the bound interface in its config. If anything ever set it to lo (an earlier DOWN command, or a manual change), it stays bound to loopback after every restart: every tracker "unreachable", status firewalled. Writing "" on every connect resets it.
Keep the command on one line
The whole value of VPN_PORT_FORWARDING_UP_COMMAND is one shell string. If the next variable ends up on the same line (for example ...2>&1'- SERVER_COUNTRIES=...), /bin/sh gets the extra text as arguments and the command dies with redir error on every connect. The port then never gets set, and SERVER_COUNTRIES silently isn't set either. Run docker compose config after editing it.
Check that it worked¶
# Gluetun logs the forwarded port and the command's output
docker logs gluetun 2>&1 | grep -iE "port forward|setPreferences"
# Egress IP from inside qBittorrent should be the VPN's, not yours
docker exec qbittorrent wget -qO- https://ipinfo.io/ip
In the qBittorrent Web UI, Options → Connection → Port used for incoming connections should show the forwarded port, and the status bar icon should be green (connected).
Other settings, and why¶
| Setting | Value | Why |
|---|---|---|
PORT_FORWARD_ONLY |
on |
Only picks ProtonVPN servers that support port forwarding (the P2P servers). |
SERVER_COUNTRIES |
from .env |
Limits the server choice. Without it Gluetun picks P2P servers worldwide. |
HEALTH_TARGET_ADDRESSES |
1.1.1.1:443,8.8.8.8:443 |
Gluetun dials these over TCP+TLS every 5 minutes and restarts the tunnel if two tries fail. The default targets are hostnames (cloudflare.com:443,github.com:443), so a short DNS-over-TLS hiccup can fail the check, restart the VPN and drop qBittorrent's connections (and possibly its port) with it. I added IP targets to stop that cascade; they don't depend on DNS. |
HEALTH_VPN_DURATION_INITIAL |
30s |
Still in my compose, but obsolete as of v3.40. Gluetun ignores it and logs a warning. Safe to drop. |
healthcheck.start_period |
120s |
The image default is 10 s. When the first servers fail Gluetun's startup check it cycles to the next ones, which took about 40 s once. Docker marked the container unhealthy before that, and compose then refused to start qBittorrent (depends_on: condition: service_healthy). Failures inside start_period don't count, and the first passing check marks it healthy straight away. |
UPDATER_PERIOD |
168h |
Refreshes Gluetun's server list weekly (upstream default 0 = off). My note on it just says weekly "due to qBittorrent issues"; the details weren't written down. Upstream notes each refresh is a burst of DNS-over-HTTPS requests through the tunnel, so less often is gentler. |
BLOCK_MALICIOUS |
off |
Upstream default is on (a DNS blocklist of malicious hosts for everything in the tunnel). Off is a choice here, not a requirement. Upstream notes the server-list updater's DNS-over-HTTPS burst "may be blocked" with it on. If you turn it on, watch the updater logs. |
restart |
always |
Same as the rest of the stack. |
Additional Info¶
Run docker compose up -d to start it.
Before the port update can work, qBittorrent must accept the API call from Gluetun without a login. Gluetun calls it from 127.0.0.1 inside the shared network stack:
Login to the Qbittorrent WebUI (the first-run password is in docker logs qbittorrent).
- Settings > WebUI tab
- Set user/pass
- Check 'Bypass authentication for clients on localhost'
- Leave the subnet whitelist off, and keep CSRF protection and host header validation enabled
- Save.
Restart the stack, check the container logs, and it should work.
You can test a download from here https://webtorrent.io/free-torrents.
Autoheal
qBittorrent carries the autoheal=true label, so Autoheal restarts it if its healthcheck fails (for example if the network stack it shares with Gluetun goes away after Gluetun is recreated).