Skip to content

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.

npx wrangler pages project create <repo-slug>-status --production-branch=main

This is not currently automated by /bootstrap-gcp-tofu-github — do it manually, once, per project.

Step 2 — Copy and fill in .upptimerc.yml

cp .upptimerc.yml.example .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(...) against api.github.com
  • Live status history: a raw fetch of history/summary.json against raw.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.

cp -r functions.example/ functions/

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:

  1. 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>-status
    
    (wrangler pages secret put works 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.)

  2. GH_STATUS_RO_TOKEN — a fine-grained GitHub PAT, created at https://github.com/settings/personal-access-tokens/new, scoped to:

  3. Repository: this repo only
  4. 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:

npx wrangler pages secret put GH_STATUS_RO_TOKEN --project-name=<repo-slug>-status
(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:

gh workflow run upptime.yml -f enabled=true -f command=readme

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

gh workflow run upptime-site.yml -f enabled=true -f pages-project=<repo-slug>-status

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 containing 404.
  • [ ] 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 network shows the issues and history/summary.json requests going to /gh-api/* and /gh-raw/* (same-origin), not api.github.com/raw.githubusercontent.com directly, and all returning 200.

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.yml and 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.