A minimal uptime/status dashboard that runs entirely on GitHub — no server, no database, no container. GitHub Actions performs HTTP/TCP checks every 5 minutes and publishes a static dashboard to GitHub Pages. Machines that aren't reachable from the public internet (behind a VPN, etc.) instead push a heartbeat to GitHub themselves.
GitHub Actions (every 5 min) VPN-only machines
-> scripts/check.py (http/tcp checks) -> scripts/heartbeat.py (cron)
-> renders pure HTML (index.html) |
|
-> data branch (history.json) <------------------------+ writes data/heartbeats/<slug>.json
-> data branch (status.json) via GitHub Contents API, no inbound access needed
-> GitHub Pages (via Actions) index.html + files/org.css
The dashboard needs history to compute 24h/7d/30d uptime, but a monitor that
runs every 5 minutes must not create ~288 commits/day on main — that
would drown out real commit history and bloat the repo you actually work in.
The chosen design: history lives as two small JSON files (status.json,
history.json) on a dedicated, orphaned data branch, updated by a bot
commit each run. main only ever contains code, so it stays clean. The
data branch does accumulate one commit every 5 minutes, but that's fine —
it's not a branch you browse or diff by hand, and old commits there cost
nothing to ignore. GitHub Pages is deployed straight from a build
artifact (actions/upload-pages-artifact + actions/deploy-pages), so
there's no gh-pages branch either.
Tradeoffs considered and rejected:
- Actions cache/artifacts as the store: caches are evictable (7-day default retention, LRU-evicted under storage pressure) and not meant for durable data — a cache miss would silently reset all history.
- Committing history to
main: simplest to explain, but produces hundreds of noisy commits/day exactly as the prompt says to avoid. - External database (Supabase/Postgres/etc.): durable and query-friendly, but it's a persistent external dependency — the opposite of "GitHub-only".
Rolling retention (default 30 days) is enforced on every run in
scripts/check.py, so history.json never grows without bound, and any
monitor removed from config/monitors.yml has its history dropped on the
next run.
Because a VPN-only machine's heartbeat.py cron job writes directly to the
data branch via the GitHub API — independently of, and possibly
concurrently with, the scheduled check's own commit — the workflow's commit
step retries with a rebase on a rejected (non-fast-forward) push instead of
just failing. This was tested against a real race (a heartbeat landing
between the workflow's fetch and its push) before being relied on.
.github/workflows/monitor.yml the one workflow: check -> commit -> deploy
config/monitors.yml monitors to check (edit this to add/remove machines)
files/org.css single stylesheet (pure CSS, light/dark themes)
scripts/check.py the checker & pure HTML generator (stdlib + PyYAML only)
scripts/heartbeat.py run via cron ON a VPN-only machine to report it's alive
scripts/requirements.txt just PyYAML
README.md this file
data/ does not live on main — it's generated at runtime (see above), and
is gitignored locally so a local test run doesn't accidentally get committed
to the wrong branch.
- Push this repo to GitHub (if you haven't already).
- In Settings → Pages, set Source to GitHub Actions (not "Deploy from a branch"). This only needs to be done once.
- In Settings → Actions → General → Workflow permissions, make sure
"Read and write permissions" is allowed for
GITHUB_TOKEN(needed so the workflow can push to thedatabranch — the workflow also declarespermissions: contents: writeexplicitly at the job level, but the repo default must not be set to "Read only" or that override still gets blocked on some org policies). - Edit
config/monitors.ymlwith your real services (see below). - Commit and push. Either wait for the next 5-minute tick, or trigger the workflow manually: Actions → Monitor → Run workflow.
- The first successful run creates the
databranch automatically (it doesn't need to exist beforehand) and deploys the site. Your dashboard will be live athttps://<user>.github.io/<repo>/.
Edit config/monitors.yml. Each entry is http, tcp, or heartbeat:
monitors:
- name: ROARS Website # must be unique — used as the history key
type: http
url: https://roars.dev
timeout: 10 # seconds, optional (default 10)
- name: Example SSH Server
type: tcp
host: example.roars.dev
port: 22
timeout: 5
- name: Taco # a machine GitHub Actions can't reach directly
type: heartbeat
slug: taco # matches `heartbeat.py taco` run on the machine
max_age: 900 # seconds; down if no heartbeat within this window (optional, default 900)- HTTP checks issue a
GETand treat any2xx/3xxresponse as healthy (redirects are not followed — a3xxitself counts as "the server responded", which is what you want for a reachability check). - TCP checks just open and close a TCP connection to
host:port. - Heartbeat checks don't make any network call from GitHub Actions at all —
they just check whether
data/heartbeats/<slug>.jsonwas written recently (see "VPN-only machines" below for how that file gets there). - Renaming a monitor is the same as deleting the old one and adding a new one — its history starts fresh under the new name.
- There's no hard limit on monitor count; checks run concurrently
(
ThreadPoolExecutor), so 20+ monitors finish in roughly one timeout period, not N timeout periods.
For machines GitHub Actions can't reach directly — behind a VPN, NAT, or a firewall with no public inbound port — flip the check around: the machine periodically tells GitHub it's alive, instead of GitHub trying to reach the machine.
- Create a fine-grained GitHub Personal Access Token (Settings → Developer settings → Personal access tokens → Fine-grained tokens) scoped to only this repository, with Contents: Read and write permission and nothing else. Don't use a classic PAT — those can't be scoped to a single repo.
- Branch-protect
main(Settings → Branches → Add rule, patternmain, require a pull request before merging). This token has repo-widecontents: write, so without branch protection it could technically also push straight tomain; protectingmainmeans it can only ever land commits on the unprotected, disposabledatabranch — exactly the permission the heartbeat actually needs, and no more. - On the machine, install the token and a crontab entry:
crontab -e # every 5 minutes, matching the monitor's default max_age of 900s */5 * * * * GH_TOKEN=github_pat_xxx REPO=dynaroars/stats /usr/bin/python3 /path/to/scripts/heartbeat.py taco
scripts/heartbeat.pyis stdlib-only (urllib), so no venv/pip is needed on the monitored machine itself. - Add a matching
type: heartbeatentry toconfig/monitors.yml(see above) with the sameslug.
If a heartbeat is missed once (network blip, cron didn't fire), the default
max_age of 900s (3x the 5-minute interval) absorbs it without flapping the
dashboard. heartbeat.py itself doesn't retry on failure — it's cheap to
just wait for the next cron tick.
.github/workflows/monitor.yml runs on a */5 * * * * cron and on manual
workflow_dispatch. Each run:
- Checks out
main(code) and thedatabranch (history), as a git worktree — creatingdataas an orphan branch on the very first run. - Runs
scripts/check.py, which checks every monitor concurrently, appends one sample per monitor tohistory.json, prunes samples older than the retention window, and generatesindex.html. - Commits the updated
data/files to thedatabranch (skipped if nothing changed, which won't normally happen sincechecked_atalways changes, but the check exists for safety). If the push is rejected because a heartbeat commit (see below) landed ondatain the meantime, it fetches, rebases, and retries (up to 3 times) instead of failing. - Assembles
index.html+files/org.cssand deploys it to GitHub Pages viaactions/upload-pages-artifact+actions/deploy-pages— the current recommended approach, which deploys from a build artifact instead of pushing rendered HTML into a branch.
A concurrency: group: status-monitor block serializes runs so two
overlapping executions (e.g. a manual run firing right as the scheduled one
starts) can't race on the data branch push or on the Pages deployment —
the second run queues instead of running in parallel.
To serve this at uptime.roars.dev or status.roars.dev:
- Settings → Pages → Custom domain, enter the subdomain. GitHub commits
a
CNAMEfile into the Pages deployment automatically once this is set — you don't need to add one tosite/yourself. - At your DNS provider, add a
CNAMErecord for the subdomain pointing to<user>.github.io. - Wait for DNS to propagate, then enable Enforce HTTPS in the same Pages settings page once GitHub shows the certificate as issued.
python3 -m venv .venv && source .venv/bin/activate.fish # or activate for bash
pip install -r scripts/requirements.txt
# run one check cycle (generates index.html in the main directory)
python scripts/check.py
# open directly in browser or serve locally
python3 -m http.server 8000
# open http://localhost:8000Run the check command a few times (or on a loop) to build up enough history samples to see the uptime bar and percentages populate.
- Zero JavaScript on the frontend — the site serves pure static HTML and files/org.css.
- Checks are unauthenticated HTTP/TCP only. If you later need authenticated
checks (an API key in a header, etc.), pass them into
check.pyvia${{ secrets.SOME_TOKEN }}in the workflow'senv:, read them withos.environin Python, and never write the raw secret value intostatus.jsonorhistory.json(both files are public, served straight to the browser). - The
heartbeat.pyfine-grained PAT lives only on the machine running it (in its crontab/environment), never in this repo or in GitHub Actions secrets. It hascontents: writeon this repo, which is why main should be branch-protected before you hand that token to a machine (see "VPN-only machines" above) — that keeps the token's blast radius limited to the disposabledatabranch even if the machine is compromised.
- Not real-time. GitHub Actions scheduled workflows have a practical minimum interval of about 5 minutes, and the actual trigger time can drift by a few minutes under GitHub's load — cron schedules are "best effort", not guaranteed to the minute. This is not designed for second-by-second monitoring or paging-grade alerting.
- No notifications (email/Slack/etc.) are built in — this is a status page, not an alerting system. It would be a reasonable extension to add a step to the workflow that notifies on a status transition.
- History resolution is one sample per check interval (5 min); there's no sub-interval granularity.
- IPv4/IPv6 dual-stack, DNS-round-robin, and geographically distributed checks aren't supported — checks run from wherever the GitHub Actions runner happens to be.