Operations platform migration¶
This runbook moves the homelab from notification-led response to OneUptime incident management without replacing the existing monitoring stack.
The target architecture is documented in operations and incident platform.
Safety rules¶
- Prometheus and the Loki ruler remain alert evaluators.
- Alertmanager remains the routing, inhibition, and silence authority.
- Existing matching Discord (
notify=page) and ntfy (criticalservice=os-patching) routes stay active through shadow and pilot stages.continue: trueevaluates later siblings, not universal failover. Allowlisted runnernotify=ticketalerts have no direct Discord fallback. - OneUptime does not receive production alerts before its restore drill passes.
- No remediation action receives an arbitrary command, SSH key, Docker socket, or runner operator credential.
- Disruptive actions require GitHub protected-environment approval.
- Stop and roll back a stage if duplicate incidents, lost recovery events, or notification regressions appear.
Stage 0: foundation¶
Deliver:
- architecture and state-ownership decision;
- operational alert contract;
- Prometheus and Alertmanager semantic CI;
- corrections for known rule and inhibition defects.
Gate:
uv run python scripts/lint-alert-schema.py
uv run python -m unittest discover -s tests -v
uv run python scripts/lint-markdown.py
promtool check config monitoring/prometheus/prometheus.yml
promtool check rules monitoring/prometheus/alerts/*.yml
promtool test rules monitoring/prometheus/tests/*.yml
amtool check-config /path/to/rendered-alertmanager.yml
Rollback: revert the foundation PR. It does not change live notification routing.
Stage 1: capacity¶
- Measure
infra-servicesCPU, memory, filesystem, and Docker usage. - Measure Proxmox RAM and
local-lvmthin-pool headroom. - Agree the target VM allocation and maintenance window with the owner.
- Resize VM 123 and verify the guest sees the new allocation.
- Update inventory and generated artifacts after the live change.
Gate:
- at least 20 percent disk free after pulling OneUptime images;
- at least 6 GiB
MemAvailablebefore OneUptime starts; - swap remains enabled;
- existing stacks are healthy;
- no
InfraServices*capacity alert fires for 15 minutes.
Rollback: CPU and memory can return to the previous allocation after shutdown if required. Disk expansion is not treated as reversible; follow the existing capacity runbook and never improvise a shrink.
Stage 2: shadow deployment¶
Deploy the five-service core:
- OneUptime application;
- OneUptime ingress;
- PostgreSQL;
- Redis;
- ClickHouse.
The probe and AI agent remain disabled. Duplicate telemetry ingestion remains disabled.
Gate:
- all containers are healthy within the documented startup window;
- Authentik protects the browser route;
- OneUptime local authentication is enabled;
- direct backend access does not bypass authorization;
- no host ports are published;
- WebSockets, redirects, cookies, and forwarded HTTPS headers work;
- the application runs within the approved memory and disk budget for 24 hours.
Rollback: stop the stack and remove its Traefik route. Do not delete volumes until backup/restore testing and owner approval establish whether state is disposable.
Stage 3: data protection¶
- Create a sample incident and workflow run.
- Take logical PostgreSQL and consistent ClickHouse backups.
- Restore into isolated volumes.
- Start the exact pinned OneUptime release against the restored data.
- Verify the incident, timeline, workflow history, users, and encrypted variables.
- Copy the backup through the tier-1 restic path.
Gate: the isolated restore passes. Production Alertmanager delivery remains blocked until it does.
Rollback: keep the shadow stack stopped and repair the backup procedure.
Stage 4: GitHub runner pilot¶
The synthetic-only native-canvas evaluation failed its concurrency gate on 2026-07-31. Two simultaneous firing deliveries for one unused correlation key created two incidents. Both workflow runs completed successfully, proving that application-level success does not provide correlation uniqueness.
The workflow is disabled. Do not repeat or continue the native-canvas lifecycle; retained run and incident IDs are in the runner pilot runbook.
Current Alertmanager routing preserves existing direct routes, not universal parallel delivery:
send_resolved: truerequests adapter recovery events;continue: trueevaluates later sibling matchers;synthetic="true"was required during native-canvas evaluation only (adapter promotion removed that requirement for allowlisted domains);- Discord requires
notify=page; runner rules now usenotify=ticket; - critical
service=os-patchingselects the separate ntfy route; - adapter service/severity/alert-name allowlists are independent of
notify; unmatched alerts reachblackhole.
Native evaluation completed before the stop:
- One firing alert created one incident.
- A repeated firing updated that incident and advanced Last Seen At.
- Two concurrent copies created two incidents.
- The workflow was disabled and later lifecycle phases were not run.
Stage 4 required a narrow adapter with durable enqueue, unique correlation,
atomic fingerprint updates, replay/dead-letter handling, active-alert
reconciliation, source restrictions, and rate limiting. That adapter is live:
lifecycle gates passed, production routes admit allowlisted domains, and
REQUIRE_SYNTHETIC=false. See the
incident adapter runbook and the
2026-07-31 /
2026-08-01 journals.
Historical note: during native-canvas evaluation the child route required
synthetic="true" and Discord stayed on the direct path. Those dated observations
are not current ticket-to-Discord acceptance. Today allowlisted real alerts ingest
without a synthetic gate; continued matching does not broaden direct routes.
Rollback must follow the adapter rollback boundary.
Removing a runner adapter route leaves its tickets at blackhole. Before removal,
approve a reviewed temporary ticket fallback or an explicit owner-approved bounded
notification-loss window with named operator and recovery checks. Existing direct
receivers remain only for already-matching alerts; do not silently relabel tickets
as pages.
Stage 5: domain rollout¶
Migrate one alert domain per reviewable change:
- node and host availability;
- containers and infra-services capacity;
- backup and restore;
- patching and Komodo;
- TLS and service health;
- monitoring-platform health.
The first domain slice standardizes NodeExporterUnavailable, correlates one
incident per inventory-backed instance, and retains direct Discord for its
notify=page labels through an additive Alertmanager route. The 2026-07-31 live
gate passed concurrent firing, exact repeat, resolution, recurrence, real sonarqube
delivery, and unchanged Discord delivery. Re-run it with:
cd /opt/homelab/services/oneuptime-incident-adapter
sudo env ADAPTER_ACCEPTANCE_DOMAIN=node-availability \
bash scripts/acceptance-test.sh --live
The second slice standardizes container restart/missing telemetry and
infra-services root-disk/memory capacity. Container restart rules aggregate to
instance,name before alerting so volatile cAdvisor labels cannot enter the
incident schema. Infra-services rules select only the canonical job="node"
series, avoiding duplicate fingerprints from runner telemetry. After deploy,
run both focused gates:
for domain in container-runtime infra-capacity; do
sudo env ADAPTER_ACCEPTANCE_DOMAIN="${domain}" \
bash scripts/acceptance-test.sh --live
done
The third slice begins backup and restore with the actionable freshness pair:
ResticBackupStale and ResticBackupFailed. Both select only canonical
job="node" textfile series, preserve the affected job as backup_service,
and correlate warning and critical thresholds by instance,backup_service.
Alertmanager inhibition uses the same identity so one failed repo cannot hide
stale alerts for unrelated jobs. After deploy, run:
The fourth slice completes current restore-drill coverage with
RestoreTestMetricsMissing, RestoreTestStale, and RestoreTestFailed.
Restore drills are host-level and rotate one local tier-1 repository per weekly
run, so all conditions correlate by canonical instance rather than the most
recently selected repository. The failed metric preserves that repository as
optional restore_service context, and the adapter accepts only the five
candidates used by the infra-services producer. External tier-1 and tier-2
repositories remain outside this rotation drill. After deploy, run:
The fifth slice starts configuration health with KomodoSecretDrift and
KomodoSecretDriftCheckStale. Both conditions describe the same canonical
infra-services SOPS → compose.env → live-consumer propagation chain, so all
secret-specific and detector-health fingerprints share one host-level
correlation. Generic Komodo container, backup, restore, and managed-node
failures remain in their existing incident domains. After deploy, run:
The patching half of the fifth slice correlates OSPatchStale and
PatchRebootPending per inventory-backed host. The infra-services-only
OSPatchOrchestrateFailed and per-host AnsiblePullStale retain separate
identities because their schedules and remediation paths differ. Freshness is
derived from producer success timestamps rather than textfile mtimes, so reboot
metric synchronization cannot make an old patch appear current. After deploy,
run:
for domain in patch-host-health patch-orchestrator ansible-pull; do
sudo env ADAPTER_ACCEPTANCE_DOMAIN="${domain}" \
bash scripts/acceptance-test.sh --live
done
The 2026-08-01 live gate passed all four Komodo and patching lifecycle modes. A separate critical patch route probe reached Discord, ntfy, and the adapter without delivery failures; the resulting OneUptime incident resolved at its second revision. Existing stale ansible-pull and patch-success producers remain real operational work rather than acceptance-generated failures.
The TLS slice standardizes TLSCertExpiringSoon on Traefik's live
traefik_tls_certs_not_after metric. Alerts aggregate with
min by (instance, cn) so volatile sans/serial labels cannot enter the
incident schema, and each certificate common name correlates independently on
infra-services. Service-health remains deferred until dedicated rules exist
(container runtime already covers restart/missing telemetry). After deploy,
run:
The monitoring-platform slice standardizes CentralAlloyUnavailable and
LokiIngestionErrors on live Alloy and Loki scrapes. Loki discards aggregate
by instance so volatile reason labels cannot enter the incident schema.
AlertmanagerNotificationFailures must sum() the counter increase so
volatile integration/reason labels cannot enter the incident schema
(otherwise the adapter 400s and Alertmanager counts more webhook clientErrors).
PrometheusStoragePressure is retired as a duplicate of migrated
infra-capacity disk alerts. Alertmanager, Grafana, and external dead-man
self-health remain out of scope until exporters and an outside-failure-domain
notifier exist. After deploy, run:
For each domain:
- complete alert schema labels and annotations;
- add rule behavior tests;
- prove firing, repeat, and resolution;
- inspect correlation keys;
- verify intended receivers for both
notify=pageandnotify=ticket, including absence of Discord for tickets; source selection is not live delivery acceptance; - add the domain to the operations dashboard.
Rollback: inventory the domain's tickets before removing its adapter matcher. Follow the adapter rollback boundary, with a reviewed temporary ticket fallback or an owner-approved bounded notification-loss window. Keep Prometheus rules and existing direct routes. Removing the matcher does not cancel queued adapter work or stop worker reconciliation; review shutdown and recovery separately.
Stage 6: runner remediation¶
The end-to-end path uses the existing runner-fleet dispatcher and a small timeline helper. Do not revive the rejected native OneUptime canvas workflow.
Operator path¶
- An allowlisted runner-pool
notify=ticketalert enters the adapter and projects a OneUptime incident; it does not select Discord. A page selects Discord only when explicitly labelednotify=page, not because of severity orcontinue: true. - Acknowledge the incident in OneUptime.
- Open the alert
action_url(or runner-fleet-dispatch.yml) and dispatch exact-runnerhealth/drift/reportwithdry_run: true. - Inspect structured artifacts from the run.
- Record the workflow run on the incident timeline:
# dry-run payload
ONEUPTIME_API_KEY=… ONEUPTIME_PROJECT_ID=… \
ONEUPTIME_INCIDENT_ID=<uuid> \
GITHUB_RUN_URL=https://github.com/spadoople/homelab/actions/runs/<id> \
DRY_RUN=1 \
python scripts/oneuptime_post_remediation_note.py --conclusion success
Or use workflow
oneuptime-remediation-note.yml
against the protected oneuptime-remediation environment.
6. Preview drain / remediate / resume with dry_run: true.
7. Apply only after protected-environment approval; never target the management
runner.
8. Prometheus recovery + Alertmanager resolved event closes the incident path;
OneUptime retains history.
Gate¶
- the selected target is not the management runner;
- Apps, protected environments, and maintenance runner variables are configured;
- every target exists in
infra/github-runners/fleet.yml; - actions time out and reject arbitrary commands or unknown targets;
- early failures still emit an auditable result;
- runner alerts expose
action_urlto the dispatcher; - timeline notes can be posted via
scripts/oneuptime_post_remediation_note.py.
Owner setup for live notes¶
Create GitHub Environment oneuptime-remediation with:
- secrets
ONEUPTIME_API_KEY,ONEUPTIME_PROJECT_ID - optional variable
ONEUPTIME_API_BASE(default publichttps://oneuptime.infra.realemail.app/api)
Rollback: disable the remediation-note workflow or environment secrets. Existing manual workflow dispatch remains available.
Stage 7: notification cutover¶
Pilot dual-write is implemented in the incident adapter. After a successful
OneUptime projection, the worker optionally posts a lifecycle message to
pilot-only Discord and ntfy destinations. Production Alertmanager Discord
and ntfy routes keep their existing matchers: notify=page for Discord, critical
service=os-patching for ntfy. continue: true does not make pilot messages an
independent fallback for incident-only tickets; they depend on successful adapter
projection. See the receiver matrix.
Enable the pilot¶
- Create a dedicated ntfy topic such as
homelab-oneuptime-pilot-<4hex>(optional: also a Discord webhook channel — not required; either destination enables pilot notify). - Add to
services/oneuptime-incident-adapter/.env.sops.yaml:
# PILOT_DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/…" # optional
PILOT_NTFY_SERVER: "https://ntfy.sh"
PILOT_NTFY_TOPIC: "homelab-oneuptime-pilot-…"
- Re-render and recreate the adapter stack on infra-services:
cd /opt/homelab/services/oneuptime-incident-adapter
bash scripts/render-env.sh
docker compose --env-file compose.env up -d --force-recreate worker
- Trigger a synthetic or allowlisted lifecycle and confirm messages appear only on the pilot destinations. Production Discord volume must not jump from this path.
Soak checklist (≥1 week)¶
- [ ] Identified / updated / resolved messages arrive on pilot Discord and ntfy
- [ ] Links open the correct OneUptime incident behind Authentik
- [ ] Noise is acceptable vs direct Alertmanager Discord
- [ ] Recovery (resolve) messaging is timely and not duplicated oddly
- [ ] Adapter outbox success rate stays healthy if a pilot destination fails (pilot notify is best-effort and must not fail projections)
Cutover (owner approval only)¶
- Move standardized domain lifecycle messages to production destinations.
- Retain reviewed direct Alertmanager routes for matching OneUptime failure and other agreed emergency alerts. Keep the independent host heartbeat outside Alertmanager; it is not a monitoring-chain watchdog.
- Remove permanent duplicate routes only after owner approval.
Rollback: clear PILOT_* secrets, re-render, recreate worker. Incident
ingestion can remain active.
Stage 8: closeout¶
Confirm:
- operations and platform-health dashboards are provisioned;
- noisy, stale, frequent, long-running, and flapping alerts are visible;
- platform health, database health, queue health, webhook delivery, backup freshness, disk, and certificates are monitored;
- the independent host heartbeat path remains outside Prometheus/Alertmanager (monitoring dead-man: host timer → Healthchecks.io → ntfy); its miss drill proves cessation/delivery, not monitoring-plane health;
- monitoring-chain loss detection has separate disposable acceptance; healthy host plus stopped Prometheus/Alertmanager does not stop the current heartbeat;
- complementary Prometheus self-health rules can report partial failures only with a working evaluation and notification path;
- operator, restore, upgrade, rollback, and synthetic-test docs match reality;
- a project journal entry records the milestone and remaining gaps.
OneUptime becomes canonical incident state only after every prior gate passes.