A minimal alternative to Uptime Kuma: HTTP, Prometheus and push checks, notifications and status pages, configured in plain key = value files.
- One static binary, Go standard library only (go.mod), in the spirit of nobloat.org
- No database: one append-only CSV audit log per monitor
- Config as code: one file per monitor, notifier and status page, checked with
up -check, reloaded on change
| HTTP checks | status codes, body regex, JSON match, headers, basic auth, redirects, TLS expiry |
| Prometheus checks | one metric of a /metrics endpoint, or a PromQL query (Thanos, Mimir, VictoriaMetrics work too) |
| Push monitors | dead man switch for cron jobs and backups; Uptime Kuma and healthchecks.io compatible URLs |
| Notifications | Slack / Mattermost webhooks, mail, ilert; reminders while red |
| Status pages | public, per tag, custom templates |
| Web UI | nobloat/ui look (graphite or paper), English and German, live updates, filters, search, keyboard shortcuts and command palette (?, Ctrl+K), pause with reason and end, create, edit and delete monitors and status pages (editors within their tags; the config directory must be writable, not :ro), dark mode, phone friendly, installable on the home screen |
| Heartbeat | pings an external URL so something notices when up itself is down |
Not included: editing notifiers and up.conf in the UI, graphs, user management (that is the IdP's job, see single sign-on).
compose.yaml with Caddy for TLS:
services:
up:
image: ghcr.io/nobloat/up:latest
restart: unless-stopped
environment: {TZ: Europe/Vienna}
volumes: [./config:/config:ro, ./data:/data]
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes: [./Caddyfile:/etc/caddy/Caddyfile:ro, caddy:/data]
volumes:
caddy:Caddyfile:
up.nobloat.org {
reverse_proxy up:7493
}
./datamust be writable by uid 65534:sudo chown 65534:65534 dataup -check -config <dir>validates a config, e.g. in CI (exit code 1 on any error)- A damaged audit log stops startup instead of being reset (
-checkreports it with file and line): back it up and repair it
The data dir and the config dir are the backup. The UI writes the config dir, so back it up too.
- Copy both with any tool while
upruns, full or incremental (rsync -a --delete, restic, borg, volume snapshots): audit logs only grow by appended, synced records, and config files are replaced by atomic renames, so a copy is never half a file. Skip.tmp-*files. - Or export through
upfor a consistent cut (admin):GET /api/exportstreams a tar.gz withmanifest.jsonfirst (app, version, created, every file with path, size, mtime, sha256), thenconfig/<file>for every file of the config dir, thendata/<file>.?since=<created of an earlier manifest>sends only the files changed since then, plus the full manifest, so deletions show.
export UP_URL=https://up.nobloat.org UP_USER=admin UP_PASSWORD=…
up export > up-full.tgz
up export -since 2026-11-05T10:00:00.123456789Z > up-inc.tgz # created from up-full.tgz's manifest.json
up restore -data ./data -config ./config up-full.tgz up-inc.tgz # offlineup restore needs an empty or missing data dir, applies the archives in order (deleting files missing from each manifest), never overwrites an existing config file, verifies sha256 against the last manifest and runs the checks of -check. Without -config only the data is restored. Untarring by hand works too.
Formats: up.conf and <name>.<kind>.conf are key = value text; data/<monitor>.csv (dots in the name become _) is CSV with the header timestamp,event,state,message.
A directory (-config, /config in the image): up.conf plus one file per entity, named <name>.<kind>.conf. Start from example/config, or see config for every feature (make run).
config/
up.conf
website.monitor.conf
api.monitor.conf
ops-chat.notifier.conf
public.page.conf
- One
key = valueper line,#starts a comment, lists are comma separated, nested keys are dotted (headers.X-Api-Key) - Durations: Go syntax plus days (
30s,1h,7d,1d12h) - Unknown keys are errors. A broken entity file is skipped and logged; only a broken
up.confstops startup - The directory holds credentials: treat it as secret
listen = :7493
base_url = https://up.nobloat.org
data_dir = /data
auth.user = admin
auth.password = secret
heartbeat.urls = https://hc-ping.com/<uuid>| Option | Default | |
|---|---|---|
base_url |
required | public URL, for links in notifications |
data_dir |
required | audit logs, one CSV per monitor |
auth.user, auth.password |
required without role.* |
basic auth for the UI and API, always admin |
trusted_proxies |
CIDRs whose Remote-User / Remote-Groups headers are believed, see single sign-on |
|
role.viewer, role.editor, role.admin |
IdP groups per role | |
access.<group> |
tags whose monitors the group sees, * for all |
|
logout_url, account_url |
IdP links next to the signed-in user's name | |
design |
graphite |
look of the UI: graphite or paper (nobloat/ui) |
brand_color, brand_color_dark |
the design's | #rrggbb accent color; the dark one defaults to a lighter shade of brand_color |
listen |
:7493 |
plain HTTP, put a TLS proxy in front |
proxy |
HTTP proxy for all checks and notifications | |
evaluation_interval |
30s |
how often notifications are evaluated and batched |
heartbeat.urls, heartbeat.interval |
1m |
URLs to GET while up runs |
Optional: with only auth.user / auth.password everyone shares one admin login, as before. Single sign-on works the same as in bill and track: caddy's forward_auth asks nobloat/auth (or another IdP) to log the user in and forwards Remote-User and Remote-Groups. up believes those headers only from trusted_proxies; anyone else needs basic auth.
trusted_proxies = 10.0.0.0/8
role.viewer = up-viewer
role.editor = up-editor
role.admin = up-admin
access.up-all = *
access.team-a = prod, api
logout_url = https://auth.example.com/logout
account_url = https://auth.example.com/settings| Role | May |
|---|---|
viewer |
see the monitors of their tags, with config (secrets masked) and log |
editor |
also check now, pause and resume them; create, edit and delete their monitors and status pages (shows their secrets), keeping tags within their own and the existing ones |
admin |
every monitor regardless of tags, notifier failure banners |
A user needs a role group and, below admin, an access.* group: up-editor + team-a may pause the monitors tagged prod or api; other monitors answer 404. The highest role wins, tags add up. Status pages and push URLs stay public: route /push/*, /api/push/*, /ping/*, /status/*, /static/* and Authorization: Basic past the IdP (see Caddyfile).
GET /api/me answers {user, role, tags} for the signed-in user (tags is ["*"] for every monitor), like bill's and track's.
type = http
description = Home page answers with 2xx
tags = public
interval = 1m
retry_count = 2
notifiers = ops-chat
url = https://example.com/health
cert_min_valid_days = 14| Type | Checks | Options |
|---|---|---|
http |
an HTTP(S) endpoint | HTTP options |
metric |
one metric of a Prometheus text format endpoint | HTTP options, metric, labels.<name>, value (< 80, ops > >= < <=); every matching series must pass, none fails |
promql |
an instant query; url is the API base |
HTTP options, query, value; every series must pass, empty fails |
push |
something reports in | token (16+ characters, unique), see push monitors |
| Option | Default | |
|---|---|---|
type, interval |
required | interval: between checks, or expected between pushes |
description |
public, shown on status pages | |
tags |
UI filters and status pages | |
retry_count |
0 |
failures after the first before red; yellow until then |
retry_interval |
interval |
between checks while yellow |
timeout |
10s |
per check |
grace |
push only: extra time before a missing push fails |
|
notifiers |
notifier names; unknown ones are skipped and logged |
For http, metric and promql (promql always uses GET).
| Option | Default | |
|---|---|---|
url |
required | |
method |
GET |
GET, POST, HEAD |
headers.<name> |
Host overrides the host header |
|
basic_auth.user, basic_auth.password |
||
body |
request body | |
accepted_status |
any 2xx | e.g. 200, 204 |
body_regex |
RE2 syntax | |
json_match.path, json_match.equals |
data.0.status = ok; 1 is a number, "1" a string |
|
max_redirects |
10 |
|
skip_verify |
false |
skip TLS verification |
cert_min_valid_days |
red without retries when the certificate expires sooner |
- Redirects never downgrade HTTPS. With credentials, headers, a body or a query string only same-origin redirects are followed
- Secrets are masked on the detail page; errors never contain the URL
curl -fsS https://up.nobloat.org/push/backup/<token>
curl -fsS -d '{"files": 12}' "https://up.nobloat.org/push/backup/<token>?status=down"
curl -fsS "https://up.nobloat.org/api/push/<token>?status=down&msg=disk%20full"
some-job > job.log 2>&1; rc=$?; curl -fsS --data-raw "$(tail -c 4000 job.log)" https://up.nobloat.org/ping/<token>/$rcNo push within interval + grace is a failure; the token is the only auth.
Sent when a monitor turns red or recovers, batched every evaluation_interval. Failed deliveries retry, also after a restart.
type = webhook
url = https://chat.example.com/hooks/xyz
reminder_interval = 1h| Type | Options |
|---|---|
webhook |
url, headers.<name>; Slack / Mattermost format |
smtp |
host, port, tls (starttls · tls · none), user, password, from, to, subject_prefix ([up]) |
ilert |
integration_key of an "API" alert source; red opens an alert, recovery resolves it |
All but ilert take reminder_interval; ilert escalates on its own.
public.page.conf serves /status/public with every monitor tagged public:
title = Example Status
tag = publictemplate points to a custom html/template (relative to the config directory) receiving StatusPage{Title, State, Monitors}, e.g. config/templates/minimal.html.
make run
make nomail
make test
make build
make package
make screenshots
make ui # copy ui.css, ui.js, partials.html and their texts from ../ui (nobloat/ui), unchanged