Runbook: monitoring, update-notifications & container healthchecks¶
The observability layer, stood up in the 2026-07-14 session. Three complementary tools plus a repo-wide healthcheck pass. Backups are deliberately NOT here — they need a separate, off-box target (planned: restic + Backrest).
Backup scope — MUST include the gitignored
stacks/*/.envfiles. They hold irreplaceable secrets that exist nowhere else (ArcaneENCRYPTION_KEY/JWT_SECRET, Homarr key, Matrix registration token, MQTT passwords, Beszel/Diun tokens, Postmoogle bot password +POSTMOOGLE_DATA_SECRET). Losing them = lockouts and undecryptable data (seedocs/runbooks/arcane-auth-recovery.md). Also back up Postmoogle'sdata/postmoogle.db(mailbox↔room mappings). Until a real backup exists, keep an encrypted off-box copy of the.envfiles.
| Concern | Tool | URL | Stack |
|---|---|---|---|
| "Is it up?" (availability) | Uptime Kuma | up.fmm.house |
stacks/uptime-kuma |
| "How's it doing?" (metrics / btop view) | Beszel | bz.fmm.house |
stacks/beszel |
| "Is there a new image?" (moving tags, notify-only) | Diun | — (no UI) | stacks/diun |
| "Is there a new version?" (pinned tags, PRs) | Renovate | Forgejo dashboard | stacks/renovate |
| Per-container health badges | Docker HEALTHCHECKs |
— | in each compose.yml |
Uptime Kuma (up.fmm.house)¶
- v2.x, SQLite (
data/kuma.db). Admin:albert. - No PostgreSQL support — Uptime Kuma only does SQLite or MariaDB/MySQL for its own storage
(verified in-source:
supportedDBTypes = ["mariadb","sqlite"]). SQLite is correct for a single instance; don't try to point it at the Postgres boxes. - No REST API — monitors are managed over Socket.IO (the web UI) only. The service monitors
were inserted directly into the
monitortable (onlyidis required; all other columns default), then UK was restarted to load them. To insert while UK is running, use its own root shell:docker exec uptime-kuma sqlite3 /app/data/kuma.db "INSERT …"(thedata/dir is root-owned so a host-side write fails), thendocker restart uptime-kumato load it. Back upkuma.dbfirst (docker exec uptime-kuma cp …). - Monitors 1–18 are HTTP(s) against each
*.fmm.houseservice,accepted_statuscodes=["200-299","300-399","401","403"]so auth-gated/redirecting services don't false-down. - Monitor #19
AdGuard DNS (fmm.house :53)(typedns) — a real DNS check: resolvesfmm.house Aagainst192.168.86.49:53every 60 s. This catches AdGuard's DNS being dead even when the HTTP monitor #2 (its web UI) is green — the exact "healthy but dead" gap from the 2026-07-16 reboot (seedocs/runbooks/dns.md§6.4). - Notifications → Matrix "Homelab Updates" (the same room Diun posts to). One
matrixnotifier (notificationid 1,is_default=1so new monitors inherit it) is attached to all monitors. It posts viahttp://continuwuity:8008(UK is onmatrix_default) using@diun's access token — so up/down alerts land alongside the image-update alerts. Wired by DB insert (no UI):loginas@diun→ build config{type:matrix, homeserverUrl, internalRoomId, accessToken}→INSERT INTO notification …+ link every monitor inmonitor_notification→ recreate UK. ⚠️ Token caveat: the stored access token is a@diunlogin session; if it's ever revoked (logout-all / expiry), UK alerts go silent — re-login as@diunand updatenotification.config. A dedicated@uptimekumabot would be cleaner long-term.
Beszel (bz.fmm.house) — hub + agent¶
PocketBase-based; admin identity is an email (albert@fmm.house). Outbound mail (password
reset, alerts) sends via Postmoogle as beszel@fmm.house → Matrix; those SMTP settings + the
appURL live in the PocketBase DB (data/hub/data.db, _params.settings), not compose. The host
shows up as system ascertain-verse.
⚠ Patched hub image —
stacks/beszel/patch/. The hub runs a locally-builtbeszel-hub-patched:<ver>, not stockhenrygd/beszel. Why: the stock login form has a hiddenwebsitehoneypot validated withv.literal(""); Android password managers (Bitwarden/Chrome) autofill it, so validation fails and the login silently won't submit on mobile — a known, still-open upstream bug (#1011), unfixed as of 0.18.7. Our patch flips that schema tov.any()so the field always validates (bots are still caught by PocketBase's auth rate-limiting, and this instance is Tailscale/LAN-only + single-user, so the honeypot bought nothing — verified in the built bundle:website:ut("")→website:ut()). The Dockerfile fetches the pinned source, applies a one-linesedguarded by agrepthat fails the build if the anchor ever moves, builds the web UI + Go hub, and mirrors upstream'sscratchimage. Update flow: bump the version inpatch/Dockerfileand theimage:tag +build.argsincompose.yml, thendocker compose build beszel && docker compose up -d beszel. Retire the patch and return to the stock image once upstream ships a fix (Diun flags the new release). Patch-free workarounds: Firefox on Android, or/_/(PocketBase admin login — no honeypot).
Architecture: beszel (hub, UI on 8090, Traefik-fronted) + beszel-agent (this host). The
agent runs in URL / universal-token mode: it connects out to HUB_URL=http://beszel:8090
with a TOKEN (a universal token) + KEY (the hub's SSH public key) and auto-registers.
Both live in stacks/beszel/.env (gitignored).
To add another host: run a beszel-agent there with the same HUB_URL, KEY, and a
universal TOKEN — it self-registers under its hostname. The hub public key is at
GET /api/beszel/getkey (authenticated); universal tokens live in the universal_tokens
collection.
Beszel is also the btop-style container view — the agent (docker.sock mounted) reports per-container CPU/mem/net for all running containers. It does not track container up/down or Docker health (that's the healthcheck badges + Uptime Kuma).
Diun (stacks/diun) — image-update notifier (no UI)¶
Watches every running container (watchByDefault: true) by image digest, every 6h, and
fans out to two channels (config in stacks/diun/diun.yml, secrets in .env):
- Matrix — posts to the "Homelab Updates" room via bot
@diun:matrix.fmm.house(reaches the homeserver internally atcontinuwuity:8008over thematrix_defaultnet). - MQTT — publishes JSON to topic
docker/diunon mosquitto (overha_default), which the HA automationdiun_image_update_notifyturns into a phone push (notify.mobile_app_albert_s_phone).
mosquitto wiring: a dedicated diun user in stacks/ha/mosquitto/passwd + an ACL entry
(user diun / topic readwrite docker/diun) in stacks/ha/mosquitto/acl. ⚠️ Those are
single-file bind mounts — a Write/Edit that replaces the inode won't reach the
container; append in place or docker restart mosquitto (see gotcha in CONTEXT.md).
Digest vs. new-version: by default Diun notifies when a watched tag's digest changes
(floating tags, rebuilds). Since most images here are hand-pinned to a version, that won't
catch "v1.71.0 is out." For true new-version alerts on a service, add label
diun.watch_repo=true (optionally diun.include_tags=^\d+\.\d+\.\d+$) to its compose service.
docker exec diun diun notif test sends a test through all notifiers.
Renovate (stacks/renovate) — new-version PRs for pinned tags¶
Diun's gap (above) is exactly Renovate's job: it parses the hand-pinned image tags in our
compose files and surfaces available upgrades as a "🐳 Image updates" Dependency Dashboard
issue on Forgejo, opening a PR only for updates you tick. Config: renovate.json (repo root) +
stacks/renovate/compose.yml (renovate/renovate:41, looped every 6h; platform: gitea since
Forgejo is Gitea-API-compatible; endpoint https://git.fmm.house/api/v1). Token + repo list in
stacks/renovate/.env (gitignored). No docker socket needed — it reaches Forgejo + registries
over the network.
⏸ Not deployed yet — blocked on Forgejo setup (needs you): 1. Host the repo on Forgejo. The homelab repo is currently local-only (no git remote). Create
homelabongit.fmm.house(enable Issues — the dashboard is an issue), thengit remote add origin https://git.fmm.house/<user>/homelab.git && git push -u origin main. (Repo is push-safe: only*.example.envare tracked, all real secrets are gitignored.) 2. Generate a token: Forgejo → Settings → Applications → scopeswrite:repository,write:issue,read:user. 3. Put both instacks/renovate/.env:RENOVATE_TOKEN=…andRENOVATE_REPOSITORIES=["<user>/homelab"], thendocker compose -f stacks/renovate/compose.yml up -d. First run creates the dashboard issue;docker logs -f renovateto watch.
Container healthchecks¶
The 2026-07-14 pass added a Docker HEALTHCHECK to every container that could support one
(so the status badge reads healthy instead of none). Each probe was verified to exit 0
inside its container before being applied, and each service was recreated individually. End
state: 0 unhealthy.
Patterns used (see each compose.yml):
- App CLI: beszel /beszel health, beszel-agent /agent health, diun diun healthcheck.
- HTTP: curl -fsS or busybox wget -q against a real 2xx path (forgejo /api/healthz, ocis
/healthz, peertube /api/v1/config, homarr /, adguard :8083/, etc.).
- TCP-only: nc -z localhost <port> (mosquitto 1883, heisenbridge 9898); python socket
(wyoming 10200/10300) or bash /dev/tcp (moonlight-web) where no nc exists.
- redis-cli ping, node -e require('http').get(...) (trmnl byos), etc.
Skipped (3) — no self-contained probe possible; not faked:
- continuwuity — distroless image: no shell, no curl/wget/nc, and conduwuit has no health
subcommand. Would need a probe binary baked into the image.
- controller-watch, trmnl-nowplaying — pure worker sidecars with no listening port; a
real liveness check would require app changes.
To add a check to a distroless service later, bake a tiny static probe into a wrapper image or use a sidecar; don't invent a probe that can't actually run in-container.
Gotcha: a wrong healthcheck marks a working container
unhealthyand can block anything thatdepends_on: condition: service_healthy. Always verify the probe exits 0 first.