A tiny uptime monitor in Go. Single static binary, JSON config, an embedded status page, and optional XMPP alerts on state changes. No database, no Node, no browser stack — the whole thing is ~10MB and idles at a few MB of RAM.
go build -o downtime . # plain Go
nix build # or via the flake -> ./result/bin/downtimecp config.example.json config.json # edit it
./downtime -config config.jsonThen open http://localhost:8080.
By default every check is appended to a local SQLite database (config
db_path, default downtime.db; modernc.org/sqlite, pure-Go / no cgo). The
status page's % uptime is all-time — it counts across restarts instead of
resetting each boot. Set db_path to an empty string to disable persistence
and fall back to a windowed uptime over the last history_size checks.
-
Dev shell:
nix develop(go + gopls + gotools). With direnv,direnv allowloads it automatically — the repo ships an.envrc(use flake). -
Build:
nix build→./result/bin/downtime.nix runstarts it (expects./config.jsonin the cwd). -
Deploy (NixOS): import the flake's
nixosModules.defaultand configureservices.downtime. It runs the binary as a hardened,DynamicUsersystemd service, rendersconfig.jsonfrom Nix, opens the firewall port, and pulls secrets from anEnvironmentFile(kept out of the Nix store).# flake.nix inputs: downtime.url = "github:zachpmanson/downtime"; { imports = [ downtime.nixosModules.default ]; services.downtime = { enable = true; openFirewall = true; environmentFile = "/run/secrets/downtime.env"; # XMPP_PASSWORD=... settings = { listen = ":8080"; monitors = [ { name = "Website"; type = "http"; url = "https://example.com"; interval = "30s"; } { name = "Postgres"; type = "tcp"; target = "db.internal:5432"; interval = "60s"; } ]; xmpp = { enabled = true; jid = "monitor@example.com"; password = "env:XMPP_PASSWORD"; # resolved from environmentFile recipients = [ "you@example.com" ]; }; }; }; }
After changing
go.mod, refreshvendorHashinpackage.nix(set it tolib.fakeHash, runnix build, paste the printedgot:hash).
- One goroutine per monitor checks on its own interval (immediately at startup,
then every
interval). - Results feed an in-memory ring buffer (
history_sizeper monitor) that backs both the status page and the JSON API. - A monitor only flips to down — and only then fires an alert — after
failure_thresholdconsecutive failures, which suppresses flapping. A single success flips it back to up and sends a recovery alert with the total downtime.
If downtime itself crashes or is restarted, it has no memory of the checks it
ran right before dying — so it cannot honestly say whether a service was up or
down during that window. To cover that: each check's timestamp is persisted to
a small JSON file (state_file, default downtime-state.json), per monitor,
while the heavier result history stays in memory only.
On boot, downtime compares each monitor's persisted last-check time against
gapFactor × interval (gapFactor = 2) in the past:
- if it's within that window, the monitor starts as ordinary pending;
- if it's older — i.e. the checks that should have run in between never did — the monitor starts as unknown, and the status page shows the crossed window as a gap (“no data since …”) instead of guessing up/down.
Once probes resume, unknown re-baselines silently (no spurious recovery
alert) on the first healthy check and reverts to normal tracking.
| type | field | checks |
|---|---|---|
http |
url |
GET; status in expect_status (default any 2xx/3xx); optional keyword must appear in the body |
http |
basic_auth |
optional { "user", "password" } credentials for endpoints behind HTTP Basic auth (e.g. Caddy's basicauth); password may be "env:VAR" |
tcp |
target |
host:port accepts a connection |
Add "disabled": true to any monitor to mark it as temporarily decommissioned:
it's shown greyed out on the status page but never probed and never alerts.
Per-monitor knobs:
"page": "name"— group this monitor onto its own page. Unset (or"index") means the root page at/; named pages are served on/namesubpaths and listed in the nav. Handy for keeping third-party monitors (feeds, SaaS boards) off your own status page. Names are single URL path segments: letters, digits,-,_, at most 64 chars."notify": false— suppress XMPP down/recovery alerts for just this monitor. The status page and history behaviour are unchanged, so an expected-down canary (e.g. a feed that's 404 most of the day) flips on the page without paging you. Omit ortrue= alert."failure_threshold": N— override the globalxmpp.failure_thresholdfor just this monitor, e.g. 1 to flip (and alert) on the first failed check.
{ "name": "YouTube (RSS)", "type": "http",
"url": "https://www.youtube.com/feeds/videos.xml?playlist_id=…",
"interval": "30m", "notify": false, "failure_threshold": 1 }{
"listen": ":8080",
"history_size": 100,
"state_file": "downtime-state.json",
"monitors": [
{ "name": "Website", "type": "http", "url": "https://example.com",
"interval": "30s", "timeout": "5s", "expect_status": [200] },
{ "name": "Postgres", "type": "tcp", "target": "db.internal:5432",
"interval": "60s", "timeout": "5s" }
],
"xmpp": {
"enabled": true,
"jid": "monitor@example.com",
"password": "env:XMPP_PASSWORD",
"server": "",
"recipients": ["you@example.com"],
"failure_threshold": 3
}
}- Durations are Go duration strings (
"30s","2m"). state_filepersists each monitor's last-check timestamp so a restart can reconstruct a coverage gap as unknown (see above); omit to disable.passwordsupportsenv:VARindirection so the secret stays out of the file.serveris an optionalhost:portXMPP override; if empty it's derived from the JID domain on port 5222 (StartTLS).- With
xmpp.enabled: false, transitions are still logged to stdout.
GET /— the embedded status page (polls the API every 10s). Pages other than the default live on subpaths, e.g.GET /external.GET /api/status— JSON snapshot of theindexpage's monitors, plus a"pages"nav list.GET /api/status/<page>— JSON snapshot of one named page; 404 if the page doesn't exist.
notify.Notifier is a one-method interface (Notify(Event) error). Add a
Slack/webhook/email sender by implementing it and wiring it up in main.go
alongside the XMPP notifier.