Engineering noteResearch & Engineering
Part of: Cloudflare Workers
Pages to Workers: The Defaults We Had to Rebuild
We moved three apps from Pages to Workers, then changed Cloudflare accounts. We had to rebuild defaults for headers, redirects and deployment targets.
The programme
On this page
- What a Pages convention means on a Worker
- The first deploy: what the website lost
- Worker first, by choice
- Headers: one policy, two delivery paths
- Redirects: in front of the Worker, not inside the build
- Generated configuration: one environment, chosen at different stages
- Admin and Evenhall: a proxy in front, one prefix at a time
- Worker to Worker: public fetch or a service binding
- Between accounts: identifiers, data and activation
- What stays behind
- What we cannot tell you
- Appendix: the reproduction and the routing settings
- Frequently asked questions
We began moving three apps from Cloudflare Pages to Workers in May 2026, then moved our Workers to a new Cloudflare account. Our records date four separate steps: a first Workers deploy of the website beside its Pages project on 9 May, with no traffic moved; the admin’s DNS cutover on 14 May; our domains becoming active on the new account on 28 and 29 June; and the deletion of the website’s Pages project on 4 September. We have not found the day the website’s apex traffic moved. We lost headers and redirects that had depended on Pages defaults. Wrangler reported our secrets as saved, yet the running Worker read them as empty. We had to follow each request to the layer that answered it and inspect the configuration we actually deploy. For our Astro website, we select the deployment environment after the build; for our React Router apps, we select it during the build. We met three of the same traps again when we changed accounts.
We had to make Pages defaults explicit in the layers that answer requests and in the configuration we deploy to each target. If you are moving a Pages app or changing Cloudflare accounts, these are the defaults to check.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
What a Pages convention means on a Worker
A Worker with static assets looks like Pages from outside: it serves files from a directory, runs code and sits behind the same edge. Cloudflare’s migration guide lists the mechanical steps: deploy with wrangler deploy, point assets.directory at the build output, add an .assetsignore for _worker.js, and build any functions/ folder with wrangler pages functions build. They cannot tell you which of your app’s behaviours depended on a Pages default. For us, these did.
| Pages convention | On a Worker with static assets | What we did |
|---|---|---|
_headers | Applied to static assets only; “not applied to responses generated by your Worker code” (headers) | Middleware, plus a static copy of the content security policy for prerendered pages |
_redirects | ”Not applied to requests served by your Worker code” (redirects) | Zone rules and the Worker |
Functions and _routes.json | Pages ran Functions ahead of assets; Workers serves assets first unless assets.run_worker_first says otherwise, and does not use _routes.json | The website runs its Worker first, by choice |
wrangler pages deploy | wrangler deploy reads a configuration the build generates | We check and merge the generated file before each deploy |
The first deploy: what the website lost
The three apps that left Pages were our website, the admin our staff work in and Evenhall, all Astro apps at the time. On 9 May 2026 we deployed the website as a Worker next to its Pages project, without moving any traffic; its apex pointed straight at Pages, with no router Worker in front. We have found the date of the parallel deploy, but not the date apex traffic moved. That first deploy showed us five gaps.
_headerswas parsed, and refused. One rule was longer than the 2,000 characters per line that the limits page allows, next to a cap of 100 rules (Wrangler reported error 10021, a code we take from our report, not from documentation).- Headers have different scopes. Pages and Workers apply header configuration at different response layers, so we configure headers in the layer that serves each response.
_redirectsstopped working. None of its 549 lines, 51 of them wildcard rules, were honoured on the Worker path.- The new Worker had no secrets. A Worker is a new service, so every secret had to be created on it again.
- Schedules could have fired twice. Pages has no cron; on Workers, a
scheduled()handler wraps our built Worker. Its configuration enabled native cron triggers while an external scheduler still called the same jobs, and our report warned that both would run them unless the external scheduler was switched off first.
For the first gap we took the oversized file out of the output and added an .assetsignore, which keeps the Worker code and the Pages control files out of the asset upload. Workers still applies the _headers rules, because Wrangler reads the file directly, and the headers page says the file “will not itself be served as a static asset”. What the ignore file protects is the Worker code, which would otherwise sit in the output as a file anyone could fetch. Our build deletes the output folder first, so .assetsignore lives in Astro’s public folder, which the build copies in.
Worker first, by choice
Cloudflare’s migration guide states the difference in default order: “Pages would default to serving your Pages Functions ahead of static assets … Workers, on the other hand, will default to serving static assets ahead of your Worker script, unless you have configured assets.run_worker_first.” This is the block in the website’s configuration:
Adapted for publication.
[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = true
not_found_handling = "404-page"
Until 15 October 2026, the comment above this block said that Astro server-side rendering required the setting. Our reproduction for Worker First Is Not Middleware First: Astro on Cloudflare showed otherwise, and we corrected the comment the same day. With run_worker_first = false, the result depends on the compatibility date and the navigation settings. At our date of 2024-09-23, an on-demand route rendered through the Worker and its middleware. At 2025-04-02 with the default navigation flags, a browser navigation to that route got the 404 page without the Worker running, while the same request without Sec-Fetch-Mode: navigate reached both. Cloudflare documents that navigation preference as the default from 1 April 2025, and an explicit compatibility flag can change it. The appendix gives the setup.
We chose true because of our Worker entry. It answers redirects and other rules before Astro runs, and we want it to see every request before the asset layer does, files and prerendered pages included. The price is that every request, static files included, runs the Worker, and the migration guide’s footnote says middleware set up this way “is charged as a normal Worker invocation”. With Smart Placement, asset requests must also first travel to the placed Worker, which Cloudflare’s routing and binding pages say may increase asset latency. Our May report does not weigh either cost. Our React Router apps (admin, Evenhall, TEEI Language and Mentorship) keep the Workers default: assets first, the Worker for everything else.
Headers: one policy, two delivery paths
The headers page says it plainly: “If you use a server-side rendered (SSR) framework, have configured assets.run_worker_first, or otherwise use a Worker script, you will likely need to attach any custom headers you wish to apply directly within that Worker script.” We did that on 10 May 2026, moving our security headers into Astro middleware.
That covered only part of the site. The Worker runs for every request on our website, but with our versions and a prerendered 404 page, Astro middleware runs only for on-demand routes: not for files, not for prerendered pages and not for paths with no route. Those responses come from the static assets, where the _headers file applies. So the website keeps one definition of its content security policy, in middleware, and two ways of delivering it. On every build we read the policy out of the middleware and write a static copy into _headers for the prerendered pages. Nobody edits the copy by hand.
The copy has to obey the 2,000-character line limit, and a content security policy can outgrow it. The documentation gives the way out: “If a header is applied twice in the _headers file, the values are joined with a comma separator.” A comma-joined content security policy is a list of policies, and a browser enforces every one of them, so a request is allowed only if all of the policies allow it. Cutting the policy into pieces naively would therefore narrow what it allows.
We keep the fallback directive in the first policy and move the longest other directive out until that policy fits. For each fetch directive we move, we add a permissive stand-in to the first policy, because only fetch directives fall back to the fallback directive, and we stop with an error if a stand-in would not shorten the policy. We put each moved directive into the first later policy with room, and none of those carries the fallback: in our evaluator, a later policy with neither a directive nor a fallback for a fetch type imposes no restriction on it.
We test that the split keeps every original directive byte for byte and that every policy fits on one _headers line with the header name. Within our evaluator’s model, we compare URL decisions before and after splitting, fallback included, for scheme-and-host source tokens, wildcard subdomains and suffix lookalikes. We use two negative controls: without the stand-ins, the split blocks a URL the original allowed, and the evaluator must reject the suffix lookalike.
We reject duplicate directive names, a missing fallback directive and any single directive too long for the line. We read the policy only as a flat list of string literals, one per line, and reject comments, variables, templates and spreads. We also refuse a hand-written policy in any other header rule, so a second matching rule cannot silently intersect with the generated one.
Redirects: in front of the Worker, not inside the build
A Worker can answer redirects itself, and zone rules can answer them before the Worker runs; we now use both. We needed the zone rule because our middleware never saw some paths in a production build. On 25 July 2026 we traced why some locale-prefixed city pages did not redirect. Astro’s i18n fallback had emitted one meta-refresh stub per locale per city page, 12,115 files in all, which pushed the client output past 20,000 files (the limit per Worker version is 20,000 on Free and 100,000 on Workers Paid). We prune those stubs at build time now. The middleware rule that was meant to answer with a 301 never fires in a production build, because Astro’s locale routing answers those paths with the 404 page before user middleware runs; on the dev server the same rule does fire. A Single Redirect at the zone runs before the Worker, so it is the layer that can answer. On 12 October 2026 we added a second zone rule for English blog posts reached through a Ukrainian or Norwegian prefix, whose stubs had answered 200 with a meta refresh rather than a redirect status.
Anyone can check that rule, measured here on 15 October 2026 at 16:08 UTC:
Adapted for publication.
curl -sI "https://theeducationalequalityinstitute.org/uk/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/?ref=check"
# HTTP/2 301
# location: https://theeducationalequalityinstitute.org/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/?ref=check
The destination answered 200. The server: cloudflare header does not tell you whether a Worker produced the response.
We generate the redirects that need application knowledge into our Worker entry, which answers before Astro runs, so they cover files and prerendered pages too, which middleware never sees; middleware handles the rest. We describe the changes to Astro’s build-time redirects in a related note.
Generated configuration: one environment, chosen at different stages
Wrangler deploys the configuration our build generates, not the one in the repository. On the website, the Astro adapter (@astrojs/cloudflare 14.2.4) writes a fresh wrangler.json into the build output on every build, and wrangler deploy --config reads that file. The build can omit a source setting, so a check returns an error when the generated file lacks run_worker_first, because /sitemap.xml must reach the Worker for its 308 to /sitemap-index.xml; a second check looks for that alias in the generated Worker entry. Both read the build output and send no request.
The adapter may copy top-level vars into the generated file, but it does not carry environment blocks through. So before each deploy, our scripts merge in production values or, for staging, the named environment. We build that environment’s vars from the source root vars overlaid by its own, an inheritance our merge step supplies and Wrangler does not. We render a complete environment block rather than flattening staging into the top level, because Wrangler does not inherit vars or bindings between environments: a flattened configuration deployed with --env staging would leave staging without its intended KV and R2 bindings. In that block we keep the adapter’s asset directory, relative to the generated file rather than the repository root, and test it with the unchanged production name and vars.
The merge also carries keys that exist only in the source configuration, such as workers_dev, which “Defaults to true when the configuration has no route or routes” (Wrangler configuration). Since Wrangler 4.44.0 preview URLs follow it, and a subdomain turned off in the dashboard but not in Wrangler comes back on the next deploy. We pin workers_dev = false and preview_urls = false for production, and the merge carries them into the file Wrangler reads. We test that the merge copies those settings and rejects malformed ones, and that it keeps staging schedules empty, so a staging deploy never runs the production schedule.
We select the environment for our React Router apps during the build. Our admin decision log from the spike on 9 May 2026 records four rules, each found the hard way.
- Build with the environment set. The Cloudflare Vite plugin reads
CLOUDFLARE_ENVat build time to choose whichenv.<name>block it flattens into the generated configuration (Vite plugin environments). A plain build drops the staging block, andwrangler deploy --env stagingafterwards does not fix it; in our log’s words, “the build artefact wins”. - Declare bindings in every environment. Top-level
kv_namespaces,varsandversion_metadataare not inherited into an environment block, so a staging build deployed with no KV bindings until we declared them in each environment. - Give
wrangler secret putthe environment the deploy uses. With--namealone, Wrangler wrote admin’s secrets to a different service environment from the one our staging deploy read. It reported “Success!”,wrangler secret listshowed the secrets as set, and the running Worker read every one of them as empty. We only found out by reading the values from inside the running Worker, and we had to pass both--nameand--env. The rule is to match the target, not the flags: for TEEI Language and TEEI Mentorship, we build each environment from one React Router 8 codebase as its own Worker name, so there--namewith that name is the right target. - An empty var beats a secret. A
varsentry with an empty string overrides a secret of the same name, and our error-reporting SDK initialised and then sent nothing. A related refusal came on the production deploy on 10 May 2026: a secret upload was rejected with code 10053 while a plain-text binding of the same name existed, so the order became deploy first, then upload the secret.
Shipping exactly the build you tested, environment included, is the subject of How We Compare Builds and Verify a Worker Release.
Admin and Evenhall: a proxy in front, one prefix at a time
We rewrote admin and Evenhall from Astro on Pages to React Router on Workers. On 9 May 2026 we decided, for admin, to “Deploy to Cloudflare Workers (not Pages)” and to move by the strangler-fig pattern. A small proxy Worker took over the hostname and held a list of path prefixes that had moved. A prefix matches its exact path or a path that begins with the prefix followed by a slash, and the root prefix is an explicit catch-all, so adding it was the final routing change. We route matching paths to the new React Router Worker and all other paths to the old Pages app. Removing a prefix from the list was the rollback, and our Evenhall document puts its propagation at about 60 seconds.
We moved admin section by section between 10 and 13 May and switched its catch-all for / on 14 May 2026, when the migration log records “CUTOVER COMPLETE”: we pointed the CNAME straight at the Worker, so the proxy was no longer in the path, detached the custom domain from the Pages project and added a Worker route on the zone. Evenhall followed the same pattern with its own proxy; its operations document says its Astro stack “was retired on 2026-05-10”, and we have not found the day its last prefix moved. One Evenhall detail is worth borrowing: we bound the new Worker to the same KV namespace the Pages project had used, because “Production KV id matches the existing Pages binding so counters keep their state across the cutover.”
The proxy cost one more Worker, a change in the old app so both halves agreed on the signed-in person, and a period with two apps answering one hostname. A direct DNS switch is simpler when the app is not being rewritten. The website went without a proxy; the May report does not say why.
Worker to Worker: public fetch or a service binding
On 9 May 2026 a fetch from one of our Workers to another through its public workers.dev address answered 404 at the edge, first in the admin migration and again when we tried to move Evenhall’s /api/* prefix. We replaced the admin call with a service binding, rolled the Evenhall switch back, and had its service-binding version working on staging the same day.
The custom domains page gives the rule: “On the same zone, the only way for a Worker to communicate with another Worker running on a route, or on a workers.dev subdomain, is via service bindings”, unless the target runs on a Custom Domain, which accepts a public fetch directly. For a target on a zone route, a global fetch on the same zone needs the global_fetch_strictly_public compatibility flag, and the errors page lists error 1042 for one without it; a service binding needs no flag. How we connect Workers now is in fetch bindings and RPC entrypoints between our Workers.
Between accounts: identifiers, data and activation
Three of May’s traps came back when we changed accounts: a new Worker starts with no secrets, enabling both schedules risks running a job twice, and the configuration that deploys is the one the build generated, not the one in the repository. Our four domains became active on the new account on 28 and 29 June 2026 (our overview has the timeline of those two days). We had to remap binding identifiers separately from moving data.
Identifiers. In the runbook, we identified two kinds of binding identifier to remap: the ID of each KV namespace and the ID of each D1 database. The runbook required us to swap them in a copy of each configuration, leaving the repository configuration unchanged while the old account still served production. R2 and Vectorize bindings keep their bucket or index names, so the same configuration finds them on the new account as long as something with that name exists there. Durable Object bindings keep their class names. The new account needed its own workers.dev subdomain before the first deploy. The React Router apps had to be deployed from the configuration their build generated; bundling one from its source configuration failed on the virtual server-build module. Unlike Pages, Workers supports custom domains only on nameservers Cloudflare manages, which all ours were.
Data. Binding by name is not a state-transfer mechanism. The runbook kept the remap apart from the data move: R2 data was already copied and byte-verified before the Workers moved, and KV and D1 needed a separate sync close to the switch. The buckets skipped during the data copy still had to exist on the new account, so we created those empty buckets before binding them. Vectorize indexes had to be created again and filled again from the source content; the runbook notes that their vectors do not export cleanly. For the Durable Objects in that move, the runbook classified the state as ephemeral and instructed us to redeploy the classes.
Activation. A secret that only lived in Cloudflare cannot be read back, so each had to be found at its source or created again. Once secrets were installed, schedules on both accounts would target the same database, so the runbook required cron triggers to stay off on the new account until cutover. It also required disabling Logpush and removing tail consumers for the first deploys.
What stays behind
Pages still does the job for a static site with no server-rendered routes, no cron, no Durable Objects and no Logpush, but Cloudflare advises: “Start new projects with Workers.” A move leaves the old project in place, and nothing reminds you to remove it. The migration guide ends with the step: once production traffic has moved, delete the Pages project in the dashboard or with npx wrangler pages project delete. We deleted the website’s Pages project on 4 September 2026, four months after the first Worker deploy. Put the deletion in the plan with a date, the same way the cutover has one.
What we cannot tell you
We cannot tell you whether Workers is faster. We have no measurement of performance or cost before and after the move. The move overlapped with upgrades from Astro 5 to 7, new adapter versions and the new account, so a difference could not be attributed to Workers even if we had measured one. Git history for May 2026 is thin in our repositories, so we draw the migration dates, sizes and findings from internal reports that readers cannot inspect, and we say where a date is missing. We read Cloudflare’s documentation on 15 October 2026.
Appendix: the reproduction and the routing settings
The reproduction ran locally with wrangler dev on 15 October 2026: Astro 7.2.10, @astrojs/cloudflare 14.2.4, Wrangler 4.107.0, nodejs_compat as the only compatibility flag and not_found_handling = "404-page". We sent each request with and without Sec-Fetch-Mode: navigate and read a marker header from the Worker and one from the middleware.
| Setting | Website | TEEI Language and Mentorship |
|---|---|---|
compatibility_date | 2024-09-23 | 2024-11-01 |
compatibility_flags | nodejs_compat_v2, nodejs_als | nodejs_compat |
| Assets binding | ASSETS, run_worker_first = true, not_found_handling = "404-page" | ASSETS, assets first |
workers_dev in production | false (with preview_urls = false) | false |
Frequently asked questions
Does the _headers file still work after moving from Cloudflare Pages to Workers?
Only for static assets. Cloudflare documents that _headers rules are not applied to responses generated by your Worker code. We set our security headers in Astro middleware for on-demand routes, and each build writes a static copy of our content security policy into _headers for prerendered pages.
Why did wrangler secret put report success while the Worker read the secret as empty?
In our admin migration, --name alone wrote the secret to a different service environment from the one our staging deploy read. We had to pass both --name and --env. Also check that no empty vars entry of the same name overrides the secret.
Does a binding move its data when you change Cloudflare accounts?
No. R2 and Vectorize bindings refer to names and Durable Object bindings to classes, but binding by name does not transfer state. Our runbook required remapping KV and D1 IDs and recreating and refilling Vectorize indexes; R2 data was copied beforehand.