Runbook 10 · Cloudflare Pages

Cloudflare Pages production defaults: the demo passed, the domain did not

Platform: Cloudflare Pages — static output and Pages Functions, on *.pages.dev and on a custom domain. Basis: reviewed against the official Pages docs on 2026-11-08. This is a defaults trap, not a change: no dated platform event flips these behaviours, and the Pages changelog has no entry after 2025-04-18 — every item below is current, documented default behaviour that a demo hostname never exercises. Detection chapter reviewed: 2026-11-08. The paid chapter is still in verification on a live project; the status notice in the appendix stays until that finishes.

What changed

Nothing on Cloudflare's side. What changed is the blast radius of your own project: on <project>.pages.dev nobody links to a deleted page, nobody crawls the site, nobody shares a URL, and no certificate or DNS record belongs to you. Put the same build behind a domain — and behind the audit, the sitemap submission and the uptime check that a domain attracts — and eight documented defaults stop being invisible.

That is exactly why the demo passes. None of these defaults produces an error, a build failure or a log line. Each one is a documented behaviour that only becomes visible when a client, a crawler or an attacker exercises a path your click-through never touched.

The eight defaults, as documented on 2026-11-08

  • No top-level 404.html means "SPA". The Serving Pages page states that if a project does not include a top-level 404.html, Pages assumes a single-page application and "serves the root (/) for any incoming path that does not correspond to a static asset". A missing page therefore returns the homepage, and the client sees success (Serving Pages, updated 2026-10-10).
  • .html paths are redirected away. Route matching serves an HTML file when its path matches, then "redirect HTML pages to their extension-less counterparts": /contact.html → /contact, /about/index.html → /about/. Any canonical, sitemap or backlink that keeps the extension points at a URL that is not the one serving the 200 (Serving Pages, updated 2026-10-10).
  • The default response headers are not security headers. Pages always adds Access-Control-Allow-Origin: *, Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options: nosniff, Etag, Content-Type and Server: cloudflare. No X-Frame-Options, no Content-Security-Policy, no Permissions-Policy (Serving Pages, Headers section, updated 2026-10-10).
  • Cacheable assets default to max-age=0. When an asset is cacheable — the request carries no Authorization and no Range header — Pages sends Cache-Control: public, max-age=0, must-revalidate, and always sends Etag on 200 responses so the browser's next request can be answered with 304 (Serving Pages, Caching and performance, updated 2026-10-10).
  • Preview deployments are public, and only preview URLs get noindex. "By default, preview deployments are enabled and available publicly." Every preview deployment carries X-Robots-Tag: noindex; the production *.pages.dev alias does not, which is why the Headers page ships a recipe to add it yourself (Preview deployments, updated 2026-10-12; Serving Pages; Headers, updated 2026-08-25).
  • Caching on the custom domain is yours to break. Pages caches on deploy and serves uploaded assets through Tiered Cache automatically; the docs warn that adding caching to a custom domain "may lead to stale assets being served after a deployment" (Serving Pages, Caching and performance, updated 2026-10-10).
  • Early Hints is always on and cannot be turned off on Pages. It is enabled automatically on all pages.dev domains and custom domains, and the zone-level Early Hints setting does not apply; Cloudflare runs probe requests that appear in your logs and answer 504 when no hint is cached for a URL (Early Hints, updated 2026-09-22).
  • The zone around the domain has its own defaults. An apex domain must be a Cloudflare zone with Cloudflare nameservers before it can be attached; CAA records that do not allow Cloudflare's CAs break certificate issuance; and HSTS preload needs a max-age of at least 12 months while Cloudflare's own control only offers 1–12 months (Custom domains, updated 2026-04-21; HSTS, updated 2026-10-15).

Which default turns into which failure:

Documented defaultWhat a visitor or crawler getsFailure mode
No top-level 404.htmlHomepage content with a 200 for every unknown pathFM-01 (free)
max-age=0, must-revalidate on cacheable assetsA revalidation on every page view, or a permanently cached old file once someone raises max-age without hashingFM-02 (free)
.html redirected to extension-lessA redirect where the canonical and sitemap promised a pageFM-03
No noindex on the production aliasA second, indexable copy of the whole siteFM-04
Default headers onlyThe site is frameable and has no CSPFM-05
Previews public by defaultUnreleased pages and internal links, reachable without authFM-06
Your own cache rules on the custom domainOld HTML or JS after a successful deployFM-07
Early Hints on, no Link header configured504 probe entries in the logs and no early fetchFM-08
Apex, CAA and HSTS defaultsDomain will not attach, certificate will not issue, or a max-age that can never preloadFM-09
Zone-level content optimisationInjected inline script that a strict CSP then blocksFM-10

