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 chart (last 2 hours, SVG, breaks the line on downtime)
- Internet speed graph β real download/upload Mbps over time (current Β· avg Β· peak), measured at home by a separate speed agent so it reflects your actual connection
- Incident timeline β ongoing + resolved, with durations
- On-demand "Test now" β owner-only button triggers a speed test instantly over a
WebSocket push; gated by Google sign-in (
OWNER_EMAILallow-list) - 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
auth.ts Google OIDC sign-in + signed session cookie (WebCrypto)
agent-link.ts Durable Object holding the speed agent's WebSocket (on-demand push)
public/ The status page β index.html Β· styles.css Β· app.js
prober/ Docker prober β reachability checks (runs OUTSIDE home)
speedagent/ Docker speed agent β Ookla speed tests (runs AT home)
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.
Reachability (the prober) and speed (the agent below) run on different boxes on purpose. The prober must be outside home to tell when home is down; the speed agent must be inside home to measure your real connection.
Measures real home download/upload with the official Ookla CLI and pushes every 15 min. Run it on a machine at home (the one whose internet you want to graph):
cd speedagent
cp .env.example .env
nano .env # INGEST_TOKEN = the same value as everything else
docker compose up -d
docker compose logs -f # expect: "ok β β329.8 β333.4 Mbps Β· ping 6.8ms Β· BSNL"Each test uses real bandwidth (~25 MB down + ~10 MB up). Change the cadence with
INTERVAL_SECONDS in docker-compose.yml. The image auto-detects x86_64 / arm64.
The agent also keeps a persistent outbound WebSocket to the Worker (held by a Durable Object) so an owner can trigger an on-demand test with no polling β see the next section.
An owner-only button in the speed panel triggers a test immediately: the Worker
pushes {cmd:'run'} down the agent's WebSocket β the agent runs a test β the result
appears in ~30s. A server-side 2-minute cooldown prevents abuse. The page itself
stays fully public; only the action is gated β by Google sign-in:
| Method | How it works |
|---|---|
| Sign in (UI) | The Sign in button (top-right) β Sign in with Google. Only an address in OWNER_EMAIL is accepted; sets a signed session cookie. The button then reads Sign out. |
| Control token | Server-side fallback for curl/cron only β send Authorization: Bearer <CONTROL_TOKEN> to /api/request-test. Not exposed in the UI. |
Secrets (all Cloudflare Worker secrets, never in the repo):
npx wrangler secret put SESSION_SECRET # HMAC key for the session cookie
npx wrangler secret put OWNER_EMAIL # comma-separated allow-list: a@x.com,b@gmail.com
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put CONTROL_TOKEN # optional β server-side curl/cron fallbackFor Google sign-in, create an OAuth Web application client and set its redirect
URI to https://status.aswincloud.com/api/auth/callback (scopes openid email).
OWNER_EMAIL is a comma-separated allow-list, so more than one Google account can be
an owner. A failed callback (wrong account, expired state) redirects back to the page
with a dismissible banner + Try another account β never a dead-end error page.
- 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.