If qBittorrent is sitting at 0 KB/s while your port-sync tool reports success, the sync is not the thing to check. A forwarded port that matches is a statement about your configuration, not about your traffic, and the two can disagree for days without anything raising its voice. Mine disagreed for four.
Cross-post from Peira Labs. Full version: peira.dev/articles/qbittorrent-stalled-vpn-port-check
The alert that fired once, then went quiet
My VPN container failed its own health check and restarted itself. On the way through, its port-forwarding service stopped, cleared the file holding the forwarded port, and then failed to start again. That file sat empty for four days. qBittorrent knew none of this and carried on listening on the last port it had been handed, so nothing outside could open a connection to it.
The part that actually cost me the four days: a monitor did notice, and it did notify me — on the day it broke. Two alerts reached my phone, I half-read them, and then it went quiet. It only ever spoke on a change of state. It fired once on the transition from working to broken and never again, because nothing changed after that. Four days of silence read exactly like four days of fine.
A notification is an event: it happens once, at a transition. Health is a state: it is true or false continuously. When the only thing carrying your signal is an event, a failure that stays failed makes exactly as much noise as a system that is working perfectly.
What a port syncer actually proves
There are at least half a dozen open-source tools that sync gluetun's forwarded port into qBittorrent, and they do it well: they poll gluetun's control server for the current port and write it into qBittorrent's listening-port setting. Their health check confirms they can reach each API. It does not confirm that the tunnel is carrying traffic, that the forwarded port answers from outside, or that a single byte has arrived.
So on the day my port forward died, the correct behaviour for every one of those tools was to report success. They were not broken. They were answering a different question than the one I needed answered.
The five checks I ended up with
I wrote a small thing called deadair to answer the other question. It is a single Rust binary, it reads only, and it changes nothing about your setup.
| Check | Fails when | Where the answer comes from |
|---|---|---|
probe |
gluetun or qBittorrent cannot be reached at all | both APIs |
tunnel |
gluetun reports no public address, or a private one | gluetun /v1/publicip/ip
|
port_agreement |
the forwarded port and the listening port disagree | both |
reachability |
qBittorrent reports firewalled or disconnected
|
qBittorrent connection_status
|
traffic |
torrents want data and zero bytes arrived in the window | qBittorrent dl_info_data
|
The one I am most pleased with is reachability, because it costs nothing. qBittorrent already knows whether anything from the outside world can open a connection to it, and it will tell you in one field. Its connection_status is exactly one of connected, firewalled, or disconnected. firewalled is the client saying, in effect, "I can call out, but nobody can call in." Behind a VPN with port forwarding, that is the dead-port symptom stated plainly — no external port-checking service, no trusting a third party with your address.
One thing that caught me while building it: current gluetun versions make every control-server route private by default. You authenticate with an API key in an X-API-Key header or with HTTP basic auth. If you are following an older guide that curls /v1/portforward with no credentials, that is why you are getting nothing back.
Here is the same stack with a dead port forward. This is real output, not an illustration:
$ deadair check
[ok] probe all probes succeeded
[ok] tunnel 203.0.113.42
[fail] port_agreement forwarded 51413, listening 6881
[fail] reachability firewalled
[ok] traffic watching: not enough history yet; 1 torrents have no seeds
[fail] overall
The exit code is the part that makes it useful to something else: 0 for healthy, 1 for a warning, 2 for a failure, so Uptime Kuma, healthchecks.io, or a plain cron line can consume it without parsing anything.
Why an idle queue must never page you
Download speed is zero most of the time in a healthy homelab. The queue empties, everything finishes. If your monitor pages you every time nothing is downloading, you will mute it within a week — and then it is worth nothing on the day it is right.
So the traffic check gates itself. Before it complains that no data arrived, it asks whether anything wanted data. It counts torrents whose state is one of downloading, metaDL, forcedDL, or stalledDL, and if that count is zero, the verdict is simply "idle" and everything is fine. Only when something genuinely wants bytes does it compare the session's downloaded-byte counter against the value from the start of the window. A counter going backwards means qBittorrent restarted, which is not a stall, so that is treated as progress.
Running it next to gluetun
The deployment detail that matters: run it inside gluetun's network namespace, exactly as qBittorrent does. That is what lets it reach both control servers on localhost, and it means the checker sees the same network the client sees.
services:
gluetun:
image: qmcgaw/gluetun
cap_add: [NET_ADMIN]
ports:
- "9113:9113" # deadair's metrics, published through gluetun
qbittorrent:
image: lscr.io/linuxserver/qbittorrent
network_mode: "service:gluetun"
deadair:
image: ghcr.io/peiralabs/deadair:0.1.1
network_mode: "service:gluetun"
environment:
DEADAIR_GLUETUN_APIKEY: CHANGE_ME
depends_on: [gluetun, qbittorrent]
The image is published for linux/amd64 and linux/arm64, so it runs on an x86 box or an ARM NAS unchanged. It is a static binary in a scratch image and pulls at 3.47 MB. If you would rather not run another container, each release ships a static binary that runs one-shot from cron and returns the exit code above.
The habit worth keeping
The thing you measure has to be the system's output rather than its configuration, because configuration agrees with itself right up until the moment it stops meaning anything. And the signal has to be a state you can re-read whenever you like, not an event that fires once and trusts you to be paying attention on the day. That second half is the one that actually cost me the four days.
Honesty about something this new: this is version 0.1.1, tested against mocked gluetun and qBittorrent endpoints, which is a long way from "running in a hundred homelabs for a year." It does not fix anything — keep your port syncer — and it only knows gluetun and qBittorrent.
Written by Peira Labs — full version, with the Prometheus metrics and the Grafana wiring, at peira.dev.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.