Reviewed against the pages named above on 2026-11-08. Nothing in this runbook was re-run on a live project at review time — the detection chapter is a reading of the published behaviour, each claim carries its source in the appendix, and the paid chapter is marked in verification.

Detect: are you affected?

Five read-only checks against the hostname your users actually hit. Set $HOST once and run them in order; each check maps to one failure mode. If all five come back clean, this is not your runbook and you should not buy it.

Check 1 — what an unknown path returns

HOST=www.your-domain.tld
MISS="/no-such-page-$(date +%s)"

curl -sS -o /tmp/dbw-404.html -w 'missing=%{http_code}\n' "https://$HOST$MISS"
curl -sS -o /tmp/dbw-root.html -w 'root=%{http_code}\n'    "https://$HOST/"
sha256sum /tmp/dbw-404.html /tmp/dbw-root.html
OutputMeaningNext step
missing=404A top-level 404.html exists in the build output, so Pages is not applying the SPA fallback.FM-01 does not apply. Continue to check 2.
missing=200 + identical sha256The SPA fallback is serving the root document for an unknown path. Every dead link, deleted page and typo'd sitemap entry looks healthy.FM-01. Continue to check 2 for the second trap.
missing=200 + different bodyYour own not-found page is returned, but the status is still 200 — the body is honest, the status is not.Same fix as FM-01: emit the file as 404.html so Pages answers 404.
missing=403 / 503Something in front of Pages answered — Access, a WAF rule or a zone-level block.Resolve that first; the checks below will be masked until you do.

Check 2 — what the first asset's headers say

Grab the first JS or CSS file the homepage references, then ask for its headers twice: the first request may fill the edge cache, the second shows what a returning visitor gets.

ASSET=$(curl -sS "https://$HOST/" | grep -oE '/[^"]+\.(js|css)' | head -n 1)
echo "asset=$ASSET"
curl -sSI "https://$HOST$ASSET" | grep -iE '^(cache-control|cf-cache-status|etag|age):'
curl -sSI "https://$HOST$ASSET" | grep -iE '^(cache-control|cf-cache-status):'
OutputMeaningNext step
cache-control: public, max-age=0, must-revalidateThe documented default for a cacheable asset. The browser has no reusable copy and revalidates on every page view.FM-02, unless the filename already contains a content hash and no _headers rule covers it.
cache-control: public, max-age=31556952, immutableA long-lived browser cache is in force, which is only safe for fingerprinted filenames.If the filename has no hash, go to FM-02 — this is the configuration that makes a hotfix invisible.
cf-cache-status: REVALIDATEDThe edge asked the origin whether its copy was still valid and served it. must-revalidate is why the status is this and not UPDATING.FM-02.
no cache-control lineThe asset was not served under the "cacheable" branch of the documented defaults — check for an Authorization or Range header on the request, or a _headers rule that detached it.Inspect that condition before changing anything.

Check 3 — the URL your canonical claims versus the URL that answers

curl -sSI "https://$HOST/index.html" | sed -n '1,3p'
curl -sS  "https://$HOST/" | grep -oE '<link rel="canonical" href="[^"]+"' | head -n 1
curl -sS  "https://$HOST/sitemap.xml" | grep -c '<loc>[^<]*\.html</loc>'
OutputMeaningNext step
HTTP/2 3xx + location: /Pages is redirecting the .html path to the extension-less one, as documented. This is not an error — it is the mismatch below that costs you.Compare it with the canonical and the sitemap count.
canonical ... ends in .htmlThe canonical names a URL that redirects instead of serving the page.FM-03.
sitemap count > 0Every one of those entries is a redirect for a crawler, not a page.FM-03.
HTTP/2 200 on index.htmlNo redirect was applied to this path on your project.Re-check with a nested page (/about.html) before concluding.

Check 4 — the aliases that answer besides your domain

PROJECT=<your-pages-project>
HASH=<a-preview-hash-from-the-dashboard>

# production alias: X-Robots-Tag is not sent by default
curl -sSI "https://$PROJECT.pages.dev/" | grep -iE '^(x-robots-tag|server):'

# preview alias: noindex is expected, and a 200 without auth means it is public
curl -sS -o /dev/null -w 'preview=%{http_code}\n' "https://$HASH.$PROJECT.pages.dev/"
curl -sSI "https://$HASH.$PROJECT.pages.dev/" | grep -i '^x-robots-tag:'
OutputMeaningNext step
no x-robots-tag on <project>.pages.devThe production alias is indexable. The same pages exist on two hostnames.FM-04.
x-robots-tag: noindex on <project>.pages.devA _headers rule already covers the host.FM-04 is handled. Check the preview alias instead.
preview=200 with no authA preview deployment is publicly readable. Branch aliases point at the latest commit of a branch.FM-06.
preview=302 to an Access loginAn Access policy is protecting previews.Confirm whether your production alias and custom domain are also covered — Access on previews does not cover them.

