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.

A release step, and processes that are not the server

The image above starts a server because that is what the binary does with no arguments. With src/commands it does something else with one:

docker run --rm myapp migrate   # before the new version takes traffic
docker run --rm myapp work      # a worker, beside the web processes

Any platform that can run a command in the image does the same — a Kubernetes Job, a release command, a process type — and nothing in the app names which. See Commands.

Stopping gracefully

Nothing to write. A rollout sends the old process SIGTERM (or SIGINT), and the built server stops the way it should:

  1. It stops taking connections and gives in-flight requests SERVER_SHUTDOWN_TIMEOUT seconds to finish (5 by default).
  2. It calls shutdown() from instrumentation.ts if you export one, for whatever you opened: a database pool, a queue client, a telemetry exporter. It gets RSC_SHUTDOWN_TIMEOUT seconds (10); one that hangs or throws is reported and does not hold the process.
  3. It exits with code 0.

An app with nothing open exits as soon as the drain finishes.

Two things still undo it quietly:

  • Another SIGTERM handler that exits. A logger or SDK that calls process.exit() on a signal runs first if it was imported first, and the drain never starts. In-flight requests, webhooks included, are dropped on every rollout, and the process exits 0, so nothing looks wrong. A logger should flush on exit, not handle the signal.
  • A grace period shorter than the drain. On Kubernetes, terminationGracePeriodSeconds has to cover SERVER_SHUTDOWN_TIMEOUT, then shutdown(), then any preStop sleep, or the SIGKILL arrives first.

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.

Bindings on Workers

A D1 database or an R2 bucket named in wrangler.jsonc is read from cloudflare:workers, the runtime’s own module — the bundle leaves it to the runtime, like bun:sqlite. vite dev under Bun or Node has no such module, so import it where the code runs on Workers, behind a check for the runtime:

src/db.tsts
const onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'

export async function db() {
  if (onWorkers) {
    const { env } = await import('cloudflare:workers')

    return env.DB   // the D1 binding, as wrangler.jsonc names it
  }

  const { Database } = await import('bun:sqlite')

  return new Database('data/local.sqlite')
}

The build renders pages through the same module, so a page that reads a table with no request in it is frozen from the local database, and only the pages that read the url or the request reach D1 at request time.

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.

A server started with nothing set gives its own process the mode it was built in, before the app loads, so process.env.NODE_ENV is production at run time too. That is what a typed env.ts built from a spread of process.env reads, and before 0.30.36 it read nothing and fell to its schema’s .default('development'). It also means rsc-kit’s own production checks run: an app that uses refreshOn must set RSC_SIGNING_SECRET, or its live pages answer 500.

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.

What the document sends ahead

Every document answers with a Link header naming what it needs first — its stylesheet, the client entry, and the fonts its layout preloads:

Link: </assets/index-D_il52MF.css>; rel=preload; as=style,
      </assets/index-C-u4Ll0r.js>; rel=preload; as=script; crossorigin,
      </assets/geist-latin-wght-normal-BgDaEnEv.woff2>; rel=preload; as=font; crossorigin

Cloudflare turns that into a 103 Early Hints sent before the origin answers and cached at the edge, so those three download while the HTML is still on its way; any other CDN passes it through, and a browser reads it the moment the headers land, before the parser reaches the <head>. The critical set only, never the chunks: a hint promotes what it names, and naming every module demotes the document behind them. Stored documents carry it too — a stored document’s own head names its fonts; a rendered document’s head is not known until after its headers have gone, so it names the stylesheet and the entry. Nothing to configure, and nothing it replaces: the stylesheet still blocks paint, and inlining critical CSS stacks on top of it.

The entry is a preload with as=script, not a modulepreload, because Cloudflare builds its 103 from preload and preconnect alone and would leave a modulepreload out. crossorigin gives the same fetch a module script makes, so the browser uses the preloaded file and does not download it twice. The document’s own <link rel="modulepreload"> is unchanged, so the module is still compiled early.

Streams and idle timeouts

An events() route and the refreshOn stream hold a connection open and are quiet most of the time. Anything in front that closes a quiet connection - the server’s idle timeout, a proxy’s read timeout - would cut them, so each sends a keepalive line every eight seconds: under Bun’s default ten, and well under nginx’s sixty and Cloudflare’s hundred.

