This article is published in English.
One Astro App on Node and Cloudflare Workers: Config Gotchas
Dual Astro configs for Node and Workers: React dedupe, Prisma edge aliases, Vite externals, CI memory, and a Workers fetch entry.
One codebase deploys onto Node (Docker self-hosting) and onto Cloudflare Workers (edge). The shared tree sits beside astro.config.mjs and astro.config.cloudflare.mjs.
The setup is viable. Getting there required multiple focused debugging passes, each revealing a Cloudflare-only setting that the Node file never needed. Starter tutorials rarely document those gaps.
The two configs are 90% identical, and that’s the problem
Branching inside one config file looks tidy at first. It frays when you also need another adapter, another externals policy, another alias map, and separate memory knobs for the build. A long conditional covering all of that reads worse than a pair of files.
The trade-off is maintenance: shared settings must stay mirrored by hand. React module resolution is the sharp edge, which is why the Cloudflare file keeps a warning comment:
// Must mirror astro.config.mjs's React handling. Without dedupe the
// production Rollup client build resolves react-dom's internal react to a
// different chunk than the islands' react, yielding two React instances ->
// "Cannot read properties of null (reading 'useEffect')" when IslandHydrator
// calls createRoot().render() on a hooked component.
Remember: React resolve.dedupe is correctness, not polish. Duplicate React copies break on the first hook, and the stack usually points at your component instead of the bundler config.
Prisma’s generated client doesn’t resolve for workerd
The Prisma 7 client leans on Node subpath imports such as #main-entry-point. A Rollup build for workerd cannot resolve that. Alias the module directly to the edge entry instead:
const PRISMA_CLIENT_DIR = path.dirname(require.resolve('@prisma/client/package.json'));
const PRISMA_EDGE_ENTRY = path.resolve(PRISMA_CLIENT_DIR, '../../.prisma/client/edge.js');
resolve: {
alias: {
'.prisma/client/default': PRISMA_EDGE_ENTRY,
},
}
How the path is derived matters. From Prisma 7.8 onward, generated .prisma/client/ files sit inside the @prisma/client package. Under pnpm that becomes a hashed location like .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/, not a fixed root node_modules folder. A path typed once on a laptop often fails on another hoist layout or store hash. Anchoring from @prisma/client/package.json survives those differences.
The externals list, and one entry that is deliberately absent
Anything Node-only must remain outside the workerd bundle. Many projects need only a small exclusion list:
const NODE_ONLY_EXTERNALS = ['ioredis'];
ioredis arrives via dynamic import() behind isCloudflareRuntime(), so Workers never fetch that chunk and externalizing it stays safe.
pg is not on that list, on purpose. @prisma/adapter-pg pulls pg in with a static import, and PrismaPg still runs on the Cloudflare Hyperdrive path, so the driver has to be inside the bundle. With nodejs_compat turned on, that TCP client rides Cloudflare’s Node compatibility layer. Marking pg external instead led to Uncaught Error: No such module "chunks/pg" when the worker loaded.
A durable rule: treat a dependency as external only when every route that imports it is dynamic and gated. One static import anywhere converts a clean build into a post-deploy crash that is harder to chase.
The adapter overwrites your externals, so re-append them
This issue was the slowest to pin down. Inside astro:build:setup, @astrojs/cloudflare forces vite.ssr.noExternal = true and resets vite.build.rollupOptions.external to ['sharp']. Whatever you wrote in ssr.external disappears before Rollup starts.
A Vite plugin with enforce: 'post' that writes the externals back works around it:
{
name: 'autonnel:cf-extra-externals',
enforce: 'post',
config(conf) {
const existing = conf.build?.rollupOptions?.external;
if (Array.isArray(existing)) {
conf.build.rollupOptions.external = [...new Set([...existing, ...NODE_ONLY_EXTERNALS])];
} else if (typeof existing === 'function') {
const existingFn = existing;
conf.build.rollupOptions.external = (id, parentId, isResolved) =>
NODE_ONLY_EXTERNALS.includes(id) || existingFn(id, parentId, isResolved);
}
// ...string / RegExp / undefined branches
},
}
Several branches are required: external might already be an array, string, RegExp, function, or undefined, and a future adapter release may change again. The honest note beside the plugin is that it should vanish once @astrojs/cloudflare stops overwriting ssr.external — a temporary patch for one version’s behaviour.
The build ran out of memory in CI and not locally
Folding every SSR entry into a single workerd bundle pushed past Node’s default 2 GB heap. CI on Cloudflare died around 1.99 GB even though a developer laptop finished cleanly — an awkward class of bug.
Two Vite flags removed the pressure:
build: {
sourcemap: false,
reportCompressedSize: false,
},
Sourcemaps dominated memory, and workerd ignores them. reportCompressedSize also allocates a gzipped copy of each chunk just to print a nicer summary table. Neither pays for itself on this target.
The worker entry does two things Node’s doesn’t have to
Node gives you a request lifecycle for free. On Workers you implement it yourself:
export default {
async fetch(request, env, ctx) {
setRuntimeEnv(env);
return runWithRequestDb(async () => {
try {
return await ssrHandler.fetch(request, env, ctx);
} finally {
ctx.waitUntil(disposeRequestDb());
}
});
},
async scheduled(_event, env) { /* ... */ },
};
setRuntimeEnv(env) exists because there is no process.env on Workers. Bindings show up as handler arguments, so config access needs a per-request bridge. Porting a Node service usually touches many files here; wiring the bridge early beats hunting scattered process.env.FOO reads later.
ctx.waitUntil(disposeRequestDb()) covers cleanup: release the database client after the response goes out. Cleaning up sooner can dispose a client that streaming output still relies on.
Would dual targets be worth repeating
Yes — provided the second target has a clear job. Workers is not a free “deploy everywhere” toggle. It adds another build pipeline with distinct failure modes, and most of them show up at deploy rather than in tests.
The work stays tractable when divergence is boxed: two configs plus one entry module. Domain code should avoid sprinkling if (isWorkers) once cache, storage, and database already sit behind adapters. Missing those seams? Create them before adding the second runtime. Doing the reverse pushes runtime checks into checkout flows and other core services.