On this page
- The symptom: silence
- The root cause: a rotated key and a hardcoded copy of it
- Pattern 1: Never hardcode the key — read it at runtime
- Pattern 2: Consolidate — one script that heals, not three that rot
- Pattern 3: A VPN-leak check that finds your home IP by itself
- Pattern 4: A dead-man’s-switch so silence becomes an alert
- Bonus: when a GUI app caches the old key
- The takeaway
I built a fully automated Arr stack: watchlist a movie on my phone, and minutes later it’s downloaded, renamed, and showing in Plex. It worked beautifully — right up until it didn’t, and the way it failed is the most useful thing this stack ever taught me.
For several days, watchlisted titles just never showed up. No error. No alert. No crash. The apps were healthy, the VPN was up, the download client was idle and cheerful. Everything looked fine, and nothing was happening.
This post is the debugging story and — more usefully — the four patterns that came out of it. If you run any kind of Arr automation, these are the changes that turn “works until something rotates” into “heals itself and tells you when it can’t.”
The symptom: silence
The tell was the absence of anything. Movies I’d watchlisted sat un-grabbed. The download queue was empty. Radarr and Sonarr reported healthy. The reconcile cron that’s supposed to sync my watchlist into Radarr was scheduled and — according to cron — running fine every 15 minutes.
The trap here is that a healthy-looking stack and a working stack are not the same thing. Nothing was broken in the sense of throwing an error. The automation had simply, quietly, stopped doing its job.
A service that crashes gets noticed. A cron job that runs, gets rejected, swallows the rejection, and exits 0 will fail forever without a single alert. Most homelab automation fails this way — not with a bang, but with a try/except that eats the error.
The root cause: a rotated key and a hardcoded copy of it
The API keys had rotated. Radarr, Sonarr, and Prowlarr had all been issued new keys, and the only live copies of those keys lived in each service’s own config.xml.
My reconcile script had the old key baked in as a string. Every run, it sent that stale key, the app answered HTTP 401 Unauthorized, and the script’s error handling logged nothing and exited cleanly. Cron saw a successful run. I saw an empty queue.
Here’s the detail that pointed straight at the fix. I actually had three automation scripts. Two of them — the reconcile job and a separate anime-sorting job — hardcoded the key and both died the instant it rotated. The third, a monitor script, kept working perfectly. Why?
Because that one script read the key from config.xml at runtime and talked to the app over 127.0.0.1, where I’d set authentication to “Disabled for Local Addresses.” It never used the stale key at all. The rotation was invisible to it.
That contrast was the design spec for the rebuild.
Pattern 1: Never hardcode the key — read it at runtime
The single source of truth for a Servarr app’s API key is its own config.xml. Read it there, every run, and a rotation can never break you.
import re, pathlib
def arr_api_key(config_xml: str) -> str:
text = pathlib.Path(config_xml).read_text()
# config.xml has a single <ApiKey>...</ApiKey> element
return re.search(r"<ApiKey>([^<]+)</ApiKey>", text).group(1)
RADARR_KEY = arr_api_key("/opt/arr/radarr/config/config.xml")
SONARR_KEY = arr_api_key("/opt/arr/sonarr/config/config.xml")
Even better, if your script runs on the same host as the apps, you may not need the key at all:
In each app: Settings → General → Security, set Authentication Method to Forms (Login Page) and Authentication Required to Disabled for Local Addresses.
Now any request from 127.0.0.1 skips authentication. Your local cron script talks to http://127.0.0.1:7878 (Radarr) or :8989 (Sonarr) with no key at all — nothing to rotate, nothing to leak, nothing to hardcode. (Browser and off-LAN access still require login.)
Any credential your automation copies is a credential that can drift. Wherever you can, read the live value from its source of truth at runtime instead of storing a second copy. A key you never wrote down can’t go stale.
Pattern 2: Consolidate — one script that heals, not three that rot
Three scripts meant three things to keep in sync and three places for a bug to hide. I merged the reconcile job, the re-monitor sweep, and the anime autosort into one script that runs every 15 minutes and does the whole loop:
- Reconcile the Plex/Jellyfin watchlist into Radarr and Sonarr (matching by TMDb/TVDB ID, not title — apps store variant titles like “Guardians of the Galaxy Vol. 2” without the colon, and title-matching silently misses them).
- Re-monitor movies and shows that should be grabbed.
- Sort adopted anime into the right libraries.
- Remediate dead grabs (Pattern 3 below).
- Run health checks and emit a heartbeat (Pattern 4).
One script, one cron entry, one place to reason about. The old scripts became thin shims that just exec the new one, so nothing that referenced them broke.
The nastiest stall: a 4K release grabs, then dies (“no connections” / stalled / missing files), but Radarr thinks the item already meets cutoff — so it won’t grab a live alternative. The queue looks busy; nothing progresses. My script tracks each queue item across runs, and if one is dead for two consecutive runs, it evicts and blocklists it and re-searches — which lets a working release through. (Manual escape hatch: force-grab a specific release with POST /api/v3/release {guid, indexerId}, which overrides the size/cutoff rejection.)
Pattern 3: A VPN-leak check that finds your home IP by itself
My torrent client runs inside a gluetun VPN container, so its traffic should never exit on my home WAN. The classic way to verify that is to compare the client’s exit IP against your home IP — but if you hardcode your home IP, the check silently goes stale the moment your ISP hands you a new address, and now you have a leak detector that can’t detect leaks.
The fix is to auto-detect. The NAS host sits outside the VPN namespace, so it can fetch its own current public IP every run and compare:
# The HOST's own public IP (it's outside the VPN) = your current home WAN:
HOME_WAN=$(wget -qO- https://api.ipify.org)
# The download CLIENT's exit IP (it's inside gluetun):
QBIT_IP=$(docker exec gluetun wget -qO- https://api.ipify.org)
# If they match, traffic is leaking onto your home connection — alert!
[ "$HOME_WAN" = "$QBIT_IP" ] && echo "LEAK: qBittorrent is on the home WAN"
Because HOME_WAN is fetched fresh each run, the check keeps working across ISP IP changes. (You can add an optional CIDR allowlist for an ISP that rotates you within a known block.)
A container restart or NAS reboot can bring the download client up before the tunnel. Bake the leak check into your automation so it runs continuously — don’t rely on a one-time manual check at setup.
Pattern 4: A dead-man’s-switch so silence becomes an alert
Every pattern above still leaves one hole: what alerts you when the script itself dies? A key rotation, a bad deploy, or the whole NAS going down all produce the same thing that started this whole saga — silence.
The answer is a push (dead-man’s-switch) monitor. Instead of a monitor reaching out to check a service, your script checks in every run. If the check-in stops arriving, the monitor alerts on the missing heartbeat. I use Uptime Kuma for this:
Add a new monitor of type Push. Set the heartbeat interval to 1800s — that’s double my 15-minute cron, so a single missed run is tolerated but a truly-dead script alerts within half an hour. Kuma gives you a push URL; store it in your automation’s env file (e.g. KUMA_PUSH_URL).
At the end of each run, the script pushes to that URL. A clean run pushes status=up. Any problem — a VPN leak, an Arr /health error, a queue item stalled over 12h — pushes status=down&msg=<the reason>, so the alert tells me what’s wrong, not just that something is.
# Clean run:
wget -qO- "$KUMA_PUSH_URL?status=up&msg=OK"
# Problem detected:
wget -qO- "$KUMA_PUSH_URL?status=down&msg=VPN_leak_qbit_on_home_wan"
Attach a notification to the monitor so a down (or a missed heartbeat) actually reaches you. ntfy is the easiest: pick a hard-to-guess topic, subscribe to it in the ntfy phone app, and add it as a Notification in Kuma. Now a dead script buzzes your phone like a text. (Telegram, email-to-carrier SMS gateway, Twilio, and SMTP all work too — ntfy is just the least friction.)
Uptime Kuma has no REST API — it’s socket.io only — so scripting monitor creation is painful. Push monitors sidestep this entirely: you create one in the UI once, grab its push URL, and everything after that is a plain HTTP GET from any script or language.
Now the exact failure that started this — automation silently not running — is the one thing that’s guaranteed to page me.
Bonus: when a GUI app caches the old key
If a desktop app (mine is a Flutter dashboard) stores Arr keys in the OS keyring, a rotation leaves it holding a dead key too. Rather than re-enter keys by hand, a small helper reads the live keys from config.xml and updates the keyring item in place — the same “read from the source of truth” principle as Pattern 1, applied to a cached copy instead of a hardcoded one.
The takeaway
The bug wasn’t really the rotated key. The bug was three copies of a secret and no alarm for silence. The rebuild fixes both at the root:
- Read credentials at runtime (or skip them via local-address auth) — nothing to go stale.
- Consolidate to one script that also self-heals dead grabs.
- Auto-detect environmental facts (your WAN IP) instead of hardcoding them.
- Add a dead-man’s switch so “the automation stopped” becomes a push notification, not a week of quiet.
Apply those four and your media stack stops being a thing you babysit and starts being a thing that tells you when it needs you — which, ideally, is never.
Related posts:
- Automating the Arr Stack — the hands-off loop this hardens
- Jellyseerr: Let Your Family Request Movies Themselves — the request front end this keeps healthy
- VPN-Shielded Torrent Stack (gluetun + qBittorrent) — where the leak check lives
- Uptime Kuma: Dead-Simple Homelab Monitoring — the push-monitor tool
- Deploy the Arr Stack with Docker Compose — the stack underneath it all
- ClamAV Download Scan + Auto-Purge — the other unattended-download safety net
- The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime — another field note where “healthy-looking” and “working” disagreed
Sources: Servarr Wiki, Uptime Kuma docs, gluetun, ntfy.
Comments
Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.