# Six Cloudflare Pages build failures before _headers

> First Astro 6 deploy to Cloudflare Pages: 6 failed builds, 2 rollbacks, 8 commits, 8 hours. The ordered list of fails the docs scatter across six pages.

**Canonical URL**: https://agentcookbooks.com/blog/cloudflare-pages-six-failures-before-headers/

**Published**: 2026-05-24

**Tags**: claude-code, deployment

---

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](/blog/cloudflare-pages-csp-pagefind/), [HTML cache week-one analytics](/blog/cloudflare-pages-html-cache-week-one-449-visitors/), [the AI-audit robots.txt trap](/blog/cloudflare-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.