Your _headers file serves 7 headers in production and 2 on a preview deployment
After adding a scoped Content-Security-Policy rule, the preview deployment served only two of seven headers. It looked exactly like a rejected _headers file. Deploying the unchanged original to a preview produced the identical result.
TL;DR · THE FIX
Cloudflare Pages preview deployments do not serve the same security-header set as production, so verifying headers on a preview tests nothing. Deploy the UNCHANGED file to a preview as a control before bisecting your edit: one extra deploy separates "my change broke it" from "this environment never had it". And any feature that depends on a per-path header must degrade gracefully, because absent is what every preview will show.
The symptom
I added a scoped Content-Security-Policy rule to public/_headers, deployed it to a preview branch, and checked the result before promoting it:
curl -skI https://<hash>.unstuck-4yk.pages.dev/privacy/ | grep -iE '^(content-security|x-frame|permissions|strict-transport|referrer|x-content)'
# referrer-policy: strict-origin-when-cross-origin
# x-content-type-options: nosniff
Two headers where there should have been seven. Content-Security-Policy, X-Frame-Options, Permissions-Policy and HSTS were all gone, including the four that had been in that file, untouched, for weeks.
This reads like a _headers file the parser rejected. Cloudflare’s _headers syntax is whitespace-sensitive and fails as a unit, so one wrong line can lose the whole block, and I had just edited that file. The two surviving headers looked like the ones the platform adds on its own. So the obvious next move was to bisect my own edit, and that was the wrong move.
What I tried first
I bisected the edit. I removed the new CSP line and redeployed: still two headers. I retyped the indentation by hand in case I had introduced a tab: still two. I simplified the CSP down to a single directive on the theory that some value in it was unparseable: still two.
By then I had spent three deploys confirming that my change was not the cause, without once establishing what a working deploy looked like in that environment. So I ran the control. I took the original file, from before any of my edits, the version that had been serving all seven headers in production for weeks, and deployed it to a preview branch. Two headers.
What was happening
The preview host does not serve the same header set, and my file had nothing to do with it. Three deployments, same path /privacy/ on all three:
| Deployment | File | Headers served |
|---|---|---|
| Preview | original, unchanged | 2 of 7 |
| Preview | edited, with new CSP | 2 of 7 |
| Production alias | original, unchanged | 7 of 7 |
The production alias unstuck-4yk.pages.dev serves all seven from the same file that gives two on a preview. No edit fixes this, because there is no bug in the file.
The consequence is that “deploy a preview and check the headers” tests nothing. Every verification I might have run against a preview would have shown a site with no CSP, no framing protection and no HSTS, regardless of what I had written, with no way to tell that apart from having broken it.
The fix
Two things change in the method.
Verify headers on production. Any header whose behaviour matters gets checked against the production hostname, after promotion, as a separate step:
#!/usr/bin/env bash
# post-deploy header check, production only
want=(content-security-policy x-frame-options permissions-policy
strict-transport-security referrer-policy x-content-type-options)
hdrs=$(curl -skI "https://unstucked.dev/privacy/" | tr 'A-Z' 'a-z')
fail=0
for h in "${want[@]}"; do
grep -q "^$h:" <<<"$hdrs" || { echo "MISSING: $h"; fail=1; }
done
exit $fail
Note the -k. A TLS-intercepting proxy on the local machine makes a plain curl fail silently, which is its own trap and would have you debugging a header problem that is really a certificate problem.
And make per-path header features degrade. I was editing _headers in the first place to allow framing a specific document that the site-wide frame-ancestors 'none' would otherwise block. A feature built on a header being present, on a platform where entire environments serve that header inconsistently, needs a fallback that is merely worse rather than broken. Here the framed document also gets a plain link, so if the frame is refused the visitor still reaches the content. Absent is what every preview will show you.
The lesson
A control run costs one deploy and is the only thing that separates “my change broke it” from “this environment never had it”. I skipped it because I had a suspect, and having a suspect feels like progress in a way that testing the baseline does not.
So run the control before bisecting the diff. When something breaks right after you changed it, the correlation is real evidence, and the cheapest way to use it is to deploy the unchanged version into the same conditions first. If it works there, your change is the suspect and you have lost one deploy. If it does not, you have saved an afternoon of bisecting a file that was never wrong.
On every hosting platform, preview and production are different environments, and the differences are rarely documented in the direction you need. Anything you verify in a preview, you have verified about the preview.
Discussion
Powered by GitHub. Sign in to leave a comment.