If something in front closes idle connections sooner, set the keepalive below it:

RSC_STREAM_KEEPALIVE_MS=4000   # under a 5-second idle timeout

Too long a keepalive does not lose anything - the browser reconnects, and a tab catches up on whatever moved - but every open tab is cut and reopens each time the timeout passes. Both streams are sent with Cache-Control: no-store, no-transform and are never compressed, so nothing in front buffers them.

Secrets

rsc-kit reads up to three. Each is its own key for its own job - never reuse one as another, so one leaking does not hand out the rest.

Variable What it protects Who needs it Read by
RSC_HOST_CALL_SECRET calls between the renderer and a Go or Laravel backend an app with a backend - required the renderer and the backend
RSC_SIGNING_SECRET the names a page hands a tab for refreshOn an app that uses refreshOn - required in production the renderer
RSC_ACTION_ENCRYPTION_KEY values a server action sends through the browser, across a deploy optional the renderer, from the build

bun create rsc-kit and rsc-kit init generate the first two into .env for an app with a backend, and php artisan rsc:install does the same for Laravel. That is development. For production, generate each once and keep it where you keep secrets - your platform’s environment settings, not the repo:

openssl rand -base64 32

or, without openssl:

node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"

Three rules apply to all of them:

  • The same value on every instance. Each is checked by a process that may not be the one that issued it: a tab served by one renderer, a call landing on another backend replica. A value per instance fails for some requests and not others.
  • At runtime, from the environment. The built server reads them when it starts, so one build deploys anywhere. RSC_ACTION_ENCRYPTION_KEY is the exception: set it at build time too.
  • Never in git. The scaffold’s .gitignore already leaves out .env.

Rotating one is a deploy, and each says so differently:

  • RSC_HOST_CALL_SECRET - the renderer and the backend must change together. Between the two, every rpc() is refused, so deploy both at once.
  • RSC_SIGNING_SECRET - pages already open hold names signed with the old key. Set the old value as RSC_SIGNING_SECRET_PREVIOUS for the deploy that rotates it: it is accepted when checking a signature and never used to make one, so open tabs keep refreshing while every page rendered after the rotation carries the new key. Remove it a day later. Without it, those tabs stop refreshing until reloaded; nothing else breaks.
  • RSC_ACTION_ENCRYPTION_KEY - an action called from a page loaded before the change fails, the same window as a deploy without the key pinned.

What the build produces

A deploy that plans before building - a binary or a folder, which script to run - asks first:

bunx rsc-kit info --json

It loads the app’s own Vite config the way a build does, so an output computed in the config, or RSC_OUTPUT=export in the environment, resolves exactly as the build will resolve it. Then every build writes the same answer to .output/rsc-kit.json as it finishes - the confirmation, from the code that decided; refuse a deploy where the two disagree:

{ "version": "0.30.33", "output": "server", "compile": "compile:binary", "binary": "dist/app" }
{ "version": "0.29.6", "output": "export", "dir": "dist" }
  • output: "server" - run the server. compile and binary are there when the app’s package.json has a compile step: run the script compile names after the build, and the file its --outfile names is the single binary to ship. It is compile:binary when the app has one, which compiles the output the build just wrote. Otherwise it is compile, which usually runs vite build again first; the build says so, and a scaffolded app has both.
  • output: "export" - dir is the static site; serve it as files.

One field is only in the build’s file, because only the build knows it: commands, the app’s commands. Compare the rest with info.

Pages an edge may serve from its cache

