Skip to content

qBittorrent

Runs inside the gluetun VPN tunnel (network_mode: service:gluetun), so it publishes no ports of its own — the Web UI is reachable on gluetun’s 21287.

Config lives on a bind mount at /volume1/docker-ssd/qbittorrent/appdata, mapped to /config. It is not a Docker named volume, so it survives docker compose down -v. It is captured nightly by the stack’s backup job (../backup/), which stops the container for the copy; the manual procedure below remains the right tool for a deliberate pre-upgrade snapshot.

qbittorrent and rustatio both run with network_mode: service:gluetun. Docker binds a shared network namespace at container start and never re-attaches it. When gluetun restarts, it gets a brand-new namespace and both dependants are left in the old, destroyed one — still running, still reporting healthy, but reachable by nothing.

Observed on 2026-08-13:

01:06:48 qbittorrent + rustatio start, join gluetun's namespace
01:06:56 gluetun dies: "getting OpenVPN version: signal: killed"
01:06:58 gluetun restarts (RestartCount=1) -> NEW namespace
dependants: RestartCount=0, still on the dead one

Symptom: curl http://192.168.1.29:21478/ and :21287/ both return nothing (curl exit 56, connection reset). The host publishes the ports and gluetun holds the mappings, so docker-proxy forwards into gluetun’s current namespace and finds no listener. Meanwhile rustatio logs listening on 0.0.0.0:21478 — true, just in a namespace nothing routes to.

Three mitigations are in place:

  1. HEALTH_VPN_DURATION_INITIAL=30s on gluetun (../gluetun/gluetun.yaml). The 6s default is shorter than a cold tunnel takes, so gluetun would kill itself during startup. This addresses the trigger.
  2. Healthchecks that can actually see the fault, plus the deunhealth.restart.on.unhealthy=true label and the deunhealth watchdog (../../host/deunhealth.yaml). Each probe hits gluetun’s control server on 127.0.0.1:8000, which exists only in gluetun’s live namespace — so an orphaned container fails it. A localhost-only probe cannot: rustatio’s built-in check passed throughout the outage above, which is why nothing alerted.
  3. service:gluetun rather than container:gluetun, with depends_on: condition: service_healthy. Compose-native, drops the hardcoded container name, and stops dependants starting before the tunnel is up.

deunhealth recovers the case above — gluetun restarting while keeping its container ID. It cannot recover gluetun being recreated (new ID): the dependants’ stored container:<old-id> reference is then dead and they fail to start at all, exiting 128. That is what left rustatio Exited (128) for eight days. Recovery there is docker compose up -d (which recreates the dependants too), not a restart.

This fragility is inherent to sharing a namespace — every remedy is detect-and-restart, not prevention. Eliminating it entirely would mean running gluetun and its dependants as one unit (a Podman pod, or one container under a supervisor).

This image is pinned to an exact LinuxServer build rather than :latest:

harbor.toolsera.lan/lscr_proxy_cache/linuxserver/qbittorrent:5.2.3_v2.0.14-ls471

The tag encodes three things — qBittorrent 5.2.3, libtorrent v2.0.14, and the LinuxServer build ls471. Pin it deliberately: qBittorrent rewrites qBittorrent.conf into the new format on first start after an upgrade, and older builds will not read a config a newer build has migrated. Upgrades are effectively one-way, which is why this file does not track :latest.

The last bump (5.2.0_v2.0.12-ls4545.2.3_v2.0.14-ls471) was taken with this backup in place:

/volume2/docker-apps-backup/qbittorrent/qbittorrent-appdata-<timestamp>.tar.gz
/volume2/docker-apps-backup/qbittorrent/qbittorrent-appdata-<timestamp>.tar.gz.sha256

The archive lives on /volume2 (pool2) while the source is on /volume1 (pool1), so it sits on independent storage, and it is outside every compose project directory. This destination changed when appdata moved off /home: writing to /volume1/docker-ssd/backups/ would now put the archive on the same pool as the data it protects.

Terminal window
TS=$(date +%Y%m%d-%H%M%S)
DEST=/volume2/docker-apps-backup/qbittorrent/qbittorrent-appdata-$TS.tar.gz
mkdir -p "$(dirname "$DEST")"
tar --exclude=appdata/qBittorrent/ipc-socket \
-czf "$DEST" -C /volume1/docker-ssd/qbittorrent appdata
sha256sum "$DEST" | tee "$DEST.sha256"

ipc-socket is a Unix socket — tar cannot meaningfully archive one and qBittorrent recreates it at startup. Everything else is included, so the archive’s file count should equal find … -type f | wc -l exactly. That equality is the check worth running:

Terminal window
tar -tzf "$DEST" | grep -v '/$' | wc -l
find /volume1/docker-ssd/qbittorrent/appdata -type f | wc -l
gzip -t "$DEST" && echo GZIP_OK

The command above is a hot backup — it runs while the container is up. Config files are consistent enough for a rollback safety net, but a running client can leave BT_backup fastresume state mid-write, so a hot archive is not guaranteed to restore torrent progress cleanly.

For a guaranteed-consistent copy, stop the container first:

Terminal window
docker stop qbittorrent
# ... run the tar above ...
bws run --access-token "$BWS_ACCESS_TOKEN" -- docker compose up -d qbittorrent

Note docker stop on a live host is a deliberate, user-initiated step.

Terminal window
docker stop qbittorrent
tar -xzf /volume2/docker-apps-backup/qbittorrent/qbittorrent-appdata-<timestamp>.tar.gz \
-C /volume1/docker-ssd/qbittorrent
bws run --access-token "$BWS_ACCESS_TOKEN" -- docker compose up -d qbittorrent

The tarball’s root entry is appdata/, so it extracts over /volume1/docker-ssd/qbittorrent and lands back in place. Archives taken before the move to /volume1 have the same appdata/ root and restore the same way — only the -C target changed. Restoring a config means also reverting the image: tag in qbittorrent.yaml to the build that wrote it — see the one-way-upgrade note above.