Hardening backlog¶
A running list of safety and observability defects found while doing other work: things that were quietly wrong rather than loudly broken. Append a finding the moment you hit one, even if it is not the job you are on. A false green is worth more attention than a red, because it actively buys confidence.
A finding earns a place here when it meets one of these bars:
- A check that can pass without having run. The worst class. A green that means "nothing looked" is indistinguishable from a green that means "everything is fine".
- Documentation that asserts a safety property the cluster does not have. Someone reading it makes a wrong decision with full confidence.
- A guard whose empty or degraded state is fail-open. The gate that lets everything through the moment it is unhealthy.
Conventions: number entries H-NN in order found, never reuse a number, and mark resolved
entries rather than deleting them. The resolved list is the pattern library for the next one.
Each entry records where it was found, the mechanism, and what closes it. Where a fix closed one
instance but not the class, say so; the class is what stays open.
Open¶
H-23: Flux commit statuses are missing for about 40% of Kustomizations on most commits¶
Found: 2026-10-18, while deciding whether NotificationDispatchFailed warnings were noise.
Each namespace's github-status Alert posts a kustomization/<name> commit status for every
cluster-apps Kustomization, and the notification-controller lists the commit's statuses before
each post. About 265 Kustomizations reconcile on every new main revision, so each commit fires
hundreds of GitHub API calls at once and trips the secondary rate limit (403 You have exceeded a
secondary rate limit). Those statuses never land: the contexts missing from a commit
are the Kustomizations its failures name. VictoriaLogs
shows it on every day of its retention (from 2026-09-20), in bursts of 100 to 230 failures touching
about 110 to 180 Kustomizations, not only after merge bursts. Of 40 main commits since
2026-10-14, 25 carried 149 to 191 kustomization/* statuses instead of the full 262 or 263. Each
failure also lands as a Warning event, the largest source in just kube warnings:
NotificationDispatchFailed on the github-status Alert.
Mitigation in place: none needed for gating. No ruleset on main requires a
kustomization/* context, and the alertmanager Alert still reports reconcile errors. The cost is
that a commit's status list cannot be read as "what deployed": a missing context means "not
posted", not "not applied".
What would close it: fewer status posts per commit. Either scope the github-status Alert to a
few root Kustomizations, or drop it if nothing reads the statuses. Then the warning count should
fall to zero and every commit should carry the full set.
H-21: A comment claimed NextDNS is enforced on every pod except one, which is not true¶
Found: 2026-09-28, while trimming comments on media/dispatcharr.
A comment on media/dispatcharr's DNS_KEEP_NAMESERVER setting said taking that one pod off
estate DNS "leaves NextDNS fully enforced for every other device". downloads/imgur-proxy also
does not use estate DNS for its proxied traffic: its nginx config sets resolver 127.0.0.1 and
gluetun's own DoT resolver is on (downloads/imgur-proxy/app/helmrelease.yaml:77-88), so imgur
lookups go out the tunnel the same way dispatcharr's do, by a different mechanism (an nginx
directive instead of DNS_KEEP_NAMESERVER). The claim predates this comment sweep; carried
forward into docs/docs/apps/dispatcharr.md at first, then qualified there once Codex caught it.
Mitigation in place: none needed for safety, since NextDNS's ad/malware filtering not covering this one proxy's fetches was already the point of imgur-proxy's own setup. The risk is purely that the original claim could lead someone to assume NextDNS covers more of the fleet's egress than it does.
What would close it: an accurate one-line note near DNS_KEEP_NAMESERVER on both apps (or a
shared page) naming every gluetun sidecar that bypasses estate DNS and by what mechanism, so the
next person doesn't have to re-derive the exception list from source.
H-3: The Renovate review gate fails open on error, and its token expires¶
Found: 2026-06-03 while building .github/workflows/renovate-review.yaml; accepted by design.
APPROVED posts success and CHANGES_REQUESTED posts failure, but if the Claude step itself
errors (API blip, rate limit, timeout) the claude/renovate-review status passes through so
a transient blip does not wedge auto-merge. The OAuth token behind it has a one-year,
non-refreshable lifetime, generated 2026-06-02 and expiring 2027-06-02. A lapsed token is an
error, so a lapsed token lets every in-policy Renovate PR auto-merge unreviewed.
Mitigation in place: a preflight step validates the token with a live call and turns a 401 into a red run. 1Password Watchtower on the token's item is the proactive alarm.
What would close it: a scheduled check that fails loudly (not the PR workflow) when the token is within 30 days of expiry, or a policy that treats "errored" as "not approved" for the high-blast-radius set only. See Renovate auto-merge.
H-5: A probe-killed graceful app is invisible to the alert named after the problem¶
Found: 2026-09-10, litellm restarted 62 times in two hours with no crashloop alert.
LiteLLM handles SIGTERM gracefully, so a liveness kill mid-boot exits 0 / Completed.
CrashLoopBackOff never triggers, so KubePodCrashLooping never fires, and a container that lives
about 100 s resets the backoff. Only a vague KubePodNotReady fired. Fixed for litellm in #5058
by raising the probe's initial delay to cover the measured 94 s cold boot.
Still open, the class: nothing alerts on a restart rate independent of the termination
reason. Any app that exits cleanly on SIGTERM can loop this way unseen. A rule on
increase(kube_pod_container_status_restarts_total[1h]) > 3 regardless of reason would close it.
H-6: Backup-metric absence is checked per box, not per tier¶
Found: 2026-08-03, verifying the docker-backup textfile hooks (#4087).
DockerBackupMetricsMissing asks absent(...{box="seedbox"}). The hourly NFS tier satisfies it,
so a permanently broken offsite (r2) tier on the same box alerts nothing, which is exactly the
silent death the rule exists to catch. DockerBackupStale cannot cover it either: a series that
never appears is not stale. Backrest also silently discards unknown config fields, so a config
that parses proves nothing about hooks being wired; verify by triggering a backup and reading the
side effect.
What would close it: per-box-and-tier absent() selectors, plus a check after each box's
first scheduled offsite run that the tier="r2" series exists.
H-8: The CI render gate is lenient by construction¶
Found: 2026-06-13, testing whether Konflate could replace the flate workflow.
The Konflate commit status fails only on a total render error. Per-resource render failures
post success, and it does not schema-validate: a nonexistent chart tag and replicas:
"two-not-an-int" both passed. It was kept advisory and flate v0.3.x stayed the strict gate. The
flate GitHub Actions workflow was later removed (#3375), so Konflate is now the only render check
on a PR, with the local just kube flate-test as the strict check nobody is forced to run.
What would close it: either a strict render job back in CI, or confirmation that the current Konflate version rejects an invalid manifest, tested with a deliberately broken PR (a known-bad control). Until one of those is done, "Konflate is green" means "it rendered", not "it is valid".
H-9: An operator-rendered rollout can be skipped with nothing reporting it¶
Found: 2026-09-03, after the Rook v1.20.6 to v1.20.7 bump (#4870).
Rook updated the CSI operator's image-set ConfigMap, but the Driver CRs reference that
ConfigMap by name and the ceph-csi-operator does not watch it, so all eight CSI plugin pods stayed
on the old cephcsi. The previous bump had been missed the same way; the CephFS nodeplugins were
55 days old. Nothing compares the declared image set to the running pods.
Remedy per bump: kubectl -n rook-ceph rollout restart deploy/ceph-csi-controller-manager
(see upgrade playbooks).
What would close the class: a rule comparing kube_pod_container_info image tags in
rook-ceph against the image-set ConfigMap, or a post-merge health check that does the same. The
same shape exists anywhere a controller only re-renders on CR events.
H-10: A completed Talos upgrade can revert and the CLI still exits 0¶
Found: 2026-09-03, five times, upgrading to 1.14.0.
If the reboot sequence's volume teardown fails (a loop device holding the encrypted EPHEMERAL
volume open), machined's fatal-error handler reverts the bootloader to the old UKI and the node
boots the old version. talosctl upgrade --wait returns 0 because it only waits for Ready.
tuppr does catch it (version mismatch), so the gap is only in the manual path.
What would close it: the manual upgrade recipe in talos/mod.just reading back
osImage after the reboot and failing on a mismatch. The loop-device detach that prevents the
revert is in Talos upgrades.
H-11: The Renovate gate can re-post a verdict it did not compute¶
Found: 2026-09-14, on external-dns #5123.
The shared review workflow skips the model and re-posts the prior verdict whenever the package
versions in the diff are unchanged since the last review. When the fix for a CHANGES_REQUESTED
belongs on main (a chart value, a bootstrap helmfile) rather than in the PR, landing it and
rebasing changes nothing the gate looks at, so the PR stays red on a verdict from before the fix.
The status reads as a fresh review and is not.
Workaround: verify the fix at the point of consumption, then admin-merge. What would close
it: the fingerprint including the base SHA of the files the review cited, so a fix on main
invalidates it. Lives in shared-workflows.
H-12: ShellyDeviceOutOfSync cannot distinguish drift from unreachable¶
Found: 2026-07-14, the Shelly firmware 2.0.0 nonce-throttle incident.
The operator's inSyncValue() maps an Unknown condition (config fetch failed, HTTP 429) to 0,
the same as False (drifted). The alert then claims config drift with driftedSections empty.
The churn that caused the 429s was fixed in shelly-operator v0.3.2, but the conflation remains, so
the next unreachable device will page as drift.
What would close it: a state or reason metric from the operator and an alert expression that
excludes Unknown. Lives in the shelly-operator repository.
H-13: The Scrypted page documented an app that was never commissioned¶
Found: 2026-08-25.
docs/docs/apps/scrypted.md described a working NVR design. The live app had only the core plugin
installed and no admin user since deploy. The page recorded intent as if it were state.
What would close it: a Status line at the top of every app page stating what is verified live and when, the way the KB entries already do. Not yet applied to any app page, scrypted included.
Resolved¶
Mark, do not delete. Each one is a pattern that will recur in a different place.
H-22: tracearr-postgres had no object-store backup (resolved 2026-10-09)¶
Found: 2026-10-09, independent review of the PR adding tracearr's dedicated TimescaleDB cluster (#5973), standing in for Codex while it was out of quota.
The shared postgres18 archives WAL and base backups to Garage through the barman-cloud plugin.
tracearr-postgres has neither: its two instances on separate nodes survive a node loss, but a
replica copies a DROP, a bad migration or corruption straight across, and prune: true on the
tracearr-database Kustomization deletes the Cluster and its PVCs if the Kustomization is renamed
or removed. The first draft of the manifest comment claimed the replicas "stand in for object-store
backups", which is exactly the false assurance this page exists to catch.
Mitigation in place: Tracearr's own UI backup before risky changes; the data is play history and library statistics, rebuildable in part from the media servers.
Fix: a dedicated Garage bucket (cnpg-tracearr) with its own read-write key, so the shared
CNPG key stays out of media. The Cluster archives WAL through the barman-cloud plugin to a
tracearr-postgres-garage ObjectStore with 30-day retention, and a daily ScheduledBackup takes base
backups. The shared CNPG key has read-only access to the bucket, so the hourly NAS mirror and the
R2 copy pick it up as well. CNPGBackupStale now fires when either cluster goes 36 hours without
a base backup; before this nothing alerted on a stopped ScheduledBackup, postgres18's included.
H-20: Three gluetun sidecars had no tunnel gate at start (resolved 2026-09-29, #5681)¶
Found: 2026-09-28, while trimming comments on media/dispatcharr. downloads/prowlarr,
downloads/qbittorrent and downloads/sabnzbd disabled the kubelet startup probe on gluetun's
health server, so Kubernetes started the app container before the tunnel was up. It bit the same
day: qBittorrent started one second before wg0 existed, never listened on the tunnel address, and
ran for 19 hours with 0 DHT nodes. downloads/imgur-proxy had the probe disabled too.
Fix: every gluetun sidecar now runs media/dispatcharr's startup probe (httpGet :9999,
initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 30). gluetun v3.41.3 answers 500
until the tunnel is up and its first health check passes, so the app container waits for it. The
5-minute window bounds the KB-042 restart risk to a handshake that is genuinely stuck.
Correction to the original entry: it also listed "no recovery after start" because these pods
set HEALTH_SERVER_DISABLE_LOOP: on. gluetun v3.41.3 never reads that variable (no occurrence in
its source), so it is a no-op wherever it is set, on or off. The in-process tunnel restart is
HEALTH_RESTART_VPN, which defaults to on, and every pod's settings summary prints Restart VPN on
healthcheck failure: yes. That half of the finding was never a gap.
H-19: Cached Fireshare videos bypassed a newly set password (resolved 2026-09-17)¶
Found: during the public deployment test. Fireshare's /_content/video/ responses carry
Cache-Control: public, max-age=31536000, immutable. After anonymous playback followed by
setting a password, the API returned HTTP 403 but Cloudflare still served the cached video
with HTTP 206. Deleting a video likewise cannot revoke an already cached response.
Fix: the Fireshare HTTPRoute overwrites Cache-Control with private, no-store, so the
access gate runs on subsequent requests. Verify using a new test video: anonymous range
playback succeeds, then setting a password makes both media endpoints return HTTP 403.
Previously cached content requires a separate purge; only generated test clips were uploaded
before applying this fix, and no existing media library was mounted.
Deletion follow-up: even with HTTP caching disabled, nginx's open_file_cache kept
deleted files readable until its 120-second revalidation. The startup command disables that
cache as well. A repeated static media request after deletion must return HTTP 404; the
upstream API reports a missing video as HTTP 500, but must not return video bytes.
H-18: Fireshare hid anonymous uploads without disabling them (resolved 2026-09-17)¶
Found: while deploying Fireshare 1.8.1. Its default show_public_upload: false hides the
upload button, but allow_public_upload: true still permits anonymous API uploads.
Fix: the Fireshare init container sets allow_public_upload: false before every startup,
along with private defaults for new media. An invalid configuration stops initialization rather
than falling back to upstream defaults. Verify both /api/upload/public and
/api/uploadChunked/public reject unauthenticated POST requests with HTTP 401.
H-1: The Gatus down-alert rule was never loaded (resolved 2026-07-01, #3372)¶
observability/gatus/app/kustomization.yaml had # - ./prometheusrule.yaml commented out since
2025-03-19. Two PRs (#3220, #3221) edited the rule's expression and recorded "117 apps now
alertable"; the rule was in a file Flux never applied. Live Prometheus had 343 alert rules and 0
for Gatus, and an internal app that was down for 20 minutes never paged while the pipeline was
demonstrably working for nine other alerts.
Lesson: verify a PrometheusRule is loaded (/api/v1/rules, or ALERTS_FOR_STATE for the
alert name), not that the CR or the file exists. The fix also switched the expression from an
allowlist of groups to a denylist (group!="connectivity") so new groups are covered by default.
H-2: tuppr metrics were never scraped (resolved 2026-06-02, #2747)¶
The hand-written alerts in tuppr/upgrades/prometheusrule.yaml had no data behind them: no
ServiceMonitor existed anywhere. Found only by comparing against upstream's chart values.
Lesson: an alert rule with no matching series never fires and never errors. For every new
rule, query the metric it depends on and confirm a non-empty result.
H-4: UpsLowRuntime could never match (resolved 2026-07-03, #3397)¶
nut_ups_status{status="OB"} and nut_battery_runtime_seconds < 600 never matched: the left side
carries a status label the right side lacks, so the vector match was always empty. Found after
the mains-loss incident it should have paged for. The same PR found the two-endpoint
ServiceMonitor quadruplicating every UPS series, so every expression needed max by (ups).
Lesson: test a rule with promtool test rules against a fixture that should fire, and
against one that should not.
H-7: MikrotikPoeDeviceLostPower resolved itself while the device was still dead (resolved 2026-08-28, #4721)¶
The evidence clause was min_over_time(...[6h] offset 30m) > 0. Thirty minutes into an outage the
first zero entered the window, min dropped to 0, and the alert announced "resolved" about a dead
device. Caught by the PR reviewer, not by a test. Lesson: an evidence term ("it used to be
good") is max_over_time; min_over_time is for "held continuously", which is rare. The comment
in the rule had documented a dwell time the expression never had, so the promtool test now
asserts the dwell.
H-14: Intermittent partial loss never tripped a for: (resolved 2026-08-27, #4684, #4685)¶
The core switch's SNMP target was down 29% of the time in stalls mostly under five minutes, for
two days, and SnmpTargetDown (for: 5m) sat one longer stall from paging while firing nothing.
Lesson: for flapping targets alert on a rate over a window (1 - avg_over_time(up[1h]) > 0.5),
not on a sustained state. Written as 1 - avg so $value is the failure fraction the summary
quotes.
H-15: ShellyDevicePendingFirmware could never fire (resolved 2026-07-15, shelly-operator v0.3.2)¶
status.availableFirmware was empty on all 49 devices, so shelly_device_update_available was
pinned at 0 and firmware 2.0.0 landed on three devices unannounced. Found while chasing the
incident it would have warned about. Lesson: a metric pinned at its "all clear" value across
the whole fleet is suspicious, not reassuring; check it can take the other value.
H-16: The cluster-secrets placeholder was re-applied over the real values hourly (resolved 2026-08-03, #4081)¶
kustomize-controller re-applied the placeholder Secret (SECRET_DOMAIN: example.com) on every
reconcile, opening a one-second window in which a HelmRelease could render a route with the
placeholder hostname, and Helm's three-way merge never repaired the drift afterwards. Fixed with
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent. Lesson: verify a fix through the mechanism
that proves it (the managedFields timestamp stopped advancing), not through the artifact the
mechanism rewrites (the annotation is absent on the live object, by design).
KB-020.
H-17: just lint was stricter than CI (resolved, #3759)¶
The local super-linter wrapper lacked the docs/ exclusion CI applies, so it failed on files CI
never checks and trained people to ignore its output. Lesson: a local gate that disagrees with
CI in either direction stops being run; keep them byte-identical in configuration.