On this page
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.
This is the story of how that happened, what I found when I went looking for a tool that would have caught it, and the small checker I ended up writing instead.
Every address, port, and name below is a placeholder. Swap in your own before you run anything: 10.0.0.x for your own LAN addresses, homelab.lan for your own internal domain, 203.0.113.42 wherever a public VPN address appears, 51413 and 6881 for your own ports, and CHANGE_ME for any secret, which belongs in your password manager and never in a note. If a value looks specific to one machine, it is a placeholder to change, not a literal to copy.
The alert that fired once, then went quiet
I found it by accident. I went to check how a 4K re-download batch was progressing and noticed the queue had not moved. Not slowed down — stopped, at zero, for four days.
The cause sat upstream of qBittorrent entirely. 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, and every new magnet link sat in metaDL waiting for metadata that was never going to arrive.
There is a second half to this, and it matters more than the fix does. My assumption, once I understood the port, was that nothing had detected any of it. That assumption was wrong. A check did notice, and it did notify me, on the day it broke. Two alerts reached my phone on the 21st. I glanced at them between other things and did not take in what they meant.
Then it went quiet for four days, because it only ever spoke on a change of state. The monitor fired once on the transition from working to broken and never spoke again, because nothing changed after that. It stayed broken. Four days of silence read exactly like four days of fine.
This is the same shape as a problem I have hit before with this stack: in the self-healing arr stack it was rotated API keys, where a rejected request returned an HTTP 401 that nothing crashed on. Different cause, identical silence.
This is the bit to steal even if you never touch a torrent client. 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 generates exactly as much noise as a system that is working perfectly. The related trap is monitoring the wrong thing entirely, which is how backups fail silently for a week and how a container looks healthy from the wrong network namespace.
What a port syncer actually proves
My first instinct was that I had simply missed a tool. The gluetun and qBittorrent combination is extremely popular, the port-forward problem is well known, and there are at least half a dozen open-source tools that solve it.
So I read them. They do exactly what they advertise, and they do it well: they poll gluetun’s control server for the current forwarded port and write it into qBittorrent’s listening-port setting. qSticky is a good representative — it monitors for port changes, updates qBittorrent, and keeps a health file recording that it is still running and can still reach both APIs.
That last part is worth saying precisely, because it is the whole gap. Its health check confirms that it can reach each service’s 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. None of the others do either. They are synchronisers, and a synchroniser that finds the numbers already equal has nothing left to report.
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.
It asks five things, and the interesting part is which data it uses for each.
| 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 Web API exposes a connection_status that is exactly one of connected, firewalled, or disconnected, documented in the official WebUI API reference. 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. You do not need an external port-checking service, and you do not need to trust a third party with your address.
The tunnel check uses gluetun’s own control server, which exposes the current public address at /v1/publicip/ip and the forwarded port at /v1/portforward, both documented in the gluetun control server wiki.
This caught me while I was building it. Current gluetun versions make every control-server route private by default. There are no publicly readable endpoints any more. 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 a healthy run, and then 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
[ok] port_agreement both use port 51413
[ok] reachability connected
[ok] traffic watching: not enough history yet
[ok] overall
$ 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
Note the two honest bits in there. The tunnel is genuinely fine in both cases, because the tunnel was never the problem. And traffic says “not enough history yet” rather than pretending to know: a single snapshot cannot tell you whether bytes moved over fifteen minutes. One-shot runs answer four of the five questions. The fifth needs the daemon.
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
This is the design decision the whole thing rests on, and it is the reason I could not just alert on “download speed is zero”.
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 will be 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: no torrent is waiting on data” 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.
Two smaller things fall out of getting that right. A counter that goes backwards means qBittorrent restarted and reset its session counter, which is not a stall, so that is treated as progress. And a check that fails once is reported as a warning rather than a failure until it has failed several samples in a row, so a single blip during a VPN reconnect does not wake anyone.
That gating is the difference between a monitor you keep and a monitor you silence.
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 rather than a view from somewhere more comfortable.
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. I have pinned :0.1.1 above rather than :latest deliberately: a watchdog that changes under you without warning is worse than one that is slightly behind.
If you would rather not run another container, the one-shot form works from cron or from any monitor that can run a command and read an exit code. Each release ships a static binary, which needs no runtime at all and is the easier route on a NAS:
curl -LO https://github.com/peiralabs/deadair/releases/latest/download/deadair-x86_64-unknown-linux-musl
curl -LO https://github.com/peiralabs/deadair/releases/latest/download/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
chmod +x deadair-x86_64-unknown-linux-musl
Swap x86_64 for aarch64 on ARM. If you would rather build it yourself, cargo install --git https://github.com/peiralabs/deadair --locked does the same job.
Then it is whatever your scheduler already understands:
DEADAIR_GLUETUN_APIKEY=CHANGE_ME \
DEADAIR_QBT_URL=http://10.0.0.20:8080 \
deadair check
echo $?
The numbers I actually graph
Running it as a daemon adds a Prometheus endpoint, which is where it earns its place next to an existing Prometheus and Grafana setup. The exposition is deliberately small. Again, this is real output:
# HELP deadair_check Health check severity.
# TYPE deadair_check gauge
deadair_check{check="probe"} 0
deadair_check{check="tunnel"} 0
deadair_check{check="port_agreement"} 2
deadair_check{check="reachability"} 2
deadair_check{check="traffic"} 0
# HELP deadair_level Overall health severity.
# TYPE deadair_level gauge
deadair_level 2
# HELP deadair_forwarded_port VPN forwarded port.
# TYPE deadair_forwarded_port gauge
deadair_forwarded_port 51413
# HELP deadair_listen_port qBittorrent listening port.
# TYPE deadair_listen_port gauge
deadair_listen_port 6881
Levels are 0 ok, 1 warn, 2 fail, which makes deadair_level the single number worth putting on a dashboard and alerting on. One deliberate choice worth copying in your own exporters: when a value is genuinely unknown, the metric line is omitted entirely rather than reported as zero. A fake zero on a byte counter looks exactly like a counter reset and will quietly corrupt any rate() you build on it.
What I would still do by hand
Honesty about something this new matters more than making it sound finished, so: this is version 0.1.1. The test suite and container build pass in CI, and I have run it against mocked gluetun and qBittorrent endpoints covering healthy, firewalled, port-mismatch, legacy-endpoint, and both authentication paths. That is a long way from “running in a hundred homelabs for a year”.
Things it does not do, on purpose or otherwise:
- It does not fix anything. It will not sync your port, restart a container, or touch a qBittorrent setting. Keep your port syncer.
- It does not test the port from the public internet. It infers reachability from qBittorrent’s own connection status, which is free and private but is a second-hand signal rather than an outside-in probe.
- It only knows gluetun and qBittorrent. Other VPN containers and other clients would need new probes.
And the manual check I still run first when downloads look wrong, which no tool replaces: confirm the container’s public address is actually the VPN’s and not my own. If the tunnel dropped, every other reading is measuring the wrong network.
The broader habit is the part worth keeping even if you never run this particular binary, and it has two halves.
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. deadair_level sits at 2 for as long as the stack is broken, and /healthz answers 503 every single time you ask it, however long it has been wrong. Neither depends on my having noticed a phone notification on a Monday afternoon.
Related posts:
- I Got Tired of a Dozen Browser Tabs, So I Built Peira — the native control panel for the same homelab this checker watches over, built around the same “configured is not working” lesson.
- The Self-Healing Arr Stack: Why My Watchlist Silently Stopped Downloading — the same silence from a different cause, plus the dead-man’s-switch pattern that catches automation which stops running entirely.
- Deploy the Arr Stack with Docker Compose (Prowlarr, Radarr, Sonarr) — where the gluetun and qBittorrent pairing described here gets set up in the first place.
- My Backups Silently Failed for a Week — the identical failure shape in the backup stack: every component reporting success, nothing checking the result.
- The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime — what happens when a health check looks at the service from the wrong network namespace.
- Proxmox Monitoring with Prometheus and Grafana: Full Stack Setup — the stack the metrics above plug into.
- Green Lights, No Packets: Common Homelab Network Issues — more cases where the indicators are healthy and the traffic is not.
Comments
Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.