A self-hosted, BetterStack-style live status page for a home network.
Built on the Cloudflare edge so the page stays up even when home β or the prober β is down.
A status page that lives at home dies with home β useless. So the page runs on Cloudflare's global edge (a Worker + a D1 SQLite database), and an external always-on server runs a tiny Docker prober that checks your home and pushes heartbeats in.
ββ external always-on server ββ βββββββββββββ Cloudflare edge ββββββββββββ
β prober (Docker) β β Worker Β· D1 Β· per-minute cron β
β every 30s: β HTTPS β β
β check home ββββββββββββββββββββΆ β POST /api/ingest β record + detect β
β POST heartbeat β (Bearer)β up/down flips β
βββββββββββββββββββββββββββββββ β GET /api/status β JSON for the page β
β scheduled() β watchdog + prune β
visitor βββ GET / ββββββββββββββββββΆ β static assets β the status page β
βββββββββββββββββββββββββββββββββββββββββββ
β on a down/up flip
βΌ
π§ Email Β· π¬ Slack Β· β Telegram
Both failure modes surface correctly:
| What breaks | How it's caught |
|---|---|
| π Home is down | The prober's check fails β pushes down β recorded, incident opened, alerts fire. |
| π₯οΈ The prober/server itself dies | No heartbeats arrive β the Worker's per-minute cron watchdog sees stale data (>2 min) and records a synthetic outage. No silent green. |
A note on the check type. The demo monitors
torrent.aswincloud.com, which is proxied through Cloudflare. Apingto it would only reach Cloudflare's edge (always up) and show false-green β so the monitor uses an HTTP check that flows through Cloudflare to the home origin:200when home is up,52xwhen it isn't. For a direct (non-proxied) host,pingortcpwork too. The prober supports all three.
- Overall banner β All systems operational Β· Partial outage Β· Major outage
- Per-monitor cards with a live status pill
- 90-day uptime bar β hover any day for that day's uptime %
- Uptime % over 24h / 7d / 30d
- Response-time sparkline (last 2 hours, SVG, breaks the line on downtime)
- Incident timeline β ongoing + resolved, with durations
- Alerts on every down/recovery β Email (Resend), Slack, and Telegram, each independent
- Dark / light theme toggle Β· fully responsive Β· zero frontend dependencies
- Config-driven β add a monitor by editing one JSON file; the page auto-discovers it
Supported check types: http Β· tcp Β· ping.
wrangler.jsonc Worker + D1 + cron + static-assets + custom-domain config
schema.sql D1 tables + starter monitor seed
src/
index.ts fetch() router + scheduled() cron watchdog
api.ts /api/ingest (auth) + /api/status (edge-cached JSON)
db.ts D1 helpers + Env types + tunables (STALE_MS, RETENTION_MS)
stats.ts uptime %, 90-day buckets, latency series, incidents
alerts.ts email / Slack / Telegram β each activates only when its secrets exist
public/ The status page β index.html Β· styles.css Β· app.js
prober/ The Docker prober (copied to the external server)
Requires a Cloudflare account. For the custom domain, your zone should be on Cloudflare.
npm install
# 1. Create the D1 database, then paste the printed database_id into wrangler.jsonc
npx wrangler d1 create status-db
# 2. Create the tables (remote = the real database the Worker uses)
npx wrangler d1 execute status-db --file schema.sql --remote
# 3. Set the shared ingest secret β SAVE it, the prober needs the same value
# openssl rand -hex 32
npx wrangler secret put INGEST_TOKEN
# 4. Deploy
npx wrangler deployCustom domain β in wrangler.jsonc the routes block maps status.aswincloud.com;
since the zone is on Cloudflare, DNS + TLS are provisioned automatically on deploy.
Before a domain is attached, the Worker is live at home-status.<account>.workers.dev.
Copy the prober/ folder to that server, then:
cd prober
cp .env.example .env
nano .env # INGEST_TOKEN = the same value from step 3 above
# config.json already points at https://status.aswincloud.com/api/ingest
docker compose up -d
docker compose logs -f # expect: "ok β home-network:140ms"restart: unless-stopped + Docker-on-boot keep it running across reboots and crashes.
- Open the page β the monitor turns green within ~30s; sparkline + 90-day bar fill in.
curl -s https://status.aswincloud.com/api/status | jq '.overall, .monitors[0].up'β"operational",true.- Outage test:
docker compose stopthe prober. Within ~2 min the card flips red, an incident opens, alerts fire.docker compose startβ it recovers and the incident shows resolved with a duration.
Edit prober/config.json, then docker compose restart. The new card appears automatically.
{
"monitors": [
{ "id": "home-network", "name": "Home Network", "type": "http", "target": "https://torrent.aswincloud.com" },
{ "id": "jellyfin", "name": "Jellyfin", "type": "tcp", "target": "192.168.1.50:8096" },
{ "id": "router", "name": "Router", "type": "ping", "target": "192.168.1.1" }
]
}
ping/tcpto LAN addresses require the prober to have a network route to them. To delete a monitor's history immediately:npx wrangler d1 execute status-db --remote --command "DELETE FROM monitors WHERE id='jellyfin'; DELETE FROM checks WHERE monitor_id='jellyfin';"
src/alerts.ts fires on every down/recovery transition. Each channel activates only
when its secrets are present β run any combination, or none. Secrets live in
Cloudflare, never in this repo.
π§ Email (Resend)
npx wrangler secret put RESEND_API_KEY # resend.com β sending domain must be verified
npx wrangler secret put ALERT_FROM # "aswincloud status <status@aswincloud.com>"
npx wrangler secret put ALERT_TO # aswin@aswincloud.com (comma-separated for several)π¬ Slack β create an app with the chat:write scope, /invite the bot to a channel,
grab the channel ID (C0β¦):
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put SLACK_CHANNELβ Telegram β create a bot via @BotFather, read your chat id from
https://api.telegram.org/bot<TOKEN>/getUpdates:
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put TELEGRAM_CHAT_IDAfter setting secrets, just push to main (or npx wrangler deploy) β no code change.
Connected to Cloudflare Workers Builds: every push to main runs
npx wrangler deploy; pushes to other branches run npx wrangler versions upload
(preview). Worker secrets persist across builds.
The prober is not part of this pipeline β it's a container on an external box. Update it there with
git pull && docker compose up -d --build.
npx wrangler dev # local Worker + D1 at :8787
npx wrangler d1 execute status-db --file schema.sql --local # seed the local DB
cd prober && INGEST_TOKEN=devtoken INGEST_URL=http://localhost:8787/api/ingest node prober.js
# (give wrangler dev the matching token via a .dev.vars file: INGEST_TOKEN="devtoken")Open http://localhost:8787.
- Fits comfortably in Cloudflare's free tier (Workers + D1 + cron).
- Raw checks are pruned after 90 days; uptime % and the day-bar use indexed
aggregates, and
/api/statusis edge-cached ~15s so page loads never re-scan. - Tunables:
STALE_MS(2 min) andRETENTION_MS(90 d) insrc/db.ts; check interval inprober/config.json(intervalSeconds). - Secrets (
INGEST_TOKEN, alert credentials) are Cloudflare Worker secrets β never committed.