Skip to content

Deploying

What to ship, and the two settings that fail quietly.

Updated View as Markdown

npm run build writes one directory, and it is the deployment:

.output/server    the server Nitro built, and the engine it calls
.output/public    hashed assets and the pages frozen at build time
npm run build
npm run start     # node or bun .output/server/index.mjs

npx vite preview runs the same build through Vite’s preview server, which is what Nitro suggests at the end of a build. Both serve the real thing; start is what a deployment runs.

Anywhere that runs the runtime

.output/ is self-contained. Copy it and start it — a container, a VPS, a process manager, a platform that runs a Node or Bun process. There is nothing to register and no platform API to satisfy, and no node_modules: the dependencies are in the bundle.

FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build

FROM oven/bun:1
WORKDIR /app
COPY --from=build /app/.output ./.output
CMD ["bun", ".output/server/index.mjs"]

The second stage carries .output and nothing else. On the docs application that is 1.0 MB against 104 MB of node_modules.

Or a platform, without a Dockerfile

Change the preset and Nitro produces what that platform expects — a Worker and its wrangler.json, a Vercel function, a Netlify handler. See Where it runs.

Two things that fail quietly

Do not set NODE_ENV when starting the server. The build bakes its mode into the bundle, so a server started with nothing set is production because it was built that way. Setting it at start time is a second source of truth and the one that can disagree — and when it disagrees the failure is silent: every page renders, and none of them hydrate.

The check is React’s debug rows in the payload:

curl -s https://your-app.example.com/ | grep -c ':D{'

0 on a correct production build. Anything else means a development bundle reached the client.

Serve .output/public at the root. Nitro does this itself, so this only matters behind a CDN: point it at that directory and let the hashed filenames do the caching — they are content-addressed, so they can be cached forever.

Server actions across a deploy

A server action can close over server-side values, and React encrypts those before sending them to the browser so the page cannot read them. The process that decrypts them on the way back has to hold the same key.

By default that key is generated at build time and baked in. Every instance running the same build agrees, so the only exposure is the deploy itself: a browser sitting on a page from the old build calls an action on the new one, and the key has changed underneath it. The call fails.

For most apps that window is seconds and nobody notices. If yours is long enough to care about — a slow rollout, long-lived pages, an app people leave open — pin the key:

# once, kept wherever you keep secrets
openssl rand -base64 32
RSC_ACTION_ENCRYPTION_KEY=<that value>

Set it at build time and the build stops baking its own; the value is read from the environment when the server runs, so the same artifact deploys anywhere.

Frozen pages and the routes that own them

.output/public holds whole frozen pages and PPR shells alongside the assets. They are read through the prerendered reader, which is a function rather than a directory precisely so a runtime with no filesystem — a Worker — can supply them from a binding instead.

A route that declares middleware is never cached publicly: it is sent as private, no-store, because middleware runs per visitor. If something in front of your app also owns the response — an auth proxy re-issuing a session on pass-through — give the paths it covers a middleware.ts so this host knows they are covered. See serving shells from a CDN.

Rebuild on deploy

Cached responses carry a build version, so a deploy invalidates them.

Ship .output/ from the same commit as the code that serves it. A server running one build against another’s frozen pages is the one combination nothing checks for you.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close