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 default | What a visitor or crawler gets | Failure mode |
|---|---|---|
| No top-level 404.html | Homepage content with a 200 for every unknown path | FM-01 (free) |
| max-age=0, must-revalidate on cacheable assets | A revalidation on every page view, or a permanently cached old file once someone raises max-age without hashing | FM-02 (free) |
| .html redirected to extension-less | A redirect where the canonical and sitemap promised a page | FM-03 |
| No noindex on the production alias | A second, indexable copy of the whole site | FM-04 |
| Default headers only | The site is frameable and has no CSP | FM-05 |
| Previews public by default | Unreleased pages and internal links, reachable without auth | FM-06 |
| Your own cache rules on the custom domain | Old HTML or JS after a successful deploy | FM-07 |
| Early Hints on, no Link header configured | 504 probe entries in the logs and no early fetch | FM-08 |
| Apex, CAA and HSTS defaults | Domain will not attach, certificate will not issue, or a max-age that can never preload | FM-09 |
| Zone-level content optimisation | Injected inline script that a strict CSP then blocks | FM-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
| Output | Meaning | Next step |
|---|---|---|
| missing=404 | A 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 sha256 | The 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 body | Your 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 / 503 | Something 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):'
| Output | Meaning | Next step |
|---|---|---|
| cache-control: public, max-age=0, must-revalidate | The 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, immutable | A 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: REVALIDATED | The 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 line | The 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>'
| Output | Meaning | Next 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 .html | The canonical names a URL that redirects instead of serving the page. | FM-03. |
| sitemap count > 0 | Every one of those entries is a redirect for a crawler, not a page. | FM-03. |
| HTTP/2 200 on index.html | No 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:'
| Output | Meaning | Next step |
|---|---|---|
| no x-robots-tag on <project>.pages.dev | The production alias is indexable. The same pages exist on two hostnames. | FM-04. |
| x-robots-tag: noindex on <project>.pages.dev | A _headers rule already covers the host. | FM-04 is handled. Check the preview alias instead. |
| preview=200 with no auth | A preview deployment is publicly readable. Branch aliases point at the latest commit of a branch. | FM-06. |
| preview=302 to an Access login | An 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
| Output | Meaning | Next step |
|---|---|---|
| referrer-policy + x-content-type-options present | These 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 repo | Every 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.
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.
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.
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 / component | What breaks | What the runbook has you change |
|---|---|---|
| Static build output | No top-level 404.html, so the SPA fallback answers everything with the root document | Emit 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 file | No security headers, no noindex on the alias, no cache TTL on hashed assets, no Link preload for Early Hints | One 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 generator | Emits .html URLs that Pages redirects away from | Emit 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 Functions | Attach the same headers in the Function's Response; do not assume the file covers an SSR route |
| CI / preview policy | Every branch is publicly readable and keeps its hash URL | Enable the Access policy for previews, delete stale deployments, and treat an unreleased page as public until proven otherwise |
| DNS and zone owner | Apex not on Cloudflare, CAA blocking issuance, HSTS max-age that cannot preload or that locks the domain to Cloudflare | Attach 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 owner | A Cache Rule on the custom domain serves yesterday's HTML after a deploy | Remove 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)
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.
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.
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.