Monitoring and alerts¶
This page explains how the stack watches itself: the metrics history in Prometheus and Grafana, the always-on checks in Uptime Kuma, the scheduled health check, the per-minute watchers, and how every alert reaches your phone through ntfy. It is for whoever looks after the server. What each dashboard shows, from a user's point of view, is on Dashboards.
Each piece answers a different question:
| Question | Answered by | When |
|---|---|---|
| How has the server behaved over time? | Prometheus and Grafana | Always, 30 days of history |
| Is every service up right now? | Uptime Kuma | Every 60 seconds |
| Is everything configured the way it should be? | stack-health --notify |
Every 6 hours |
| Is someone transcoding, or signing in from a new device? | activity-watch |
Every minute |
| Will downloads make playback stutter? | downloads-throttle |
Every minute |
| Did a scheduled job fail? | stack-failure-notify |
When a unit fails |
stack-health and Uptime Kuma overlap on purpose. stack-health is the deep check (hardlinks,
VPN binding, firewall, backups, plugins); Kuma is the always-on watcher that tells you when
something broke.
Metrics: Prometheus and Grafana¶
These run in the monitoring module (COMPOSE_PROFILES).
flowchart LR
RA[Radarr] --> SC["scraparr :7100"]
SO[Sonarr] --> SC
PR[Prowlarr] --> SC
BA[Bazarr] --> SC
HOST["The server: CPU, RAM, disks, network, temperature"] --> NE["node-exporter :9100"]
TF["Textfile metrics in STATE_ROOT/metrics"] --> NE
SC --> PROM["Prometheus :9090, 30 days, 4 GB"]
NE --> PROM
PROM --> GRAF["Grafana :3001"]
PROM --> GL["Glance Lab page"]
JDB["jellystat-db"] -->|"grafana_watch_reader, SELECT only"| GRAF
- node-exporter reads the server itself through a read-only mount of
/, including thermal zones. Its textfile collector also exports the metrics the scripts write to$STATE_ROOT/metrics(thestatefolder next toCONFIG_ROOT). That folder is mounted on its own because node-exporter runs asnobody, which cannot pass through the home directory.
| File | Metric | Written by |
|---|---|---|
updates.prom |
arr_image_updates_available, arr_image_updates_checked_timestamp_seconds |
stack-update-check and stack-update (Updates) |
backup.prom |
arr_backup_last_success_timestamp_seconds |
stack-backup (Backups) |
youtube.prom |
arr_youtube_sync_timestamp_seconds |
youtube-sync.py (Glance's YouTube rows) |
The update and backup files are written whole and renamed into place, so a scrape never sees
half a file.
2. scraparr reads Radarr, Sonarr, Prowlarr and Bazarr from one container. It cannot read
environment variables in its YAML, so scripts/configure-scraparr fills the API keys from
.env into $CONFIG_ROOT/scraparr/config.yaml (mode 600). The rendered file holds live keys,
which is why it is written outside the repository.
3. Prometheus scrapes node-exporter every 30 seconds and scraparr every 60 seconds (with a
45 second timeout, because the arr APIs are slow). It keeps 30 days, capped at 4 GB, so
it cannot grow without bound. Scraparr publishes no port; Prometheus reaches it on the Docker
network, and node-exporter, which runs on the host network so it sees the server's own
traffic, at host.docker.internal:9100. Check collection at http://<tailscale-ip>:9090/targets: every row
should be UP.
4. Grafana gets its Prometheus datasource and six dashboards from apps/grafana/ in the repository,
in the Media Stack folder. It re-reads apps/grafana/dashboards/ every 60 seconds, so a new
dashboard JSON appears without a restart. Edits made in the UI are allowed and survive, but for
anything permanent export the JSON into apps/grafana/dashboards/.
5. Glance queries Prometheus for its Lab page: disk space and growth, pending updates and the
age of the last backup.
The watching dashboard¶
"Media Server: Watching" reads Jellystat's recorded history from its Postgres database, not live
Jellyfin sessions. scripts/configure-grafana-watch.py connects it with the least access that
works (it needs both the monitoring and stats modules):
- It creates the Postgres role
grafana_watch_readerwithSELECTon eight columns ofjf_playback_activityonly (Id,UserId,UserName,NowPlayingItemName,SeriesName,PlaybackDuration,ActivityDateInserted,PlayMethod). Client IPs and other playback metadata stay unreadable. - The role defaults to read-only transactions and a 10 second statement timeout.
- Its generated password goes only into Grafana's encrypted datasource settings
(
jellystat-readonly)..envis not changed, and no database port is published. - It refuses to continue if the role has elevated rights or the existing datasource points elsewhere, then verifies that exactly the intended columns are readable and that no write privilege exists. It reports permission drift rather than silently revoking grants.
- Rerunning is idempotent. If the datasource is missing, it generates a new password and recreates the connection.
Offline tests: python3 -m unittest discover -s tests -p 'test_grafana_watch.py' -v. Anyone who can
query this datasource in Grafana sees all the permitted viewing records; there is no per-user
isolation.
Uptime Kuma: is it up right now?¶
Uptime Kuma (:3003, monitoring module) checks every service once a minute. After three failed
checks in a row (one failure plus two retries, 60 seconds apart) it sends DOWN to ntfy, and
UP when the service recovers. The history is on the status page /status/stack.
scripts/configure-uptime-kuma.py sets it up, and derives the monitors from docker-compose.yml:
- One HTTP monitor per published TCP port of every service in an active module, at
http://<service>:<port>on the Docker network, with a health path where the app has one (/pingfor Navidrome,/api/healthfor Needle,/healthzfor SFTPGo, and so on). Kuma's own port and SFTP (2022) are skipped. gluetun's published ports become qbittorrent and slskd, since both answer through the VPN container. - Services without a published port: Byparr and scraparr over the Docker network, Plex and
node-exporter through
host.docker.internal(both run on the host network). Each only when its module is on. - media drive mounted: node-exporter's metrics must mention the
STORAGE_MOUNTmount point. - vpn forwarded port: gluetun's
/v1/portforwardmust report a port above 0. Proton sometimes stops handing one out while the tunnel stays up; torrents keep working but get no incoming peers and slow down, which no other check notices. gluetun's control server opens only that one read-only route, without credentials, and only to containers on the stack's network (apps/gluetun/auth.toml).vpn-port-syncusually repairs it on its own (VPN and ports). - gluetun healthy: gluetun's own container health check, read through the read-only Docker socket proxy.
The script is idempotent. Re-run it after switching a module on or adding a service, and the new one gets a monitor; a service removed from compose loses its monitor. Monitors you add by hand in Kuma are never touched. It also:
- creates the admin account and sets its password from
UPTIME_KUMA_USERandUPTIME_KUMA_PASSWORD. Kuma refuses weak passwords at setup, so the.envpassword is written as a bcrypt hash straight into Kuma's database, fed through stdin so it never shows in a process list; - adds the ntfy notification as the default for every monitor;
- turns off Kuma's DNS cache (nscd), which kept a recreated container's old address and reported it down for good after every restart or update;
- builds the status page with every monitor on it.
Kuma 2 has no REST API, only socket.io, so the script re-runs itself in a small virtual
environment in $STATE_ROOT/venv with python-socketio.
The health check every 6 hours¶
arr-health.timer runs scripts/stack-health --notify at
00:20, 06:20, 12:20 and 18:20 (up to 10 minutes later), as the stack user, only while the media drive
is mounted: the whole stack is down while the drive is off. Any failing check is pushed to ntfy as
"media stack: N health check(s) failing", listing each one.
It prints sections with an OK or FAIL per check and exits 0 only when nothing failed. Checks of a
module that is switched off are skipped. A few of the less obvious ones, and why they exist:
| Section | Check | Why |
|---|---|---|
| STORAGE | SMART health, no bad sectors, under 55 °C | Any reallocated, pending or uncorrectable sector means failure is approaching. A drive behind a USB bridge needs smartctl -d sat |
| HARDWARE | Not throttled or under-voltage, SoC under 80 °C | Only where vcgencmd exists (a Raspberry Pi). The low four bits of get_throttled mean under-voltage, frequency capped, throttled or soft temperature limit, right now |
| SYSTEMD | Every container has a memory limit | A container created while the memory cgroup was unavailable silently loses its limit, and compose won't recreate it later because its config is unchanged |
| BACKUPS | Last backup under 48 hours old | Only while the drive is on, since it is powered by hand |
| SERVICES | Jellyfin plugins all Active | A Jellyfin update can disable plugins built for an older version without anything else failing |
| SERVICES | Radarr, Sonarr, Lidarr, Prowlarr report no errors | Errors each app raises about itself: unreachable indexers or download clients, missing root folders |
| MONITORING | Homepage sees container status; the socket proxy refuses writes but allows log reads | Homepage's status dots go through the proxy; if it breaks, every tile shows "API error". Dozzle needs log reads |
| CLEANUP | Cleanuparr's download cleaner off | It removes seeding downloads, which cost no disk here (hardlinks) |
| INDEXERS | Byparr healthy | Its /health launches a real browser and takes 15 to 20 seconds, so the container's own health result is read instead |
| VPN | qBittorrent bound to the tunnel, upload cap in both modes, forwarded port in sync | P2P must never leave the VPN (VPN and ports) |
stack-health is not read-only: it writes a hardlink test file on the drive and starts throwaway
curlimages/curl containers. Keep it for validating changes, not for a look-only investigation. Run
it first whenever something seems off: scripts/stack-health (the health alias).
activity-watch: things someone should know about¶
arr-watch.timer runs scripts/activity-watch every
minute (from three minutes after boot, only while the drive is mounted). Each alert fires once;
what was already reported lives in $STATE_ROOT/watch-activity.json. The first run only records
the devices and log entries that already exist, so installing it does not flood your phone. If
Jellyfin or Seerr does not answer, the run ends quietly and tries again a minute later.
| Alert | Fires when |
|---|---|
| "Name is transcoding" | A Jellyfin stream is transcoded in software. The server may not keep up, so it can stutter. The message names the title, device and Jellyfin's reason. Hardware transcodes (compose.gpu.yml) are cheap and expected, so they don't alert |
| "New device on Jellyfin" | A device Jellyfin has never seen signs in |
| "Failed Jellyfin login" | Jellyfin's activity log records a failed sign-in |
| "Request still not downloaded" | A Seerr request approved more than 48 hours ago is out (Radarr says the movie is available; Sonarr has aired episodes) but nothing has downloaded. Films not yet released are skipped: Radarr waits for them on purpose. It may not be on any indexer |
downloads-throttle: keeping playback smooth¶
At full speed (about 90 MB/s) decrypting the WireGuard tunnel takes most of the CPU; playback
stutters and qBittorrent drops the arr apps' requests. At 40 MB/s the VPN alone kept the CPU about
90% busy. arr-throttle.timer runs
scripts/downloads-throttle every minute to prevent that:
- The cap is qBittorrent's alternative download limit, 20 MB/s. It switches on while anyone plays something in Jellyfin or Plex, or while the 5 minute load average reaches twice the number of CPU cores. It switches off once nobody is watching and the load is under the number of cores. On a four-core Raspberry Pi 5 that is on at 8, off under 4.
- The upload cap is permanent: 10 Mbps in both the normal and the alternative mode.
- Every run re-applies both limits if they drifted.
- A limit you switch on by hand is left alone. The script only switches off a limit it switched
on itself (it marks that with
$STATE_ROOT/throttle-by-script).
Downloads are otherwise unlimited by choice. This cap and vpn-port-sync's VPN restart are the only
things allowed to act on their own; don't add other limits without deciding to. stack-health
confirms the alternative limit is set (not qBittorrent's 10 KiB/s default, which would stall
downloads) and reads the upload cap from downloads-throttle --upload-cap rather than keeping its
own copy.
stack-failure-notify: when a scheduled job fails¶
The units below have OnFailure=arr-notify-failure@%n.service. When one fails,
scripts/stack-failure-notify pushes "media stack: unit failed" with the unit's last eight log lines
and the journalctl command for the rest.
| Unit | Job |
|---|---|
arr-backup.service |
The nightly backup (Backups) |
arr-updates.service |
The daily update check (Updates) |
arr-firewall.service |
The host firewall (Security) |
arr-stack.service |
Starting the stack (Storage and boot) |
arr-youtube.service |
The hourly sync of Glance's YouTube rows |
Every alert into ntfy¶
flowchart LR
KUMA["Uptime Kuma, every minute"] --> TOPIC(("ntfy topic"))
CU["stack-update-check, daily"] --> TOPIC
UPD["update, by hand"] --> TOPIC
HC["stack-health --notify, every 6 h"] --> TOPIC
WA["activity-watch, every minute"] --> TOPIC
SP["vpn-port-sync, every 15 min"] --> TOPIC
UNITS["arr-backup, arr-updates, arr-firewall, arr-stack, arr-youtube"] -->|"OnFailure"| NF["stack-failure-notify"]
NF --> TOPIC
ARR["Radarr, Sonarr, Lidarr, Prowlarr"] --> TOPIC
SEERR["Seerr"] --> TOPIC
JD["JellyDash"] --> TOPIC
QA["Questarr"] --> TOPIC
TOPIC --> PHONE["ntfy app on your phone"]
| Alert | Source | Set up by |
|---|---|---|
| A service DOWN after three failed checks, UP when it recovers | Uptime Kuma | configure-uptime-kuma.py |
| "N update(s) available", each update once, MAJOR flagged | stack-update-check (arr-updates.timer) |
Updates |
| "update failed", "health check failed after update" | stack-update |
Updates |
| "N health check(s) failing", including the media drive's SMART health, bad sectors and temperature | stack-health --notify (arr-health.timer) |
This page |
| Software transcode, new Jellyfin device, failed Jellyfin login, request stuck 48 hours | activity-watch (arr-watch.timer) |
This page |
| "VPN forwarded port restored", "VPN still has no forwarded port" | vpn-port-sync (arr-port-sync.timer) |
VPN and ports |
| "unit failed", with its last log lines | stack-failure-notify |
This page |
| Health errors, a download that needs a manual import, failed downloads or imports | Radarr, Sonarr, Lidarr, Prowlarr | configure-arr.py |
| A request is available to watch, or failed to reach Sonarr or Radarr | Seerr | configure-seerr.py |
| Someone starts watching (any user), a new Seerr request | JellyDash (NTFY_URL and NTFY_TOPIC in compose) |
Dashboards |
| "Game Available", "Download Started" | Questarr | configure-questarr.py, see Games |
Seerr leaves new requests out on purpose, because JellyDash already pushes them.
Setting up ntfy¶
- Install the free ntfy app (iPhone or Android), tap +, and subscribe to the topic in
NTFY_TOPICon the server inNTFY_SERVER(defaulthttps://ntfy.sh). - Treat the topic as a password: anyone who knows it can read the channel, which carries user
names, titles, check names and log lines. To keep them off a public server, run your own ntfy
and point
NTFY_SERVERat it. - With no
NTFY_TOPIC, every script skips its notifications silently.
Changing the topic later: the scripts and Seerr and Questarr pick it up on their next run, and JellyDash when its container is recreated. Uptime Kuma and the arr apps keep the ntfy settings they were given the first time, because their configure scripts only check that one exists; change the topic in their notification settings by hand.
Scripts and units involved¶
| Name | Role | Reference |
|---|---|---|
configure-uptime-kuma.py |
Admin, ntfy, monitors from compose, status page | scripts |
configure-grafana-watch.py |
Least-privilege Jellystat reader for Grafana | scripts |
configure-scraparr |
Fills scraparr's config with API keys, outside the repo | scripts |
stack-health |
The deep check; --notify pushes failures |
scripts |
arr-health.timer / .service |
Every 6 hours, with the drive mounted | systemd |
activity-watch |
Transcodes, new devices, failed logins, stuck requests | scripts |
arr-watch.timer / .service |
Every minute, with the drive mounted | systemd |
downloads-throttle |
The 20 MB/s cap and the permanent upload cap | scripts |
arr-throttle.timer / .service |
Every minute, with the drive mounted | systemd |
stack-failure-notify |
Pushes a failed unit's last log lines | scripts |
arr-notify-failure@.service |
The OnFailure= handler |
systemd |
stack-env.sh / stack_env.py |
The shared notify helpers and write_update_metrics |
scripts |
When it goes wrong¶
- Kuma says a service is down, but it runs. See Uptime Kuma says a service is down, but it's running.
- "vpn forwarded port" DOWN. See No forwarded port at all.
- Playback or the disk is slow while downloading. See
Slow playback or disk while downloading;
journalctl -u arr-throttleshows when the cap switched. - "byparr (cloudflare solver) healthy" fails. See Byparr healthcheck fails.
- Dozzle can't open logs, or every Homepage tile shows "API Error". The socket proxy refused
the request; see
Dozzle lists containers but can't open their logs
and
docker logs homepage | grep -i sock. - A Prometheus target is DOWN. Open
http://<tailscale-ip>:9090/targets; for scraparr, rerunscripts/configure-scraparrafter an API key changed. - For anything else, start with Troubleshooting.