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.

Declarative Monitoring: AutoKuma and the Case for Monitors-as-Code

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:

  1. Read desired state: Parse environment variables or Docker labels for monitor definitions
  2. Read actual state: Query Uptime Kuma's API for existing monitors tagged as AutoKuma-managed
  3. Diff: Compare desired vs. actual
  4. Reconcile: Create missing monitors, update changed ones, optionally delete unmanaged ones
  5. 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-monitors

The 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:

  1. Uptime Kuma detects the failure (within the monitor's check interval, typically 60 seconds)
  2. After MAX_RETRIES failures (usually 3), it fires a notification
  3. Notification goes to ntfy (self-hosted push notification server)
  4. ntfy pushes to my phone via FCM (Android) or APNs (iOS)
  5. 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:

  1. Add environment variables to the autokuma-monitors ConfigMap
  2. Commit and push to Git
  3. Flux detects the change and applies the ConfigMap
  4. AutoKuma's reconciliation loop picks up the new variables
  5. 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.