Skip to main content

Six Cloudflare Pages build failures before _headers

Illustrated receipt card summarizing: Six Cloudflare Pages build failures before _headers

A new Astro 6 codebase, the @astrojs/cloudflare adapter, three API endpoints, GitHub connected, click Save and Deploy. Eight hours later: 6 failed builds, 2 rollbacks, 8 commits to reach LIVE. The three Cloudflare Pages posts on this site so far (CSP for Pagefind, HTML cache week-one analytics, the AI-audit robots.txt trap) all live after the build succeeds. This is the upstream list — the launch-day failures that have to clear before any of those become relevant.

What I ran

Not a skill activation. First-time Pages setup for an existing Astro 6 repo on a fresh Cloudflare account. The adapter was in astro.config.mjs, three endpoints sat under src/pages/api/, the local wrangler.toml carried an [env.dev] block from prior local-dev work. Default build command, default output directory, default branch (main). Six distinct failure modes followed — not one config bug looping. The order matters because each one blocks the next from surfacing.

What happened

The ordered punch-list, with the fix per step. Letter markers stand in for commit hashes — the originals belong to a sister repo and don’t resolve here, but the commit pattern (8 sequential, 2 explicit rollback-to-same-SHA operations) is what matters:

1. UI confusion (Workers vs Pages)            — fixed via legacy URL
2. wrangler.toml [env.dev] not supported       — commit A
3. Webhook stuck on old SHA                    — commit B (empty commit)
4. Windows-only package-lock.json missing      — commit C
   @emnapi Linux-optional entries
5. ASSETS-binding name reserved by Pages       — commit D (insufficient)
6. Pure SSG refactor: delete adapter           — commit E → LIVE
   + delete /api/* endpoints + replace with
   mailto: for the one active endpoint
7. Plausible v2 + external /scripts/init.js    — commits F, G, H → 500 on
                                                  every route, all rolled back
8. Plausible classic single-script             — commit I → LIVE permanently

The interesting failures in detail:

The 2026 UI defaults new projects to Workers Static Assets, not Pages. Pages is reachable only via a legacy URL (/<account>/pages/new/provider/github) or via “create another project” on accounts that already have a Pages project. New accounts in 2026 cannot easily find Pages at all from the front-door UI.

[env.dev] in wrangler.toml blocks config validation before the build even starts. Pages supports only preview and production named environments. Move any local-dev config to .env.

The webhook can stick on an old SHA. Push goes through, origin/main advances, the Cloudflare build webhook keeps cloning the previous commit. Unjam with git commit --allow-empty -m "trigger CF webhook".

Windows-host lockfiles don’t include Linux-optional @emnapi/* entries. npm install on Windows omits them; npm ci on the CF Linux builder then fails. npm install --include=optional --package-lock-only doesn’t help — Windows npm can’t resolve the Linux-only binaries. Fix: delete package-lock.json from git, add to .gitignore. Cloudflare falls back to npm install (no npm ci) when the lockfile is absent. Trade-off: lose dependency determinism between local installs, but ^X.Y ranges work fine for a static-output site.

ASSETS binding name is reserved by Pages. @astrojs/cloudflare emits dist/server/.prerender/wrangler.json with {"assets": {"binding": "ASSETS", ...}}. Pages reserves that exact identifier for its own static-asset binding. platformProxy: { enabled: process.env.NODE_ENV !== 'production' } did not bypass — the vite plugin activated post-build anyway.

Pure SSG was the unblock. If every /api/* endpoint is either (a) dormant for a later phase or (b) replaceable with a mailto: link, deleting the adapter and the endpoints removes the binding conflict entirely. The one active endpoint here was a GDPR Article 12 SAR-style flow that tolerates email-based requests — became a mailto: link in four templates. output: 'static' stays. Adapter + endpoints can be restored from git history when the dormant phase activates; there’s a clean commit-line marker.

Plausible v2 caused a Production 500 on every URL. Three separate commits including one that only added/removed a Google Search Console verification HTML to isolate the variable. Bare https://<domain>/scripts/plausible-init.js returned 500. Not a Plausible logic error — a Cloudflare-side routing conflict with /scripts/ under Pages. The classic single-script tag (<script defer data-domain="..." src="https://plausible.io/js/script.js">) is stable, CSP-friendly, and the Plausible backend accepts both formats.

Where it drifted

The six issues are documented (where they’re documented) across six different Cloudflare pages. The ordering matters and isn’t anywhere: [env.dev] blocks before the webhook issue can even surface; the lockfile blocks before ASSETS surfaces. Nobody hits all six at once — they hit them sequentially across one frustrating day.

The Plausible 500 was the highest-friction surface. The diagnostic step that confirmed v2 was at fault was rolling back to the same green commit Cloudflare had just marked green and re-deploying it. The same SHA, re-deployed, came up clean — meaning the failure wasn’t in the code but in the routing state Cloudflare had accumulated. The “rollback to same SHA resets internal routing state” behavior is nowhere in CF docs.

The webhook-stuck-on-old-SHA issue has the same shape: the failure is in CF’s state, not your code. The fix is also stateful — an empty commit that re-fires the webhook.

What I’d change

If your /api/* endpoints are dormant for a later phase or replaceable with mailto:, delete the adapter and the endpoints, ship pure SSG, restore from git history when those endpoints actually need to run. The ASSETS conflict goes away entirely and the build matrix collapses by half.

Use Plausible’s classic single-script tag over the v2 installer on Cloudflare Pages until v2 stops conflicting with how Pages routes /scripts/. The backend accepts both formats.

Treat the green-build webhook as a separate failure mode from the green-build code. An empty commit (git commit --allow-empty) is the unstick when the webhook is wrong; a same-SHA redeploy is the unstick when the routing state is wrong. Both are normal-mode operations on Cloudflare Pages once you’ve seen them once.

Final post-deploy scores: securityheaders.com A+, ssllabs.com A+ across 4/4 IPs, observatory.mozilla.org 115/100. That’s not the post — that’s what the launch-day list buys you.