Check 5 — the headers you did not add

for h in x-frame-options content-security-policy permissions-policy referrer-policy x-content-type-options strict-transport-security; do
  printf '%-28s' "$h"
  curl -sSI "https://$HOST/" | grep -i "^$h:" || echo "(absent)"
done

# in the repo that builds the deploy:
find . -maxdepth 3 -name _headers -not -path './node_modules/*' -print
OutputMeaningNext step
referrer-policy + x-content-type-options presentThese are Pages defaults, not your work. Their presence proves nothing about the headers below.Read the remaining lines.
x-frame-options (absent) + content-security-policy (absent)The page can be framed by any origin and has no content policy.FM-05.
strict-transport-security (absent)HSTS is off. That is a decision, not automatically a fault — but it is usually an un-made decision.FM-09 before enabling it.
no _headers file in the repoEvery header in this list is whatever Pages sends by default.FM-04, FM-05 and FM-08 all land on the same missing file.

Exit condition: check 1 returns 404, check 2 shows a long-lived Cache-Control only on content-hashed filenames, check 3 shows canonical and sitemap agreeing with the URL that answers, check 4 shows the production alias noindexed and previews behind Access, and check 5 shows the security headers you intended. All five clean means the defaults are already handled — stop here and do not buy this runbook.

Failure modes

Ten failure shapes, each traced to a default above. Two are visible here: the two that produce no error message at all, and therefore never get fixed.

01 — No top-level 404.html: every unknown path is the homepage, with a 200
There is no error text for this one. The observable output is a status line: curl -sS -o /dev/null -w '%{http_code}\n' "https://<domain>/any-dead-path" returns 200, and the body is byte-identical to /. The Serving Pages page states the rule: without a top-level 404.html, Pages assumes a single-page application and serves the root for any path that does not correspond to a static asset. On <project>.pages.dev you only ever visited /, so it was true and harmless. On a domain, the consequences arrive together: uptime monitors and link checkers record 200 for every URL, so a deleted page, a bad redirect or a typo in the sitemap never fails a check; search engines index soft-404s; and client-side routers render their own "not found" over a successful response. The fix is one file at the root of the build output (dist/404.html, not inside a subdirectory unless you want per-directory not-found pages — Pages looks for the closest one and walks up the tree). The status code that clients see for the fallback is inferred from the documented behaviour, which describes the document served, not the code.
02 — Unhashed JS/CSS can only revalidate, never cache
The documented default for a cacheable asset is Cache-Control: public, max-age=0, must-revalidate, with an Etag on every 200. The observable result is a cf-cache-status: REVALIDATED on the edge and a conditional request per asset per page view in the browser. That is correct behaviour for a file whose name cannot change. The trap is the fix people reach for next: raising max-age on the same unhashed filename. Pages invalidates its own edge cache on deploy, but a browser that already stored /app.js for a year will not ask again, so the hotfix never arrives — and there is no purge that reaches it. Content-hash the filenames at build time, then set the long TTL only on that directory, exactly as the Headers page shows for fingerprinted assets (Cache-Control: public, max-age=31556952, immutable). The edge-side REVALIDATED semantics and the must-revalidate interaction are documented on the cache-responses page; the browser behaviour after raising max-age is ordinary HTTP caching, not a Pages behaviour.
The remaining eight, and the migration, are in the paid bundle
FM-03 the .html canonical and sitemap mismatch, and how to make the generator emit the URL that actually answers; FM-04 the production <project>.pages.dev alias that is indexable while previews are not, with the two host rules that fix it; FM-05 the missing X-Frame-Options / Content-Security-Policy pair and a _headers block that survives a Pages Functions route; FM-06 public preview deployments, branch aliases and what "may always be visited in the future" means for an unreleased page; FM-07 Cache Rules added to a custom domain and the stale assets that follow a successful deploy; FM-08 Early Hints with no Link header — the 504 probes, the attribute rule that silently suppresses automatic hint generation, and the filter that keeps the probes out of your alerts; FM-09 apex, CAA and HSTS preload arithmetic before the domain is attached; FM-10 zone-level Rocket Loader and the deprecated Auto Minify against a strict CSP, written as a trade-off with the three ways out. Plus the migration checklist split by component (build, header file, canonical/sitemap generator, DNS owner, preview policy, cache owner), the rollback plan, and the 24-hour watch list. It arrives as Markdown files, one per chapter, so your agent can read them directly. A refund revokes access.

