# Deploying

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

`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
```

```sh
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.

```dockerfile
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`.

<Aside type="tip" title="What a port measured">
  A production app ported from Next, same pages, same backend, the Bun binary
  image beside the Next one it replaced:

  | | Next | rsc-kit |
  | --- | --- | --- |
  | docker image | 104.56 MiB | 50.12 MiB |
  | docker build | 5 m 13 s | 2 m 45 s |
  | Lighthouse | — | 99 mobile, 100 desktop |

  Nothing was tuned for the numbers. The image is smaller because the bundle
  carries no `node_modules`; the build is faster because there is one build,
  not a build and a trace.
</Aside>

### 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`](/guides/instrumentation/)
   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](/hosts/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:

```ts title="src/db.ts"
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.

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

```sh
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=modulepreload,
      </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.

## Streams and idle timeouts

An `events()` route and the [`refreshOn`](/guides/live-data)
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:

```sh
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`](/guides/live-data) | 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](#server-actions-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:

```sh
openssl rand -base64 32
```

or, without openssl:

```sh
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; their watching is refused and they 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.

## 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:

```sh
# once, kept wherever you keep secrets
openssl rand -base64 32
```

```sh
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.

:::danger[Set it everywhere or nowhere]
Half-configured is worse than unconfigured. If one instance reads the variable
and another falls back to a baked key, they disagree, and the symptom is an
action that fails for some visitors and not others with nothing in the logs
pointing at a key.

Unset, everything works — the build-time key is used, which is the default
precisely because it cannot be got half right.
:::

## 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](/guides/edge-caching).

## 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.
