Not every status page is public. To restrict one to employees or a specific audience, gate it at the edge with Cloudflare Access (part of Cloudflare Zero Trust) instead of adding a login to the app.
Why at the edge, not in the app
Cloudflare Access is an identity gate you configure in the Zero Trust dashboard — an Access application bound to a hostname (or path) plus a policy that decides who may enter. It runs ahead of the StatusBeam Worker and the edge cache in Cloudflare’s request pipeline, which gives it three properties that make it the right layer for this:
- No app code. StatusBeam ships no login, session, or authorization logic. Identity, MFA, and session lifetime are handled by Access, wired to your own identity provider (Google, Okta, GitHub, Microsoft Entra, or a one-time PIN).
- Cache-safe. Because Access authenticates before the request reaches the cache, private content is never served from cache to an unauthenticated visitor — and the near-instant cache-purge-on-status-change still works.
- Composable. “Fully internal” and “public + internal” are the same primitive — an Access application on a hostname — applied once or twice.
Prerequisite: a custom domain
Access can only protect a hostname on a Cloudflare zone in your account — it
cannot protect a bare *.workers.dev URL. So a private page needs a
custom domain, and you should also close the unguarded
fallback URL:
// wrangler.web.jsonc
{
"workers_dev": false, // don't expose the unauthenticated *.workers.dev URL
"routes": [{ "pattern": "status.internal.example.com", "custom_domain": true }],
}Without workers_dev: false, the page stays reachable — ungated — at its
*.workers.dev address even after you add Access to the custom domain.
Gate the whole page
-
In the Cloudflare dashboard, go to Zero Trust → Access → Applications → Add an application → Self-hosted.
-
Set the application domain to your status page hostname (e.g.
status.internal.example.com). -
Add a policy with Action: Allow, scoped to your audience — Emails ending in
@yourco.com, an identity-provider group, or a one-time PIN. Save. -
Visit the page in a fresh browser session: you should be redirected to the Access login before the status page renders.
Everything served on that hostname — the pages, badges, feeds, and JSON API — now requires sign-in.
Keep badges, feeds & JSON public (optional)
A whole-hostname gate also blocks the cookie-less machine endpoints, which are meant to be fetched without a browser session:
- Badges —
/api/badge/[slug]/uptime.json,.../response-time.json(embedded in READMEs and fetched by GitHub’s image proxy) - Feeds —
/feed.rss,/feed.atom,/history.rss,/history.atom - JSON —
/api/status.json,/api/status/[slug].json
For a fully internal page, gating these too is usually what you want. But if you need some to stay reachable while the page itself is private, choose per consumer:
- Public machine endpoints — add a second, more-specific Access application
with a Bypass policy (Everyone) for each public path; a more-specific path
application takes precedence over the hostname-wide one. Badges and JSON both live
under
/api, so one bypass app onstatus.internal.example.com/apiopens both — but the feeds do not:/feed.rss,/feed.atom,/history.rss, and/history.atomsit at the root, so add a bypass app for each or they stay gated. - Trusted CI / monitors — issue an Access
service token
and send its
CF-Access-Client-Id/CF-Access-Client-Secretheaders on each request, instead of opening the path to everyone.
Public page + a separate internal page
Access is all-or-nothing per hostname — it cannot show some services publicly and others only to staff within one page. To run both, deploy a second copy rather than adding visibility rules to the app:
-
Copy
wrangler.web.jsonctowrangler.internal.jsonc. Give it a different Workernameand an internalrouteshostname. Point it at its own D1/KV — or reuse the same bindings with a separatestatus.config.ymlif only the service list differs. -
Deploy it alongside the public one:
bunx wrangler deploy --config wrangler.internal.jsonc -
Add an Access application (see above) on the internal hostname only. Leave the public hostname open.
You now have status.example.com (public) and status.internal.example.com
(gated) running as two independent Workers, with no per-viewer logic anywhere.