Declarative Monitoring: AutoKuma and the Case for Monitors-as-Code
28 monitors defined as code in Git, automatically reconciled by a sidecar container. Here's why I stopped clicking through the Uptime Kuma UI and started declaring my monitors as YAML.
Uptime Kuma is one of the most popular self-hosted monitoring tools for good reason. It's simple, it looks great, and it tells you when things are down. But it has the same problem as every UI-driven tool: the configuration lives in the database, not in your Git repository.
That means your monitors aren't versioned. They aren't reviewed in pull requests. They can't be reconstructed from source after a disaster. If someone (you, at 2 AM) deletes a monitor by accident, it's gone. If you rebuild the cluster, you're clicking through the UI recreating 28 monitors from memory.
AutoKuma fixes this. It's a sidecar container that reads monitor definitions from Docker labels or environment variables and reconciles them into Uptime Kuma's database. Monitors become code. And code lives in Git.
The Pattern: Monitors-as-Code
The idea is straightforward: define your monitors in YAML, store them in Git, and let a reconciliation loop ensure Uptime Kuma's actual state matches the declared state.
apiVersion: v1
kind: ConfigMap
metadata:
name: autokuma-monitors
namespace: monitoring
data:
AUTOKUMA__life-hub__TYPE: http
AUTOKUMA__life-hub__URL: http://life-hub.life-hub.svc.cluster.local:80/health
AUTOKUMA__life-hub__INTERVAL: "60"
AUTOKUMA__life-hub__RETRY_INTERVAL: "30"
AUTOKUMA__life-hub__MAX_RETRIES: "3"
AUTOKUMA__life-hub__ACCEPTED_STATUSCODES: "[200]"
AUTOKUMA__navidrome__TYPE: http
AUTOKUMA__navidrome__URL: http://navidrome.navidrome.svc.cluster.local:80/ping
AUTOKUMA__navidrome__INTERVAL: "60"
AUTOKUMA__ghost__TYPE: http
AUTOKUMA__ghost__URL: http://ghost.ghost.svc.cluster.local:2368/ghost/api/content/settings/?key=...
AUTOKUMA__ghost__INTERVAL: "120"Each monitor is defined by a set of environment variables following the naming convention AUTOKUMA__{name}__{property}. The double underscores delimit the monitor name from the property. AutoKuma parses these on startup and creates or updates the corresponding monitors in Uptime Kuma.
How AutoKuma Works
AutoKuma runs as a sidecar container alongside Uptime Kuma in the same pod. It connects to Uptime Kuma's internal API (no external exposure needed) and acts as a reconciliation controller:
- Read desired state: Parse environment variables or Docker labels for monitor definitions
- Read actual state: Query Uptime Kuma's API for existing monitors tagged as AutoKuma-managed
- Diff: Compare desired vs. actual
- Reconcile: Create missing monitors, update changed ones, optionally delete unmanaged ones
- Repeat: On a configurable interval (default: 5 minutes)
containers:
- name: uptime-kuma
image: louislam/uptime-kuma:1.23.13
ports:
- containerPort: 3001
volumeMounts:
- name: data
mountPath: /app/data
- name: autokuma
image: ghcr.io/bigboot/autokuma:latest
env:
- name: AUTOKUMA__KUMA_URL
value: http://localhost:3001
- name: AUTOKUMA__KUMA_USERNAME
value: admin
- name: AUTOKUMA__KUMA_PASSWORD
valueFrom:
secretKeyRef:
name: uptime-kuma-secret
key: password
- name: AUTOKUMA__TAG
value: autokuma
envFrom:
- configMapRef:
name: autokuma-monitorsThe AUTOKUMA__TAG setting is important. AutoKuma tags every monitor it creates with this label. On reconciliation, it only modifies monitors that have this tag. Manually created monitors in the UI are left untouched. This lets you mix declarative and imperative monitors without conflicts.
The 28 Monitors
My monitoring covers four categories:
Application Health (12 monitors)
Every service in the cluster gets an HTTP monitor hitting its health or status endpoint via cluster-internal DNS:
- Life Hub:
/health - Navidrome:
/ping - Audiobookshelf:
/healthcheck - Miniflux:
/healthcheck - Ghost: Content API settings endpoint
- Mealie:
/api/app/about - Linkding:
/health - Vaultwarden:
/alive - Authentik:
/-/health/live/ - Homepage:
/ - Actual Budget:
/ - Grafana:
/api/health
All use cluster-internal URLs (service.namespace.svc.cluster.local). No ingress, no TLS, no authentication overhead. If the pod is healthy, the endpoint responds. If it's not, Uptime Kuma knows within 60 seconds.
Infrastructure (6 monitors)
- Traefik dashboard
- Longhorn UI
- ArgoCD / Flux dashboard
- Kubernetes API server
- NFS mount availability (TCP check on port 2049)
- Unraid web UI
External Endpoints (6 monitors)
These hit the public URLs through Cloudflare Tunnel to verify the full request path:
- steven.naviauxlab.com (Ghost blog)
- Each major app's external URL
- Cloudflare Tunnel health
Having both internal and external monitors for the same service is intentional. An internal check passing but external check failing means the problem is in the ingress chain (Traefik, Cloudflare Tunnel, DNS). An internal check failing means the service itself is down.
Notification Targets (4 monitors)
- ntfy server availability
- Uptime Kuma status page
- Life Hub collector sync state (custom JSON query)
- Certificate expiry (TLS monitors)
Why Not Just Use Prometheus?
I run Prometheus and Grafana for metrics. They're great for "how fast is the API responding?" and "how much memory is the pod using?" But they're not great for "is the service reachable from outside the cluster?" or "did the SSL certificate expire?"
Uptime Kuma fills a different niche: simple up/down monitoring with a beautiful status page and straightforward alerting. It answers the question your users (even if that's just you) actually care about: "Is it working right now?"
The two systems complement each other. Prometheus for deep telemetry. Uptime Kuma for availability. AutoKuma to make the availability checks declarative.
Status Pages
Uptime Kuma's status pages are what make it worth running alongside Prometheus. I have a single status page (the one the Life Hub collector scrapes) that groups monitors by category:
- Core Infrastructure: Kubernetes API, Traefik, Longhorn, NFS
- Applications: Each app with its health status and response time
- External Access: Public URLs through Cloudflare Tunnel
The status page is publicly accessible (it's a status page, it should be) and doubles as the data source for Life Hub's infrastructure monitoring collector. Every 15 minutes, the collector scrapes the status page API, joins monitor names with heartbeat data, and pushes snapshots to the database.
Alerting Chain
When a monitor goes down:
- Uptime Kuma detects the failure (within the monitor's check interval, typically 60 seconds)
- After
MAX_RETRIESfailures (usually 3), it fires a notification - Notification goes to ntfy (self-hosted push notification server)
- ntfy pushes to my phone via FCM (Android) or APNs (iOS)
- Simultaneously, Life Hub's collector picks up the "down" status on next run and logs it to the database
The ntfy integration means I get push notifications without depending on any SaaS monitoring service. The entire alerting chain is self-hosted: Uptime Kuma → ntfy → phone.
The GitOps Workflow
Adding a new monitor to the homelab:
- Add environment variables to the
autokuma-monitorsConfigMap - Commit and push to Git
- Flux detects the change and applies the ConfigMap
- AutoKuma's reconciliation loop picks up the new variables
- Monitor appears in Uptime Kuma within 5 minutes
Removing a monitor: delete the environment variables from the ConfigMap, commit, push. AutoKuma can optionally delete monitors that are no longer declared (controlled by a config flag).
The entire monitoring configuration (what to check, how often, what status codes are acceptable, where to alert) is in Git. It's versioned, it's reviewed, and it survives cluster rebuilds. After a disaster recovery, Flux reconciles the ConfigMap, AutoKuma recreates the monitors, and monitoring is restored without a single click in the UI.
Limitations
AutoKuma doesn't handle notification channels. Alert destinations (ntfy, Slack, email) still need to be configured through the Uptime Kuma UI. This is the one piece that isn't fully declarative. In practice, I set up notification channels once and never touch them, so this is a minor annoyance.
Environment variable syntax is verbose. Each monitor property is a separate environment variable. A monitor with 6 properties means 6 env vars. For 28 monitors, the ConfigMap is long. A future improvement would be a CRD-based approach where each monitor is a Kubernetes custom resource, but AutoKuma doesn't support that today.
Reconciliation isn't instant. The default 5-minute loop means there's a delay between pushing a Git change and seeing the monitor in Uptime Kuma. For monitoring configuration, this is fine. For urgent changes, you can still use the UI (AutoKuma won't touch manually created monitors).
Lessons Learned
Monitors-as-code is worth the setup cost. The first time you rebuild a cluster and your monitoring comes back automatically, the investment pays for itself. No more screenshots of monitor configurations. No more "I think it was checking every 60 seconds?" guessing.
Internal + external monitoring catches different failures. An internal health check passing while the external URL fails means your ingress is broken, not your app. This distinction saves debugging time.
Tag-based ownership prevents conflicts. AutoKuma's tagging system is elegant. Managed monitors are tagged and reconciled. Manual monitors are left alone. Two systems coexist in the same Uptime Kuma instance without stepping on each other.
Status pages are underrated. Having a single URL that shows the health of everything in the homelab is useful for debugging, for showing off, and for feeding data into other systems. The Life Hub integration wouldn't work without the status page API.
If you're running Uptime Kuma and managing monitors through the UI, try AutoKuma. The shift from imperative to declarative monitoring is the same shift that GitOps brought to deployments: more predictable, more recoverable, and one fewer thing you need to remember how to recreate.