- PHP 84.7%
- CSS 13.6%
- Dockerfile 0.9%
- Shell 0.8%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| bin | ||
| docker | ||
| public | ||
| src | ||
| views | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
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