Argus is a self-hosted uptime monitor in the spirit of BetterStack or UptimeRobot. It checks your endpoints on a schedule, opens an incident the moment one goes down, and shows the whole history on a live dashboard and a public status page.
It started as the first project on my backend roadmap and turned into the most complete thing I’ve built: a concurrent checker in Go, a real-time SolidJS frontend, Postgres-backed history, and request-timing breakdowns you’d normally pay for.
What it does
- Monitors HTTP(S) endpoints with per-monitor method, headers, body, timeout, and check interval
- Keyword assertions — alert if a page stops containing a string, or starts containing one
- Detects downtime and opens/closes incidents automatically
- A live dashboard that updates without a refresh
- A public, no-auth status page with 90-day uptime history
- Uptime %, average and p95 latency, all computed in SQL
- Full request-timing breakdown — DNS, TCP, TLS, TTFB, transfer — plus SSL certificate expiry
Stack
| Layer | Tech |
|---|---|
| Backend | Go |
| Frontend | SolidJS + TypeScript |
| Database | PostgreSQL 18 (pgx/v5) |
| Realtime | Server-Sent Events |
| Migrations | golang-migrate |
| Deploy | Hetzner + Docker |
How it works
The architecture has one idea at its center: every completed check flows into a single onResult callback, and everything else subscribes to it.
pool := checker.NewPool(workers, buffer, func(r checker.URLResult) {
models.Results.Insert(ctx, r) // 1. persist the check
if summary := models.Incidents.DetectAndUpdate(ctx, r); summary != nil {
r.Incident = summary // 2. open or close an incident
}
hub.Broadcast(r) // 3. push to the live dashboard
})cmd/server/main.go
Adding a new concern — an email alert, a webhook — means adding one line here, not threading a dependency through the checker. A fixed worker pool does the actual checking, so the number of in-flight HTTP requests stays bounded no matter how many monitors exist. A scheduler ticks every 10 seconds and submits only the monitors that are due, based on each one’s interval.
Engineering decisions worth calling out
- Two IDs per row. A UUID v7 is the internal primary key — sortable, no index hotspot. A short NanoID (
mnt_…,inc_…) is the public API surface. The two never cross: foreign keys use the UUID, API responses use the NanoID. - SSE over WebSockets. Checks flow one way, server to client. Plain HTTP means no upgrade handshake, it survives proxies untouched, and the browser reconnects for free. WebSockets would have been bidirectional plumbing I didn’t need.
- A denormalized
last_checked_atcolumn, kept honest by writing it in the same transaction as the result insert — so the scheduler reads one indexed row per monitor instead of aggregating the results table on every tick. - Timing-safe API key comparison with
crypto/subtle.ConstantTimeCompare, so a==short-circuit can’t leak the key one byte at a time. - BetterStack-style timing for free via
net/http/httptrace— hooks capture DNS, TCP, TLS, TTFB, and transfer durations on every check without a single extra request.
Status
Live and running, still building incrementally.