Lightweight, self-hosted status page written in Go. Monitors HTTP endpoints, stores results as daily JSON files, and renders a clean status page inspired by Better Stack.
- HTTP health checks with configurable interval, timeout, and expected status code
- 90-day uptime history displayed as a pill-shaped bar chart per endpoint
- Sections to group endpoints by region or environment
- Retry mechanism — on failure, retries up to 3 times at 5-second intervals before marking down
- Degraded detection — services that recover on retry or exceed latency thresholds are marked degraded (yellow), not down
- Timeout detection distinguishes timeouts from hard failures in logs
- Status logic: green (all ok), yellow (degraded), red (down after all retries)
- JSON file persistence — one file per day, atomic writes, no database required
- Embedded assets — single binary with templates and CSS baked in via
embed.FS - Page caching with automatic invalidation on new check results
- Graceful shutdown on SIGINT/SIGTERM
- TLS skip verify per endpoint for self-signed or incomplete certificate chains
- Custom slugs to decouple display names from data storage keys
- Structured logging with
[UP],[DOWN],[TIMEOUT]prefixes /healthAPI endpoint — JSON endpoint for programmatic monitoring of all endpoints- Webhook notifications — alerts on state changes (up→down, down→up) with native Slack, Discord, and generic HTTP POST support
- Multiple webhook URLs — send notifications to several webhooks simultaneously (e.g. Slack + Discord)
- SVG status badges — embeddable shields.io-style badges per endpoint (
/badge/{slug}.svg) - Latency display — current response time shown next to each endpoint's uptime percentage
- Auto-refresh — page reloads every 30 seconds via meta refresh
- Automatic data cleanup — JSON files older than 90 days are deleted on startup
- Custom logo — display your own logo in the header from a local image file
- Dark mode toggle — switch between light and dark themes, preference saved in localStorage
- Hardened Docker image — multi-stage build with UPX compression, runs as
nobody, read-only filesystem
cd src
go build -o statusly .
./statusly # uses ./config.yaml
./statusly /path/to/config.yamldocker compose up -d --buildThe app will be available at http://localhost:8080.
Create a config.yaml in the project root:
title: "Statusly"
port: 8080
data_dir: "data"
sections:
- name: "Production"
endpoints:
- name: "API"
url: "https://api.example.com/health"
interval: 30s
timeout: 10s
expected_status: 200
latency_threshold: 2s
- name: "Web App"
url: "https://app.example.com"
interval: 60s
timeout: 10s
expected_status: 200
- name: "Staging"
endpoints:
- name: "API Staging"
slug: "api-staging"
url: "https://staging-api.example.com/health"
interval: 30s
timeout: 10s
expected_status: 200
skip_tls_verify: true| Field | Default | Description |
|---|---|---|
title |
"Statusly" |
Page title shown in the header |
logo |
— | Path to a logo image file (replaces title text in header) |
port |
8080 |
HTTP server port |
data_dir |
"data" |
Directory for JSON persistence files |
The logo is loaded into memory at startup and served at /logo. In Docker, mount the file as a volume:
volumes:
- ./logo.png:/logo.png:ro| Field | Default | Description |
|---|---|---|
name |
— | Display name shown on the status page |
slug |
— | Data storage key (auto-generated from name if empty) |
url |
— | URL to check |
interval |
60s |
Time between checks (Go duration: 30s, 5m, etc.) |
timeout |
10s |
Request timeout |
expected_status |
200 |
HTTP status code that counts as "up" |
skip_tls_verify |
false |
Skip TLS certificate verification |
latency_threshold |
— | Response time threshold for degraded status (Go duration: 500ms, 2s) |
Configure webhook notifications to receive alerts when an endpoint changes state (up→down or down→up):
notifications:
# Single webhook
webhook_url: "https://hooks.slack.com/services/T.../B.../xxx"
# Or multiple webhooks
webhook_urls:
- "https://hooks.slack.com/services/T.../B.../xxx"
- "https://discord.com/api/webhooks/123/abc"
- "https://your-server.com/webhook"Both webhook_url and webhook_urls can be used together — they are merged and deduplicated.
Supported webhook formats (auto-detected from URL):
- Slack —
hooks.slack.comURLs. Sends rich message with color-coded attachment (green/red) and block layout. - Discord —
discord.com/api/webhooksURLs. Sends embed with color and timestamp. - Generic HTTP POST — any other URL. Sends JSON with
endpoint,status,previous_status,http_status,latency_ms, andtimestampfields.
| Field | Description |
|---|---|
webhook_url |
Single webhook URL. Leave empty to disable. |
webhook_urls |
Array of webhook URLs for multi-target notifications. |
The /health endpoint returns a JSON summary of all monitored endpoints:
curl http://localhost:8080/health{
"status": "healthy",
"endpoints": {
"api": { "up": true, "status": 200, "latency_ms": 42 },
"web-app": { "up": false, "status": 500, "latency_ms": 300 }
}
}status:"healthy"if all endpoints are up (or no data yet),"degraded"if any endpoint is down- HTTP status is always 200 — consumers decide how to interpret the JSON
- Endpoints with no check data yet are omitted from the response
Each endpoint gets an embeddable SVG badge at /badge/{slug}.svg:
https://status.example.com/badge/api.svg
https://status.example.com/badge/web-app.svg
Embed in your README or documentation:
The badge shows the endpoint slug and current status (up in green, down in red, unknown in gray). Badges include Cache-Control: no-cache headers to always reflect current state.
Endpoints are grouped into sections, rendered as separate cards on the page. Use them to organize by region, environment, or service tier.
If you use the legacy flat endpoints key (without sections), they will be auto-migrated into a single "Monitors" section.
By default, the data storage key is derived from the endpoint name (lowercased, spaces replaced with hyphens, non-alphanumeric characters stripped). Set slug explicitly to rename an endpoint without losing historical data:
- name: "API v2 (new name)"
slug: "api" # keeps using old data
url: "https://api.example.com"On each check, if the initial request fails (error or wrong status code), Statusly retries up to 2 more times at 5-second intervals:
- Up — first attempt succeeds with expected status code
- Degraded — first attempt fails but a retry succeeds, or response time exceeds
latency_threshold - Down — all 3 attempts fail
Each day in the 90-day uptime bar is color-coded:
| Color | Condition | Tooltip |
|---|---|---|
| Green | All checks passed | "Operational" |
| Yellow | Degraded events (no down checks) | "Degraded (N events)" |
| Red | Any down checks (all retries failed) | "Down for X" |
| Gray | No data | "No data" |
Down duration is calculated from the number of failed checks multiplied by the check interval.
The global status banner shows:
- "All services are online" when all endpoints with data are up
- "Some services are experiencing issues" when any endpoint is down
- Endpoints with no data yet do not affect the global status
src/
├── main.go # Entrypoint, signal handling, wiring
├── config.go # YAML config parsing, defaults, validation
├── checker.go # HTTP health checker with retries and degraded detection
├── store.go # JSON file persistence, in-memory summaries
├── server.go # HTTP server, template rendering, page cache, /health API, /badge SVG
├── notifier.go # Webhook notifications (Slack, Discord, generic, multi-target)
├── templates/
│ └── index.html # Go html/template with embedded functions
├── static/
│ ├── style.css # Styles (Better Stack-inspired design)
│ └── favicon.svg # SVG favicon (green check mark)
├── go.mod
└── go.sum
The system is designed so that serving an HTTP request never touches the disk. There are three layers:
1. Store (in-memory) — The source of truth at runtime. Holds three maps:
current— last check result per endpoint (1 entry each)summaries— 90 daily summaries per endpoint (just 5 integers: date, total, up, degraded, timeout)todayResults— raw check results for the current day only
JSON files on disk are write-only during normal operation — they exist purely as persistence so data survives restarts. The only time files are read is store.Load(90) at startup.
2. HTML cache — The fully rendered page is stored as a []byte. When a checker reports a new result, it calls InvalidateCache() which sets a dirty flag. The next HTTP request re-renders the template from in-memory data and caches the result. Subsequent requests serve the cached bytes directly — no template execution, no data access.
3. Disk (JSON files) — One file per day, written atomically (tmp + rename) after each check. Never re-read after startup. Old files are only read during the initial 90-day load.
Data flow:
Checker.check()
→ store.Record() (updates memory + writes today's JSON)
→ srv.InvalidateCache() (marks HTML cache dirty)
handleIndex()
→ dirty? → yes → buildPage() from memory → cache HTML
→ dirty? → no → serve cached []byte directly
Only today's raw results are kept in memory. The other 89 days are compressed into summaries (4 integers per day per endpoint). Estimated RAM usage:
| Component | 10 endpoints | 100 endpoints |
|---|---|---|
current (1 result/endpoint) |
~320 B | ~3 KB |
summaries (90 days × 64 B) |
~56 KB | ~560 KB |
todayResults (2880 results/day at 30s) |
~900 KB | ~9 MB |
| HTML cache | ~100 KB | ~1 MB |
| Go runtime + goroutines | ~10 MB | ~15 MB |
| Total | ~12 MB | ~25-30 MB |
At 30s intervals, each endpoint generates 2,880 check results per day (~32 bytes each). The dominant cost is today's raw results. Scaling to 100 endpoints still fits comfortably under 30 MB.
Check results are persisted as JSON files, one per day:
data/
├── 2026-02-26.json
├── 2026-02-27.json
└── 2026-02-28.json
Each file maps endpoint slugs to arrays of check results:
{
"api": [
{ "ts": 1740700800, "status": 200, "latency_ms": 42, "up": true },
{ "ts": 1740700830, "status": 200, "latency_ms": 85, "up": true, "degraded": true },
{ "ts": 1740700860, "status": 500, "latency_ms": 300, "up": false }
],
"web-app": [{ "ts": 1740700800, "status": 200, "latency_ms": 15, "up": true }]
}On startup, the last 90 days of data are loaded into memory to build day summaries. Files older than 90 days are automatically deleted during startup to prevent unbounded disk growth.
The status page automatically reloads every 30 seconds via <meta http-equiv="refresh">. No JavaScript polling or WebSocket connection required.
Multi-stage build: golang:1.25-bookworm for compilation, alpine:3.21 for UPX binary compression (~72% size reduction), and dhi.io/alpine-base:3.23 for the production image. The final image includes only the compressed static binary and CA certificates. Supports multi-platform builds (amd64 and arm64).
services:
statusly:
build: .
ports:
- "8080:8080"
environment:
- TZ=UTC
volumes:
- ./config.yaml:/config.yaml:ro
- ./data:/data
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALLSecurity hardening:
- Read-only filesystem (
read_only: true) - No new privileges (
no-new-privileges) - All capabilities dropped (
cap_drop: ALL) - Runs as
nobody:nobody - Config mounted read-only
Set TZ to your local timezone so daily JSON filenames match your expectations.
cd src
go test -v ./...cd src
go test -cover ./...cd src
go build -o ../statusly .docker compose down && docker compose up -d --buildAll check results are logged to stdout with structured prefixes:
[UP] API: status 200 (42ms)
[DEGRADED] API: recovered after 1 retries, status 200 (85ms)
[DEGRADED] API: status 200, high latency 520ms (threshold 500ms)
[DOWN] Web App: status 500, expected 200 after 3 retries (300ms)
[TIMEOUT] API Staging: timed out after 10042ms (3 retries)
[ERROR] API: failed to record check — write error