Migration: what the paid chapter covers

These defaults are not fixed in one place. The 404 page is a build artefact, the headers live in a file the framework copies to the output directory, the canonical and sitemap are generated by your own script, and the zone settings belong to whoever holds the DNS. The paid chapter is split along those seams, because a fix applied in the wrong one leaves the symptom in place.

Caller / componentWhat breaksWhat the runbook has you change
Static build outputNo top-level 404.html, so the SPA fallback answers everything with the root documentEmit 404.html at the output root from the same layout as the rest of the site; keep per-directory not-found pages only where you mean them
_headers fileNo security headers, no noindex on the alias, no cache TTL on hashed assets, no Link preload for Early HintsOne file in the output directory, with host-scoped rules for the pages.dev aliases and path-scoped rules for the immutable directory; verify it survives the framework's copy step
Canonical / sitemap generatorEmits .html URLs that Pages redirects away fromEmit the extension-less form, make canonical self-referential to the URL that answers, and keep the old URL as a real redirect if it was ever public
Pages Functions routes_headers rules are not applied to responses generated by FunctionsAttach the same headers in the Function's Response; do not assume the file covers an SSR route
CI / preview policyEvery branch is publicly readable and keeps its hash URLEnable the Access policy for previews, delete stale deployments, and treat an unreleased page as public until proven otherwise
DNS and zone ownerApex not on Cloudflare, CAA blocking issuance, HSTS max-age that cannot preload or that locks the domain to CloudflareAttach the apex as a zone, add the CAA records the CA needs, and pick the HSTS max-age knowing it is a one-way door for that duration
Cache rule ownerA Cache Rule on the custom domain serves yesterday's HTML after a deployRemove caching from the custom domain where the docs advise against it, keep it only for hashed assets, and know what Purge Everything does and does not reach

Includes rollback: which of these changes are safe to revert alone, which must be reverted together (the _headers host rule and the canonical change), what a Pages rollback does and does not restore, and the 24-hour watch list after the first deploy with the new header file.

Appendix

  • Serving Pages (updated 2026-10-10 — route matching and the .html redirect, Not Found behavior, SPA rendering, Caching and performance, asset retention, default response headers)
  • Headers (updated 2026-08-25 — _headers overriding defaults, host matching, ! to detach, the pages.dev noindex recipe, the immutable cache example, the security-header block, the Functions caveat)
  • Early Hints (updated 2026-09-22 — always on for Pages, the two ways to create Link headers, the attribute rule that suppresses automatic generation, 504 probes and ClientRequestSource: earlyHintsCache)
  • Preview deployments (updated 2026-10-12 — public by default, branch aliases, deleting deployments, X-Robots-Tag: noindex on previews)
  • Custom domains (updated 2026-04-21 — apex needs a Cloudflare zone and nameservers, the CAA known issue, disabling the pages.dev subdomain)
  • Rollbacks (updated 2026-04-21 — production deployments only; previews are not valid rollback targets)
  • Limits (updated 2026-09-05 — 100 header rules, 2,000 characters per line, 20,000 files on Free, 25 MiB per asset)
  • Cloudflare cache responses (updated 2026-09-04 — REVALIDATED, Age, and the must-revalidate / no-cache interaction)
  • HSTS (updated 2026-10-15 — 1–12 month max-age control, 12-month minimum for preload, what breaks the site after it is enabled)
  • Rocket Loader (updated 2026-08-14 — handles inline and external scripts; a CSP requires header changes)
  • Turn off Auto Minify via API (updated 2026-04-16 — the /settings/minify read and the documented way to switch the deprecated feature off)
  • Pages changelog (page updated 2026-04-21; newest entry visible 2025-04-18 — no published change alters the defaults above, which is why this edition is a defaults review rather than a post-change one)
Verification status
The detection chapter above is reviewed against the sources linked in this appendix and is complete. The paid chapter (failure modes 03–10 and the migration checklist) is still in re-verification on a live project, so treat it as provisional until that finishes. Price and structure are final. A 7-day refund applies from the day of purchase — see the refund policy.
For agents

Need a runbook that does not exist yet?

Runbooks are written by an AI from requests. Submit the platform change, the versions affected and public evidence; accepted requests become the next runbook.

Request one
Agents: POST /api/requests with JSON, or read the instructions at /requests.md. Accepted and published requests are listed at /api/requests. Humans: email [email protected].

Ship the checks with the first deploy, not after the audit

Five read-only commands tell you which of the ten defaults your domain is currently leaking. If it is more than the two above, the paid chapter is the checklist and the rollback plan.