Upptime Status Page Bootstrap¶
End-to-end, repeatable walkthrough for turning this harness's disabled-by-default
Upptime templates (upptime.yml, upptime-site.yml, gcp-incidents.yml,
.upptimerc.yml.example) into a live status page. Written after
saxo-nordic-small-cap-trader-paper-v2 bootstrapped from these templates and
hit every gap described below (issues #78-81 there; harness issue #31 here).
Read this in full before enabling anything — the failure modes below are all silent: the workflows report green, and the status page still looks broken.
Who this is for¶
Almost every project bootstrapped from this harness. GCP-backed projects default to a private GitHub repo, and every step in "Private repo: the part everyone hits" below applies unconditionally to private repos — there is no way around it short of making the repo public.
The monitoring split (context, not new)¶
| Surface | Monitoring mechanism | How it reaches Upptime |
|---|---|---|
| Cloud Run Service (HTTP) | Upptime HTTP check (OIDC private-audience) | Direct HTTP poll in upptime.yml, via a sites: entry |
| Cloud Run Jobs (batch) | Cloud Monitoring on completed_execution_count, result=failed |
Alert → Pub/Sub → gcp-incidents.yml → GitHub Issue → Upptime incident |
Jobs have no HTTP endpoint — Upptime cannot poll them. Don't add a sites:
entry for a job. See infra/monitoring.tf.example for the Pub/Sub + alert
policy wiring this requires.
Step 1 — Create the Cloudflare Pages project¶
Separate from the docs-site Pages project (<repo-slug>). Naming convention:
<repo-slug>-status.
This is not currently automated by /bootstrap-gcp-tofu-github — do it
manually, once, per project.
Step 2 — Copy and fill in .upptimerc.yml¶
Fill in owner, repo, and status-website.cname (use the *.pages.dev URL
if you haven't attached a custom domain yet — attaching one is a one-time
manual step in the Cloudflare dashboard; DNS-via-API is out of scope).
status-website.publish: true is required if the repo is private.
upptime/uptime-monitor's site generator has this exact guard in
src/site.ts:
if (repoDetails.data.private && !(config["status-website"] || {}).publish) {
mkdir("-p", "status-page/__sapper__/export");
exec("echo 404 > status-page/__sapper__/export/index.html");
cd("../..");
return;
}
Without publish: true, every scheduled run silently deploys a 4-byte
index.html containing literally 404 instead of building the real site.
The workflow still reports success — there's nothing to catch this except
knowing to look for it. Leave sites: [] if there's nothing to HTTP-poll
(jobs-only projects, per the split above) — the status page still works,
showing only Cloud-Monitoring-sourced incidents.
Step 3 — Private repo: the part everyone hits¶
@upptime/status-page (the Sapper app Upptime builds and deploys) fetches
all of its data client-side, unauthenticated, directly from the visitor's
browser:
- Incidents/maintenance:
octokit.issues.listForRepo(...)againstapi.github.com - Live status history: a raw
fetchofhistory/summary.jsonagainstraw.githubusercontent.com
Both 404/403 for a private repo — there is no visitor-facing way to supply a
token in this version of the template. Every page load redirects to /error
("An error occurred in trying to get the latest status details"), even once
Step 2 is done correctly.
Fix: a same-origin Cloudflare Pages Functions proxy that injects a
read-only token server-side, with a strict allow-list — not an open proxy.
Cloudflare Pages Functions live in a functions/ directory and are bundled
automatically by wrangler pages deploy from wherever the command is run —
this repo's checked-out root, in the reusable _uptime-site.yml workflow.
No changes to that shared reusable workflow are needed.
This ships two routes (gh-api/[[path]].js, gh-raw/[[path]].js) backed by
shared logic in functions/_lib/github-proxy.js. Read that file before
trusting it: every upstream path is an exact allow-list match against the
handful of endpoints @upptime/status-page actually calls (issues
list/detail/comments, history/summary.json) — never a caller-controlled
path. This is what keeps the rest of the private repo private even though the
underlying PAT (below) technically has broader read access. Don't relax the
allow-list to "anything under /repos/{owner}/{repo}/*" or similar — that
reopens exactly the hole this proxy exists to close.
Uncomment in .upptimerc.yml:
status-website:
apiBaseUrl: https://<cname-or-pages-domain>/gh-api
userContentBaseUrl: https://<cname-or-pages-domain>/gh-raw
Provisioning the token¶
Two Cloudflare Pages project settings, both scoped to the <repo-slug>-status
project:
-
Non-secret env vars
GH_STATUS_OWNER,GH_STATUS_REPO— set via the Cloudflare dashboard (Pages project → Settings → Environment variables) or:(npx wrangler pages secret put GH_STATUS_OWNER --project-name=<repo-slug>-status npx wrangler pages secret put GH_STATUS_REPO --project-name=<repo-slug>-statuswrangler pages secret putworks for plain config values too, not just secrets — using it keeps them alongside the real secret below, but the dashboard's plain "Environment variables" section works identically.) -
GH_STATUS_RO_TOKEN— a fine-grained GitHub PAT, created at https://github.com/settings/personal-access-tokens/new, scoped to: - Repository: this repo only
- Permissions: Issues: Read-only, Contents: Read-only
This step cannot be automated or delegated to an agent. GitHub does not expose an API to create personal access tokens (fine-grained or classic) — token creation is deliberately interactive-browser-only, so scripts/agents can't mint credentials on a user's behalf. A human has to do this, every time (and again on renewal — fine-grained PATs expire, max 1 year).
Store it:
(paste the token when prompted — don't pass it as a command-line argument, it'll land in shell history)This is a Cloudflare-side secret, separate from the GitHub Actions
secrets pipeline (secrets: inherit in the workflow does not touch it).
It's read by the Pages Function at request time via context.env, never
returned to the browser.
Do not reuse the repo's GH_PAT (used elsewhere in CI/CD) for this. That
token has much broader scope, and this one sits behind a public-facing
endpoint — least privilege matters more here, not less.
Step 4 — Generate history/summary.json¶
upptime.yml's default update command only performs response-time checks
against sites: — it does not write history/summary.json. Only the
readme/summary command does, and that command is not scheduled by
default. If nobody runs it, LiveStatus.svelte's unconditional fetch of
history/summary.json 404s regardless of everything else being correct
(content will just be [] if sites: [] — that's fine and expected, but the
file has to exist).
Run it once manually:
Then decide whether to schedule it (uncomment the schedule: block in
upptime.yml — see the comment there for the recommended two-schedule
approach: update frequently, readme at whatever cadence makes sense given
how often incidents/sites change).
Step 5 — Enable and deploy¶
Uncomment the schedule: blocks in upptime.yml and upptime-site.yml once
you're satisfied with a manual dry run.
Verification checklist¶
Run through all of these on a fresh bootstrap — each catches a different failure mode above, and none of them show up as a red CI check:
- [ ]
curl -sD - https://<status-domain>/— content-length should be several KB (a real Sapper page), not 4 bytes containing404. - [ ]
curl https://<status-domain>/gh-api/repos/<owner>/<repo>/issues?labels=status→200, not an error page.curl https://<status-domain>/gh-api/repos/<owner>/<repo>/contents/anything→404(allow-list rejecting an out-of-scope path). - [ ]
curl https://<status-domain>/gh-raw/<owner>/<repo>/master/history/summary.json→200. Any other path under/gh-raw/→404. - [ ] Load the page in an actual browser (or
playwright-cli) and confirm it does not redirect to/error— curl alone can't catch this, since the redirect happens client-side after the Svelte components mount. - [ ] Network tab /
playwright-cli networkshows the issues andhistory/summary.jsonrequests going to/gh-api/*and/gh-raw/*(same-origin), notapi.github.com/raw.githubusercontent.comdirectly, and all returning200.
Why not something simpler?¶
This was reconsidered explicitly (see
docs/experiments/sessions/2026-07-01-upptime-private-repo-proxy.md and the
ADR-0013 addendum in saxo-nordic-small-cap-trader-paper-v2). The
alternatives:
- Make the repo public. Simplest technically, but most projects bootstrapped from this harness have code that shouldn't be public.
- Public status-only repo, separate from the private code repo, holding
just
.upptimerc.ymland incident issues. No proxy needed at all — this is Upptime's own documented pattern for private-code users. Genuinely simpler if you're starting fresh and don't mind a second repo. Not what this guide covers, since keeping everything in one repo is usually preferred once a project is already underway. - Open proxy (no allow-list). Rejected outright — it would let anyone read any file or any issue in the private repo via the proxy's own token, defeating the point of keeping the repo private.