LIFE COMPASS

0005 — Cloudflare Pages, for control over response headers

Context

The site is served from GitHub Pages today at a custom subdomain, built by Jekyll’s auto-enabled plugins, with no CI at all. GitHub Pages could keep hosting it after the rewrite — Actions can build and deploy there perfectly well. So moving needs a reason beyond wanting the newer thing.

Decision

Cloudflare Pages, and the reason is response headers. Two things depend on them and neither is optional.

A service worker must be served Cache-Control: no-cache, or clients hold onto a stale worker and new deploys never reach the people who already installed the app. GitHub Pages offers no header control whatsoever.

More importantly, the privacy claim in 0006 rests on a Content-Security-Policy — default-src 'self'; connect-src 'none'; form-action 'none'. That turns “this application does not transmit your writing” from an assertion in a README into something the browser enforces, including against a future dependency, a careless change, or a compromised build. A _headers file makes it deployable; running that real file through a Pages emulator in CI makes it enforceable.

Per-PR preview deployments are a real benefit but were not the deciding factor.

Consequences

C1. A DNS cutover, sequenced deliberately and late. The live domain stays on GitHub Pages throughout the rewrite, with production builds going to the pages.dev origin until the app is actually worth switching to.

C2. Deploy plumbing to build and own: preview on pull request, production on main, teardown on close.

C3. Preview deployments are named pr-<number> rather than by branch. Cloudflare truncates preview alias hostnames to 28 characters, and a branches/<issue>-<slug> convention spends nine of those on the prefix alone — so two pull requests against one issue would otherwise collide onto a single alias and silently overwrite each other’s previews.

C4. Teardown must walk every page of the deployments API. It offers no server-side branch filter, and reading only the first page reaps a branch’s newest deployment while leaking all its earlier ones — while still reporting success.

C5. A Cloudflare account becomes a dependency, and deploy credentials become repository secrets.

C6. Pages canonicalises /page.html to /page with a 308, and answers an unmatched path with 200 and the site’s index unless a 404.html exists. Neither is configurable, neither matches what GitHub Pages did, and neither was visible until something was actually deployed — the second meant a mistyped link looked like a working page. A 404 page now exists; the redirect is left in place, because keeping .html files on disk is what allows links written before the move to survive it.

The redirect is not finished business. A service worker caching /page.html caches a redirect rather than a page, so the canonical form has to be settled before one exists (#23), and this is where the “keep the extensions, that is what the live site serves” reasoning in pages.ts stops being true — the live site is now this build.

C7. The zone can override the headers Pages sends, so _headers is not by itself the whole contract. Cloudflare’s default Browser Cache TTL is four hours, and it silently rewrote max-age=0, must-revalidate to max-age=14400 on static assets — HTML was untouched, which is why nothing looked wrong until there were assets to notice it on. A service worker served that way would strand installed clients on stale code for up to four hours after every deploy: precisely the failure this record chose Cloudflare to be able to prevent, arriving through the door the choice opened.

Fixed by a Cache Rule setting Browser Cache TTL to Respect Origin for the hostname, rather than by exceptions for /sw.js and *.js — path exceptions leave two places to reason about, and the next asset type that needs one gets forgotten.

That rule lives in a dashboard and cannot be version-controlled, so the production smoke test asserts its observable consequence instead: every checked path must serve the same Cache-Control through the zone as the origin sends without one. Comparing the two rather than pinning a literal value means the check survives _headers declaring different policies per path (#23), and it fails loudly if either side returns nothing — otherwise two empty strings compare equal and the assertion passes having verified nothing.