Engineering noteResearch & Engineering
Part of: Astro on Workers
Astro on Workers: the bugs between build and runtime
We found a 500 before a cache hit, private values in the Worker bundle and missing sitemap routes. Each exposed a boundary we had to migrate.
The programme
On this page
- Plan the migration around execution boundaries
- Request time: a 500 before a cache hit
- Build time: import.meta.env is inlined
- Module scope is not request time: the database
- Markdown: keeping rehype means choosing unified()
- Prerendering in Node, answering in workerd
- The build is Rolldown now, and so is everything that reads it
- Keep compatibility workarounds small and dated
- Verify this
- Appendix: versions
- Continue in the Astro series
- Frequently asked questions
The first request returned 500. The second returned 200. After we moved the website of The Educational Equality Institute (TEEI), a nonprofit, from Astro 5 to 7 on Cloudflare Workers, a cache write could succeed while the request that started it failed: we read a removed API after starting the write. A warm cache hid the broken path.
We also found private .env values in the Worker bundle and every opted-in server-rendered route missing from the sitemap. Updating imports was only part of the work. We had to establish when each piece of code runs, which environment supplies its values and which generated files our own tools read. This note follows those boundaries through the failures, fixes and a reproducible build experiment.
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.
Plan the migration around execution boundaries
On the website described in how TEEI runs its platforms on Cloudflare Workers, we replaced the runtime accessors and the database integration, and explicitly selected the Markdown processor that keeps our plugins. Underneath all three, Astro 7 runs on Vite 8, which bundles with Rolldown.
For a similar migration, we would use the following decisions to choose the work and the evidence that closes it. They cross two major upgrades: the runtime accessor and environment changes arrived with Astro 6; the database removal and Markdown default changed in Astro 7.
| Existing dependency | Change to make | What to verify |
|---|---|---|
locals.runtime in a request handler | Read bindings from cloudflare:workers, context from locals.cfContext and metadata from request.cf | A cache miss returns the right response and registers its background work |
Private values read through import.meta.env | Move the reads to runtime; use a rewrite only as a bridge for remaining direct reads | Inspect the generated Worker with a canary value, including object-style reads |
@astrojs/db | Use a database client compatible with workerd and construct it inside the function that needs it | Import-time loading performs no async I/O; client reuse is a separate decision |
| remark or rehype plugins | Install @astrojs/markdown-remark; select unified() in Astro and configure any direct processor calls too | The rendered HTML and heading metadata still contain the required anchors |
| Prerendering that fails in workerd | Consider prerenderEnvironment: 'node' | Count the static output and check what imported modules do without Worker bindings |
| Tools that read Astro’s output | Update their format assumptions and execution order | Tests cover the new output and still reject a real violation |
The versions at the end of this note describe our repository and reproduction on 15 October 2026. They do not establish the patch versions deployed in production.
Request time: a 500 before a cache hit
The adapter’s upgrade notes for version 13 and Astro 6 are short: Astro.locals.runtime is gone. Bindings and secrets come from import { env } from 'cloudflare:workers', importable since March 2025, request metadata from Astro.request.cf, and the execution context from Astro.locals.cfContext. What the notes cannot tell you is where your own code still reaches for the old property. With adapter 13 or later, locals.runtime is still there as a compatibility object, so code that reads it builds without complaint; we get the exception only at request time, when we read its env, ctx, cf or caches property.
On 24 September 2026 we found a surviving read, on our impact page’s published readings, the pinned exports a citation points to. The handler started writing the response to the cache, then read locals.runtime.ctx to keep the write alive with waitUntil. The read threw “Astro.locals.runtime.ctx has been removed in Astro v6”, so every first request for a reading answered 500, while the cache write had already started and landed. The second request for the same address was a clean hit. Cloudflare counted about 1,000 of those 500s in two hours.
The fix reads the documented replacement:
Adapted for publication.
let put: Promise<void>;
try {
put = cache.put(request, out.clone()).catch(() => undefined);
} catch {
put = Promise.resolve();
}
const ctx = locals?.cfContext;
if (ctx && typeof ctx.waitUntil === 'function') {
ctx.waitUntil(put);
} else {
await put;
}
We found the same read in a second reader, where a try/catch hid the exception: the response survived, but its cache writes ran without waitUntil. Our test makes any read of locals.runtime throw, which is stricter than the adapter’s property getters, and supplies a working cfContext, so any return to the old API fails. The test asserts a 200 response as well as one cache write and one promise passed to waitUntil. A test that checked only the cache write would have passed against the original bug, because the exception came after the write had started.
Elsewhere, code that a Node script might also import loads cloudflare:workers lazily, inside a try, because the module exists only in workerd and a static import would break the script.
Build time: import.meta.env is inlined
The Astro 6 upgrade guide says it in one line: “import.meta.env values are always inlined and never coerced.” Before Astro 6, a private variable read through import.meta.env became a reference to process.env. From Astro 6 on, it becomes the value. On Workers that matters, because the Worker bundle is built on a machine that has your .env file.
On 3 October 2026 we found that private values from .env, credentials among them, sat as string literals in the Worker server bundle, the code we deploy as the Worker. The Worker bundle is not served to visitors, and we have not measured how long it carried them. We reproduced the inlining on 15 October in a minimal project with two endpoints.
Use only a synthetic canary value in this local experiment. The example endpoints intentionally return it so the build output can be inspected; do not use real credentials or deploy these endpoints.
One endpoint reads the value through import.meta.env:
Verbatim excerpt.
export const prerender = false;
export const GET = () => new Response(String(import.meta.env.MY_SECRET));
The other reads it from the Worker environment:
Verbatim excerpt.
import { env } from 'cloudflare:workers';
export const prerender = false;
export const GET = () => new Response(String((env as Record<string, unknown>).MY_SECRET));
With MY_SECRET=canary-value-7f3a91 in .env, Astro 7.2.10 and @astrojs/cloudflare 14.2.4, astro build turned the first endpoint into this line in a chunk under dist/server:
Verbatim excerpt.
var GET = () => new Response(String("canary-value-7f3a91"));
The second endpoint’s chunk imports cloudflare:workers and contains no trace of the value. We built it twice, the second time in a clean copy, with the same result.
There is a second, less obvious way in. When server code reads import.meta.env as an object, with bracket access, optional chaining, typeof, or by passing it to a helper, Astro injects an Object.assign(import.meta.env, { ... }) expression containing the private keys named in that module. It finds them by searching the compiled module text for each key name, so any mention of the name counts. When we read the environment as an object, we can embed a private value without ever reading that key directly.
We closed the leak on 3 October in two steps. First, we moved the secret reads in many modules to the Worker environment, read at request time, and the database client factories that needed it became async. We set every name those modules read as a Worker secret before the change went out, because a runtime read of a binding that does not exist returns undefined.
Second, for the direct reads that remained, we added a Vite plugin instead of editing each file. In the ssr environment, which is the Worker build, it rewrites each direct import.meta.env.NAME read of a secret name into a read from env of cloudflare:workers. Object-style reads are not rewritten. The client build and the Node prerender are untouched: they have no Worker env to read, and we leave public reads for Astro to inline. Our ordinary transform runs on the JavaScript that TypeScript and Astro have already emitted, and before env inlining, because in Astro 7.2.10 Astro places its env plugin immediately before Vite’s define plugin. We find the candidate reads by searching the module text, then hand them to esbuild’s define, which replaces only real member expressions. A read that appears inside a string or a comment can enter the candidate set, but esbuild leaves that string or comment unchanged:
Adapted for publication.
import { transform } from 'esbuild';
const RUNTIME_ENV_IDENT = '__workerSecretEnv';
const READ_RE = /\bimport\.meta\.env\.([A-Za-z_$][A-Za-z0-9_$]*)/g;
// isSecret is yours to write: the names that must never be inlined.
export async function rewriteSecretReads(code, id, isSecret) {
const names = [...new Set([...code.matchAll(READ_RE)].map((m) => m[1]).filter(isSecret))].sort();
if (names.length === 0) return null;
const define = Object.fromEntries(
names.map((name) => [`import.meta.env.${name}`, `${RUNTIME_ENV_IDENT}.${name}`]),
);
const result = await transform(code, {
loader: 'js',
format: 'esm',
define,
banner: `import { env as ${RUNTIME_ENV_IDENT} } from 'cloudflare:workers';`,
sourcefile: id,
sourcemap: true,
platform: 'neutral',
});
return { code: result.code, map: result.map, names };
}
We still write a build-time API in the source, so a reader of a file cannot see that the Worker reads the secret at runtime. That is why we do not trust the source to tell us. Our deploy scripts check the built bundle and refuse one in which they find a secret, and the check has tests of its own. In the staging build recorded on 3 October 2026, it found none.
Module scope is not request time: the database
The Astro 7 upgrade guide is plain about it: “The @astrojs/db package has been removed in Astro v7.0 and is no longer maintained.” Our database is Turso, which speaks libSQL, so we use the libSQL client itself.
Two details matter on Workers. Import from @libsql/client/web, the build that runs in workerd without Node APIs. And do not open the client at module scope: the integration’s top-level await did async I/O in the Worker’s global scope, which Workers do not allow. Cloudflare’s guidance on bindings goes further: “The correct approach would be to create a new client instance for each request.” That advice bundles two choices, and we keep them apart: deferring construction until a function needs the client, and giving each call a client of its own. A lazy factory that caches its client makes the first choice and not the second. This example makes both:
Adapted for publication.
import { createClient } from '@libsql/client/web';
export async function readSettings(runtimeEnv?: Record<string, unknown>) {
const url = readEnvString(runtimeEnv, 'DB_URL');
const authToken = readEnvString(runtimeEnv, 'DB_TOKEN');
if (!url || !authToken) return defaults;
const client = createClient({ url, authToken });
const [mode, banner] = await Promise.all([
getSettingValue(client, 'site.maintenance_mode', 'false'),
getSettingValue(client, 'site.announcement_banner', ''),
]);
// …
}
Whether one client can be shared across requests, and what it costs when it cannot, is a question of its own; our note on a shared database client across Worker requests takes it on.
Markdown: keeping rehype means choosing unified()
Astro 7 renders .md and .mdx with Sätteri, its own Markdown pipeline, “instead of the remark/rehype pipeline”, and @astrojs/markdown-remark is no longer installed by default. Markdown still renders without it. What stops running is every remark and rehype plugin in your configuration.
We use rehype-slug and rehype-autolink-headings for linkable headings, so we installed @astrojs/markdown-remark and chose its unified() processor explicitly:
Adapted for publication.
markdown: {
processor: unified({
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap', properties: { className: ['heading-anchor'] } }],
],
}),
},
The order of the two plugins matters. The autolink plugin acts only on a heading that already has an ID, and Astro adds its own heading IDs after our plugins have run, too late for autolink, so rehype-slug goes first. We have not built the site under Sätteri, so we cannot say what its output or build time would have been for us.
We also call createMarkdownProcessor() from the same package directly for our English article template, with the heading plugins listed again, and take both the HTML and the heading metadata from its result. A change to Astro’s markdown configuration alone would not reach that second pipeline.
Prerendering in Node, answering in workerd
Version 13.1.0 of the adapter added prerenderEnvironment, 'workerd' by default or 'node'. We set 'node' on 10 May 2026, because prerendering in workerd failed in our build environment with “WebAssembly.instantiate(): Wasm code generation disallowed by embedder”. We found no page in Cloudflare’s documentation for that error, so treat it as what we saw, not as documented behaviour. The build still succeeded; it produced no static HTML.
The price is that the site now runs in two runtimes, and they do not offer the same modules.
cloudflare:workers exists only in workerd, and many of our files read env at module level, so a Node prerender that imported them would fail before it rendered anything. A Vite plugin with enforce: 'pre' therefore resolves cloudflare:workers to a stub, and only in the prerender environment; every other environment gets the real module. The stub is a callable proxy: an ordinary binding-name read returns another callable proxy and a call returns undefined, so a module can load even when it touches a binding at the top. Its then returns undefined on purpose; without that, await on anything from the stub would treat it as a promise and wait forever. Because we return truthy proxies, we cannot use a binding check during prerendering to establish whether the real binding exists. A page that prerenders without errors proves only that its modules load.
We met the same split in the dev server. On 9 October we found that Astro 7.0.6 invalidates its content data module only in the ssr Vite environment when the content store changes. With prerenderEnvironment: 'node', pages marked prerender = true render in a separate prerender environment that kept the data it loaded first, so an edited post kept its old HTML and a new one had no static path until we restarted the dev server. The virtual module has the same ID in both environments, but each keeps its own module graph and its own evaluated copy.
Our dev-only plugin therefore acts in every environment other than ssr and client, which in our setup means prerender, and clears three layers: the module graph, the evaluated module and, through Astro’s content-changed event, the route cache, which in Astro 7.2.10 includes cached getStaticPaths() results.
Its test starts a real Vite dev server with a stand-in for Astro’s content plugin that, like Astro 7.0.6, invalidates only ssr: without our plugin a test module imported in the prerender environment returns the old data, with it the new data.
The build is Rolldown now, and so is everything that reads it
Vite 8 bundles with Rolldown. Our migration note of 25 June records that Rolldown rejected our old manualChunks function on the Worker build, so we removed it that day; chunk grouping for the browser now lives under vite.environments.client, as advancedChunks, where the Astro 6 guide moved client output options.
The harder part was our own build. The adapter writes the browser assets, the Worker entry and a generated Wrangler configuration, and we run our own post-build steps after it. One of them exists because the adapter’s entry has no scheduled handler, and our Worker has cron triggers; adapter 13.5.0 had no option for a scheduled handler when we checked on 11 May 2026. So after the build we rename the adapter’s entry and write a new entry in its place that imports it and adds the scheduled handler beside its fetch.
Two of our steps broke after the upgrade, and they broke in different ways.
A format. Before each deploy, a release check of ours scans the Worker output for routes we have withdrawn. We wrote the check for Astro 5’s manifest format. Astro 7, bundled by Rolldown, no longer writes the manifest in that form; it writes each module as its own region of the bundle. After the upgrade, every Astro 7 bundle we tried to deploy triggered 19 hits in that check for the same withdrawn route, which blocked the deploy meant to retire that route. On 7 October we taught the check the new form, with tests that prove a real violation still fails it.
An order. Our sitemap step keeps a server-rendered route in the sitemap only when we have opted that route in and the built Worker contains it. We searched under the new entry name before we renamed the entry, so we removed every opted-in server-rendered route from the sitemap. On 24 September we made the step read the renamed entry when that exists and the original otherwise; the full build that day had all eight opted-in routes in the sitemap. Keeping content, sitemaps and search in sync covers that pipeline.
The lesson for a major upgrade is the same in both: every tool that reads the framework’s output is part of the upgrade, even if its own code did not change.
Keep compatibility workarounds small and dated
Our rewrite plugin bridges many old import.meta.env reads, but it hides the runtime read from someone inspecting the source. On a new project we would read secrets through cloudflare:workers or Astro’s astro:env schema directly. Our Node prerender solved a sandbox failure at the cost of a second runtime, a stub and extra dev-server invalidation. Where workerd prerenders successfully, we would keep the default. We chose unified() because we need remark and rehype plugins; a site without them has no such requirement.
Version compatibility needs the same discipline. On 15 October 2026 we moved from Astro 7.0.6 to 7.2.10 and pinned it with a tilde, ~7.2.10. Astro 7.3.0 removed beginContentEntryCollection, which @astrojs/cloudflare 14.2.0 to 14.2.4 imports, and the adapter’s peer range, ^7.2.0, still permits npm to install 7.3. With 7.3.8 and adapter 14.2.4, our build failed with MISSING_EXPORT.
The adapter that supports Astro 7.3 is 14.2.5 or later and requires Wrangler 4.125 or later. Our upgrade note also pairs that change with newer Workers types and a newer Sentry SDK; those are choices in our upgrade plan, rather than additional adapter peer requirements. The pin keeps us off every 7.3 fix until we do that work. A pin therefore needs a recorded reason and an exit condition.
For the update within Astro 7, we did compare builds: on 15 October we built the same source with 7.0.6 and adapter 14.1.1, then with 7.2.10 and adapter 14.2.4. The generated pages differed only in per-build noise such as random IDs and timestamps, and no value from the local secrets file reached the Worker entry, its chunks or the browser assets. That establishes a result for those two builds. We have no before-and-after measurement of build time, bundle size or page performance for the move from Astro 5 to 7.
Verify this
You can reproduce the inlining in your own project. Nothing here calls TEEI’s systems.
Create an Astro 7 project with
npm i astro@7.2.10 @astrojs/cloudflare@14.2.4. We ran it on Node 22.23.2; other versions may behave differently. The whole configuration we used:Adapted for publication.
js // astro.config.mjs import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ output: 'server', adapter: cloudflare() });Adapted for publication.
jsonc // wrangler.jsonc { "name": "repro", "compatibility_date": "2026-01-01", "compatibility_flags": ["nodejs_compat"] }Put
MY_SECRET=canary-valuein.env.Add the two endpoints from the section on build time: one that returns
import.meta.env.MY_SECRET, one that returnsenv.MY_SECRETfromcloudflare:workers.Run
npx astro build, thengrep -rl canary-value dist.
Expected, from our runs on 15 October 2026: both endpoint chunks exist. The first contains the value as a string literal; the second imports cloudflare:workers, reads env.MY_SECRET and does not contain the value. dist/client does not contain it either. The adapter also writes it to dist/server/.dev.vars for local development; we did not test whether wrangler deploy uploads that file, so check your own deploy.
This experiment tests the inlining mechanism independently of our site. The historical failures and regression-test descriptions come from our private repository; they are not results you can reproduce by calling a TEEI endpoint.
Appendix: versions
The versions in our repository on 15 October 2026, read from node_modules and matching the lockfile:
| Package | Version |
|---|---|
astro | 7.2.10 (pinned ~7.2.10) |
@astrojs/cloudflare | 14.2.4 |
@astrojs/markdown-remark | 7.3.2 |
@libsql/client | 0.15.15 |
vite | 8.1.3 |
esbuild | 0.28.2 |
wrangler | 4.107.0 |
rehype-slug, rehype-autolink-headings | 6.0.0, 7.1.0 |
| Node | 22.23.2 |
Continue in the Astro series
The homepage case follows these runtime boundaries through one visible activity line. Next, keeping content, sitemaps and search in sync follows a post through the outputs whose publication rules also have to survive a migration.
Frequently asked questions
Does Astro put my .env secrets into the Cloudflare Worker bundle?
It can. Since Astro 6, import.meta.env values are always inlined at build time. In a minimal project with Astro 7.2.10 and @astrojs/cloudflare 14.2.4, built on 15 October 2026, an endpoint that read a private value with import.meta.env carried that value as a string literal in the Worker server bundle, while an endpoint that read env from cloudflare:workers did not.
What replaces Astro.locals.runtime.env on Cloudflare in Astro 6 and 7?
The Cloudflare adapter removed Astro.locals.runtime from version 13, which goes with Astro 6. Bindings and secrets come from import { env } from 'cloudflare:workers', the execution context for waitUntil from Astro.locals.cfContext, and request metadata from Astro.request.cf. The adapter keeps a compatibility object at locals.runtime, but reading its env, ctx, cf or caches property now throws.
What replaces @astrojs/db in Astro 7?
Astro 7 removed @astrojs/db. We use @libsql/client/web, which runs in workerd, and create the client inside a function rather than at module scope.
How do I keep my rehype plugins in Astro 7?
Astro 7 renders Markdown with Sätteri by default. To keep remark and rehype plugins, install @astrojs/markdown-remark and set markdown.processor to unified({ rehypePlugins: [...] }) from that package.
When should prerenderEnvironment be node in the Astro Cloudflare adapter?
The default is workerd. We set node because prerendering in workerd failed in our build environment with a WebAssembly error and produced no static HTML. The cost is two runtimes: cloudflare:workers does not exist during a Node prerender, so we stub it there.