Runtime Contract
A deployed project runs on Cloudflare’s Worker runtime (workerd), not Node.js. Most code ports without changes, but four differences catch people the first time they deploy a server-rendered app (Next.js/OpenNext, RSC, or any framework with a fetch handler). Knowing them up front turns a day of redeploys into minutes.
1. Configuration lives on env, not process.env
Your Worker receives its bindings — env vars, secrets, D1, KV, R2, and the Inbox — as the second argument to fetch, not on a global process.env:
export default {
async fetch(request, env, ctx) {
const base = env.API_BASE; // There is no runtime process.env on workerd. If a library reads process.env.SOMETHING, that value is undefined unless it was replaced at build time (see the next section). Thread bindings down from the env argument instead.
2. NODE_ENV is pinned to production for you, except in prebuilt bundles
Many libraries (React most notably) branch on process.env.NODE_ENV and ship a slower, larger development build when it isn’t "production". Because workerd has no runtime process.env, an unpinned NODE_ENV silently evaluates falsy and you get the dev build — the site renders, but client components can fail to hydrate.
yolo deploy handles this for you: when it bundles your Worker with esbuild, it statically replaces process.env.NODE_ENV with "production". You don’t need to do anything.
The one exception is a prebuilt worker ("worker": { "prebuilt": true } in .yolo/deploy.json) — the output of a framework build like OpenNext, which ships as-is without re-bundling. There, yolo deploy can’t rewrite the code, so your framework build must pin NODE_ENV itself. If it doesn’t, yolo deploy warns:
deploy: warn prebuilt worker references process.env.NODE_ENV — workerd has no
runtime process.env, so an unpinned NODE_ENV can ship a dev build …Fix it in your build step, for example with an esbuild define:
esbuild.build({ /* … */ define: { 'process.env.NODE_ENV': '"production"' } });3. node: built-ins need the nodejs_compat flag
workerd provides a subset of Node’s built-in modules (node:crypto, node:async_hooks, node:stream, node:buffer, and more) — but only when the nodejs_compat compatibility flag is enabled. Add it to .yolo/deploy.json:
{
"compatibilityFlags": ["nodejs_compat"]
}yolo deploy doesn’t fail the build on a node: import — it leaves the module for the runtime to provide and warns if the flag is missing:
deploy: warn worker imports node: builtins (node:async_hooks) but
compatibilityFlags does not include "nodejs_compat" …A misspelled builtin (node:async_hook) is still a hard build error — it isn’t a real module. And a require("node:…") of a builtin gets a build warning: a CommonJS require of a builtin compiles to a helper that throws in an ESM Worker unless it’s guarded by try/catch (a common optional-dependency probe), so the build proceeds but an unguarded call fails at runtime. Prefer an ESM import.
nodejs_compat is the only compatibility flag hosting accepts — a ship with any other flag is rejected. Enabling it turns on the supported node: built-ins as a group; you don’t opt into modules individually.
4. Errors surface as a branded page, and logs are opt-in
An uncaught exception in your fetch handler does not show the visitor a raw Cloudflare 1101 — the router catches it and renders a branded Application error page. That keeps users out of a broken state, but it also means the stack trace isn’t in the response.
To see what actually threw, read the release’s logs:
deploy.logs {projectId, kind: "app"} # console output + uncaught exceptions
deploy.logs {projectId, kind: "requests"} # per-request status / latency / error classApp logs are captured for every release shipped while log capture is on (it is on for YOLO Host). A release shipped before capture was turned on has nothing to query — re-ship and reproduce. Request-level outcomes (kind: "requests") are always available.
A note on newer JS globals
workerd tracks a fixed compatibility date, so a handful of newer JavaScript globals are gated on it. If your app (or a dependency) relies on a global that isn’t present at runtime — you’ll see a ReferenceError in deploy.logs — the cause is usually the compatibility date rather than a bug in your code. Prefer widely-supported APIs, or vendor a small polyfill into your bundle.
Check before you ship
yolo deploy doctor lints your worker entry against this contract offline — no bundling, no network:
$ yolo deploy doctor
yolo deploy doctor — worker project
✓ entry 'src/index.ts' resolves
It scans your entry file only (warnings are advisory — they don’t block). For a full dependency-level check, yolo deploy --dry-run bundles without shipping.
For the highest-fidelity check, run your project on the actual runtime before shipping:
yolo deploy dev [--port 8787] [--var KEY=VALUE ...]yolo deploy dev serves your project locally on workerd (the same runtime and pinned compatibility date as production), so every gotcha on this page — an empty process.env, a missing node: builtin, a WeakRef/FinalizationRegistry gap, a dev-React build — surfaces locally instead of only after a deploy. A plain node dist/server.js runs on Node and gives false confidence; yolo deploy dev doesn’t. Real env vars and secrets are server-side, so pass local values with --var; declared KV/D1/R2 bindings get empty in-memory stand-ins.
Checklist
Before shipping a server-rendered app:
- Run
yolo deploy doctorand clear the warnings. - Read config from the
envfetch argument, neverprocess.env. - Add
"compatibilityFlags": ["nodejs_compat"]if you (or your deps) importnode:builtins. - For a prebuilt bundle, pin
NODE_ENV=productionin your framework build. - Ship a preview, hit it in a browser, and check
deploy.logsbefore promoting to production.
Related pages
- Managed Hosting — the deploy flow, supported project types, and
.yolo/deploy.json - Secrets & Env — how env vars and secrets bind to
env - Inbox Notifications — sending mail from a deployed app