rsc-kit implements the edge contract: one marker and two endpoints that an edge uses the same way for every framework that implements it. next-bun-compile implements it too. The edge serves a static page whole and a PPR page’s shell straight from its cache, and has the origin render only a shell’s holes, onto the same response. Serving shells from a CDN covers what that buys. What rsc-kit puts in it:

  • .output/edge.json is written by every server build, with empty lists when there is nothing to list. An export writes none.
  • shells are the pages the build report marks ◐, as patterns. static are the pages it marks ○, each at the path it was rendered at. Both leave out every page with middleware above it (a middleware.ts, or the host’s export const middleware), since middleware decides per request who may see the page, and every page bound from the request’s host (app/[domain]/), since a path cannot say which host it is. static also leaves out a frozen redirect and a page a listed shell pattern matches.
  • A root middleware.ts empties both lists, since it is above every page, and the build names the middleware that kept each page out. Headers that are the same on every page belong in rscKit({ headers }) instead (see security headers): they keep the page listed, and /_edge/page hands them to the edge as the page’s headers.
  • Past 200, shells drops the deepest and static keeps the shallowest, and the build says which. A page left out is served by the origin, which answers it correctly.
  • build covers the client bundle and the bytes of every stored page, shell and postponed state, so a release that changes only what the server rendered is a new build, and an edge drops what it cached before it. It is not the version a browser tab sees: a server-only change does not make open tabs reload.
  • A static page’s payload is copied to /assets/_rsc/<hash>.rsc, served immutable like the rest of /assets/, and named in the document, so the page hydrates without asking the origin for anything.
  • EDGE_TOKEN switches both endpoints on. The platform sets it; without it, everything under /_edge/ is a 404.
  • The resume’s body is ignored. rsc-kit hands out "state": "" and reads the postponed state from its own build, so it never parses what a caller wrote.

Do not work the list out from the files in .output/server/rsc-static/. A guarded page has a shell on disk too, _team_ cannot say whether it was a parameter or a folder of that name, and the folder’s layout is not a contract.

A build carries no backend address and no secret. RSC_BACKEND, RSC_HOST_CALL_SECRET and RSC_SIGNING_SECRET are read when the server runs, so one build runs against whichever backend its deployment names, and rotating a secret is a restart, not a rebuild.

Is it ready

GET /_rsc/health says whether this renderer can serve, for a platform deciding when to route to it - which an open port alone does not say:

{ "ok": true, "version": "08f805d3", "backend": "ok" }
  • 200 - up. backend is "ok" when the backend answered, "none" for an app without one.
  • 503 - the backend did not answer within 2 seconds: unreachable, or refusing the secret.

Why it is not ready names the app’s insides, so the reason and backend go only to a probe that sends the host-call secret, as x-rsc-host-secret. Anyone else gets { "ok": true } or { "ok": false } and the status - which is all a router needs. Not keyed on localhost: behind a reverse proxy on the same machine, every visitor is localhost.

The backend check asks the route guards with none to run - every adapter passes an empty list - so it runs no app code and reads no data, yet it proves the address, the network, the shared secret and the adapter. Never cached; HEAD answers with the status alone.

Pages across a deploy

A page from the previous build asks for a payload from the new one, whose client components its own manifest cannot load. Every document carries which build it is (<meta name="rsc-kit:build">, an id derived from the client entry), the client sends it as X-RSC-Version on every payload request, and a server on another build answers 409 with where to go; the client loads the document from the current build, once. A payload that still names a client reference the page lacks — a CDN answering from cache, say — reloads the document the same way a missing chunk does. Nothing to configure, and nothing to set: the id is the client’s, so a deploy that changed only the server leaves every open page alone. There is no override — a version set anywhere else would disagree with what the document says, and a client’s honest claim would be refused on every navigation.

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.

Build, then start — never build under a running server. The server loads its rsc and ssr services lazily, on the first request that needs each, and a vite build that is rewriting .output/ at that moment hands it a file that is half-written or briefly missing. The runtime caches that failed import — Bun does, and so does Node’s ESM loader — so the route answers 500 with an ENOENT for a file that now exists, and keeps doing so until the process restarts. In development use vite (the dev server), which owns its own rebuilds; in production build into a fresh directory or stop the server first, and start it against a finished .output/.

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.

HTTP/2 in front

Every client component is its own chunk, so a page loads only the code it renders — and a page with many of them makes several small requests. Over HTTP/2 or HTTP/3 those share one connection and cost nothing extra; over HTTP/1.1 a browser opens at most six connections per origin and the rest queue.

Behind Cloudflare there is nothing to do: HTTP/2 and HTTP/3 are on by default, for proxied sites and Workers alike. The case to check is a Bun or Node server exposed to the internet directly, which speaks HTTP/1.1 — put anything that speaks HTTP/2 in front of it (a CDN, an ingress controller, Caddy, nginx), which is how almost every deployment runs anyway. curl -sI --http2 https://your.site answers HTTP/2 when it is right.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close