I ship the same application to two runtimes: Node, for people who self-host with Docker, and Cloudflare Workers, for people who want it on the edge. Same repo, same source files, one astro.config.mjs and one astro.config.cloudflare.mjs.
That worked. It cost me about six distinct debugging sessions to get there, and every one of them was a case where the second config had to say something the first one didn't. Here they are, because none of them are in a getting-started guide.
The two configs are 90% identical, and that's the problem
The obvious approach is one config with an if. I started there and moved away, because the divergences aren't a single flag - they're a different adapter, a different externals policy, a different alias set, and a different set of build memory knobs. An if ladder covering all of that is harder to read than two files.
The cost is real and I want to name it: two files that must stay in sync in the parts where they agree. The place this bites is React resolution, and it bites hard enough that the Cloudflare config carries a comment saying so:
// 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.
If you take one thing from this post: resolve.dedupe for React is not an optimization. Two React copies in one bundle fail at the first hook call, with an error message that points at your component and not at your bundler.
Prisma's generated client doesn't resolve for workerd
Prisma 7 generates a client that uses Node.js subpath imports (#main-entry-point). Rollup, targeting workerd, can't resolve that. The fix is aliasing straight to the edge entry:
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,
},
}
Note what that path resolution is doing. In Prisma 7.8+ the generated .prisma/client/ lives inside the @prisma/client package directory, and with pnpm that's .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/, not the project's top-level node_modules. Hardcoding a path works on your machine and breaks on any machine with a different hoisting layout or store hash. Resolving relative to @prisma/client/package.json is the version that survives.
The externals list, and one entry that is deliberately absent
Node-only packages must not go into the workerd bundle. Mine is short:
const NODE_ONLY_EXTERNALS = ['ioredis'];
ioredis is loaded via dynamic import() behind an isCloudflareRuntime() guard, so its chunk is never fetched on Cloudflare and externalizing it is safe.
pg is not in that list, and the comment explaining why is the single most useful comment in the file:
@prisma/adapter-pgimportspgstatically andPrismaPgruns on the CF Hyperdrive path, sopgmust be bundled. Withnodejs_compatenabled, pg's TCP connection works through Cloudflare's Node.js compatibility layer. Externalizing it producedUncaught Error: No such module "chunks/pg"at worker load time.
The general rule I extracted: a package is externalizable only if every path that imports it is dynamic and guarded. One static import anywhere in the graph and externalizing turns a build-time question into a load-time crash, which is much worse because it happens after deploy.
The adapter overwrites your externals, so re-append them
This one took the longest to find. @astrojs/cloudflare sets vite.ssr.noExternal = true and vite.build.rollupOptions.external = ['sharp'] inside its own astro:build:setup hook. Anything you put in ssr.external is simply gone by the time Rollup runs.
The workaround is a post-enforced plugin that re-appends:
{
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
},
}
Four branches, because external can legitimately be an array, a string, a RegExp, a function, or absent, and I don't control what the adapter will set next release. The comment above it says "remove once @astrojs/cloudflare stops clobbering ssr.external", which is the honest status: this is a workaround against a specific version's behavior, and it's written to be deletable.
The build ran out of memory in CI and not locally
Bundling every SSR entrypoint into one workerd bundle peaked above Node's 2 GB default heap. It OOM'd at about 1.99 GB on Cloudflare's build CI while succeeding on my machine, which is the least fun failure mode there is.
Two lines fixed it:
build: {
sourcemap: false,
reportCompressedSize: false,
},
Sourcemaps were the biggest single consumer, and workerd doesn't consume them anyway. reportCompressedSize allocates a gzipped copy of every chunk purely to print a nicer table at the end of the build. Both are pure cost in this target.
The worker entry does two things Node's doesn't have to
The Node build gets a request lifecycle for free. On Workers I own it:
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 on Workers there is no process.env. Bindings arrive as an argument to the handler, which means anything that reads configuration has to go through an indirection that is populated per-invocation. If you're porting a Node app, this is the change that touches the most files, and doing it early is much cheaper than doing it after you've written a hundred process.env.FOOs.
ctx.waitUntil(disposeRequestDb()) is the other half: the connection has to be released after the response is returned, not before, or you'll dispose a client that streaming code still needs.
Would I do it again
Yes, but I'd be clearer about what the second target is for. Running on Workers is not a free "also deploy anywhere" checkbox. It's a second build with its own failure modes, and roughly all of them surface at deploy time rather than in tests.
What makes it worth it is that the divergence is confined: two config files and one entry file. No if (isWorkers) scattered through business logic, because everything runtime-specific is behind an adapter (cache, storage, database) that already existed for other reasons. If your app doesn't have those seams yet, build them first and add the second runtime after. Doing it in the other order is how you end up with runtime checks in a checkout service.
Top comments (0)