No description
  • PHP 84.7%
  • CSS 13.6%
  • Dockerfile 0.9%
  • Shell 0.8%
Find a file
nhendriks 6a923dacfb
Some checks failed
Build and deploy / deploy (push) Failing after 3s
Consensus engine, webhook receiver, status page and Pushover alerting
2026-07-13 08:31:44 +02:00
.forgejo/workflows Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
bin Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
docker Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
public Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
src Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
views Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
.env.example Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
.gitignore Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
docker-compose.yml Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
Dockerfile Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00
README.md Consensus engine, webhook receiver, status page and Pushover alerting 2026-07-13 08:31:44 +02:00

UptimeMonitor

Two Uptime Kuma instances watch the same things. This sits between them and Pushover, and only wakes you up when both of them agree something is down.

One instance alone being unable to reach a host usually means the instance has a problem, not the host. Requiring consensus turns that class of false alarm into silence, and turns a real outage into one notification instead of two.

   Kuma Helsinki  ─┐
                   ├─→  UptimeMonitor  ─→  Pushover   (only when both agree)
   Kuma home      ─┘         │
                             └─→  status page

How it decides

Each instance's latest verdict per monitor is stored separately. The aggregate state, which is the only thing Pushover ever hears about, moves like this:

Helsinki Home Aggregate Notification
Down Down Down Fires, once
Down Up Pending Nothing. They disagree.
Up Down Pending Nothing. They disagree.
Up Up Up Recovery fires, but only if we had previously alerted
Pending (Kuma retrying) anything Pending Nothing. Not a decisive answer.
Maintenance anything Maintenance Nothing. A planned window is not an outage.

A monitor can opt out with require both = off, which makes a single instance reporting down enough. Use it for anything only one instance can physically reach, such as a host on the home LAN.

Two things it deliberately does not do

It does not sync monitors between the instances. Kuma has no API for creating monitors: the API key it issues unlocks only the read-only Prometheus /metrics endpoint. Copying Kuma's database between instances would also destroy the independence that makes "both agree" meaningful. Instead, the status page shows an unpaired panel: any monitor one instance reports and the other has never reported is flagged immediately, so drift is visible in seconds rather than discovered during an outage.

It is not the last line of defence. Every consensus notification flows through this app, which makes the app a single point of failure. If it dies you get silence, and silence looks exactly like "all fine". Guard against that from below, with notifications wired straight to Pushover inside each Kuma instance, bypassing this app entirely:

  • Helsinki checks that home is alive. Home checks that Helsinki is alive.
  • Both check https://<app-url>/health, which is this app's heartbeat.

Those are the checks that tell you the consensus layer itself has stopped working. The app also alerts when an instance goes quiet (see Settings), because a silent instance can never disagree, and so silently mutes every consensus alert.

Running it

One container, one port, one volume.

cp .env.example .env      # then set ADMIN_PASSWORD_HASH
docker compose up -d --build
Env Meaning
ADMIN_PASSWORD_HASH Bcrypt hash of the admin password. php -r 'echo password_hash("pw", PASSWORD_DEFAULT);'
APP_URL Public origin, used to build the webhook URLs shown in Settings
HTTP_PORT Host port to publish on (default 8791, bound to loopback)
PUBLIC_STATUS_PAGE true lets anyone read the status page. Settings always need a login.
RECONCILE_INTERVAL Seconds between reconciler ticks (default 60)

Everything else (Pushover credentials, per-monitor config, Kuma URLs) is edited in the UI and stored in /data/uptime.sqlite. Back up that one file and you have backed up the app.

Wiring up Kuma

In each instance: Settings, Notifications, Setup Notification, type Webhook, content type application/json, Post URL = the URL shown on the app's Settings page for that instance. The two URLs carry different tokens and are not interchangeable.

Then apply the notification to every monitor you want consensus-gated (Kuma has a "Default enabled" toggle that applies it to new monitors automatically).

Monitors appear in UptimeMonitor on their own, the first time either instance posts about them. They are matched across instances by name, case-insensitively, so the two Kuma instances must call the same check the same thing.

Optional: the reconciler

If you paste a Kuma API key into Settings, a background loop reads that instance's /metrics every minute and corrects the app's copy of its state. It exists to catch a webhook that never arrived, for example because the app was restarting during a deploy. Leave the key blank and the app runs on webhooks alone.

Endpoints

Path Auth Purpose
POST /webhook/{primary|secondary} Per-instance token Where Kuma posts
GET / Login (unless PUBLIC_STATUS_PAGE) Status page
GET /api/status Same as above Machine-readable state
GET /health None Liveness. Point your Kuma cross-checks here.
GET /settings, /monitors/{id} Login Configuration

Deployment

Pushing to main triggers .forgejo/workflows/deploy.yml, which runs on the self-hosted runner on barad-dur.hel1-1.fi.exped.systems: it pulls, lints every PHP file, rebuilds the image, restarts the container, and fails the job if /health does not come up within 30 seconds.

The host needs /opt/uptimemonitor/.env to exist before the first deploy. The deploy refuses to run without it rather than start a container nobody can sign in to.

Layout

public/index.php     front controller, all routes
src/Correlator.php   the consensus engine
src/Reconciler.php   /metrics safety net + silent-instance alarm
src/Pushover.php     delivery, per-monitor config with global fallback
src/Monitors.php     data access
views/               status, monitor, settings, login
docker/              apache conf, php.ini, entrypoint