---
title: Deployment
description: Run a stateless server, keep old modules and typed reads available across deploys, and choose the right liveness posture.
order: 5
---

# Deployment

To ship a Kovo app, pick the smallest deploy shape that matches the app: a static host for L0/L1
pages, or a stateless server for mutations, guarded routes, typed reads, and per-request data. In
both shapes, keep versioned client modules available across deploys.

## The stateless server

The v1 server holds no state between requests. Concretely:

- **No session of what's on screen.** An enhanced mutation tells the server which fragments to render
  through the `Kovo-Targets` header, read off the live DOM's `kovo-deps` stamps at submit time. The
  server answers a self-contained question on every request.
- **Preview liveness stays stateless.** Ordinary mutation refreshes, refetch-on-focus, and
  BroadcastChannel tab sync need no instance affinity. SSE live queries are roadmap, not part of the
  technical preview.
- **No optimistic state server-side.** Predictions live in the document and die with it.

Operationally, this means any instance can answer ordinary routes, mutations, and typed reads. You
do not need sticky sessions. Session data follows whatever your `sessionProvider` reads — a signed
cookie, a session table — while the framework itself pins no UI state to an instance.

Two request-shaped facts worth knowing at the infrastructure layer:

- Mutations are `POST /_m/<name>` and queries are `GET /_q/<name>` — name-shaped paths you can rate
  limit, cache-exempt, and read in access logs.
- In-flight mutations at navigation use `keepalive`, and the framework registers no `unload`
  handlers, so bfcache hygiene is a guarantee rather than a tuning exercise.

## Retain prior build artifacts and reads

This is the deployment mistake to design out first. Emitted module URLs are immutable and versioned,
and your serving layer has to retain prior versions. The same deploy-skew contract covers typed
read endpoints: a stale document must be able to ask `/_q/<key>` for a token-tagged full value for
the build it was rendered with. That is how token mismatch recovery stays loud and recoverable
instead of silently merging a foreign query shape.

Here's why it matters. Kovo documents are long-lived. A tab opened before your Tuesday deploy still
has HTML pointing at Tuesday-minus-one's handler modules:

```html
<button
  on:click="/c/__v/8f3a1c40a176a92571fb2dc2ea4c4e431376c36da7e55cfb8a9cc2975e30cfe7/cart.client.js#Cart$removeItem"
>
  ×
</button>
```

The loader imports that URL on first interaction, which may be hours after the deploy that replaced
the module. If deploys delete old artifacts, the user's first click throws a 404 from inside a page
that looks perfectly healthy.

So the rule for your serving layer:

- **Publish `/c/*` artifacts additively.** New deploys add new versioned URLs; they never rewrite or
  delete the ones still referenced by documents in the wild.
- **Serve them immutable.** A full SHA-256 digest of the exact JavaScript response lives in the URL,
  so `Cache-Control: public, max-age=31536000, immutable` is correct. There is no `?v=` fallback.
- **Keep the required skew window.** Retain prior immutable modules and prior-token `/_q/` reads for
  the supported deploy-skew window, with a required minimum of 24 hours. Configuring less, or using a
  platform that cannot retain both artifact classes for that window, is a deploy-skew error.

Declare that retention in `kovo.config.ts` once your deploy pipeline really does it:

```ts
import { defineConfig, node } from '@kovojs/server/build';

export default defineConfig({
  preset: node({
    retention: {
      hours: 24,
      immutableClientModules: 'retained',
      priorTokenQueryReads: 'retained',
    },
  }),
});
```

Use the same `retention` option with `vercel()` or `cloudflare()` when your platform setup keeps old
`/c/__v/...` files and prior-token `/_q` reads reachable for the window. Without that declaration,
`kovo build` fails as soon as the app emits a versioned client island. The build cannot infer CDN or
object-store retention from the app source.

A CDN or object store in front of `/c/*` makes this nearly free: deploys upload new versions and
touch nothing else.

The framework handles the merge decision. Every page render, mutation truth chunk, delta, and typed
read response carries one app-build token. If a stale document receives a mismatched
payload, the loader discards it, refetches the full query over `/_q/<key>`, and reloads the current
route if the refetch still belongs to a different token. A long-lived document that POSTs yesterday's
form shape is answered by schema validation and the 422 path, never undefined behavior.

## What a deploy actually changes

It helps to see the two artifact classes a deploy touches and their opposite caching rules:

| Artifact              | URL stability          | Cache policy                             | On deploy                                    |
| --------------------- | ---------------------- | ---------------------------------------- | -------------------------------------------- |
| HTML documents        | stable paths (`/cart`) | revalidate (`no-store` on PRG responses) | replaced — next navigation gets the new page |
| `/c/*` client modules | content-addressed      | `immutable`, long max-age                | added — old representations retained         |
| `/_q/*` typed reads   | stable typed endpoint  | private/no-store when session-dependent  | serves current and in-window prior tokens    |

Documents update by navigation; modules update by being referenced from newer documents. A tab that
never navigates keeps working against its original module set indefinitely — which is the point of the
retention rule. It also makes rollbacks boring: rolling back re-publishes a previous document set
whose module URLs are still being served, because you never deleted them.

## Static host or node server

Which shape you deploy depends on what the app does.

**A node server** is the normal shape for anything with mutations, guarded routes, or parameterized
queries — the request lifecycle (CSRF, replay, guards, transactions, post-commit query reruns) runs
server-side. It's stateless, so you can run two of them behind a load balancer from day one.

**A static export** is enough when the site is L0/L1 only: platform behaviors and client islands, no
mutations, no per-request rendering. The export is plain HTML plus the loader plus `/c/*` modules,
deployable to any static host. This docs site is exactly that — every page works with JS disabled,
and the search island's module loads on first use. The degradation contract holds either way: Safari,
Firefox, and no-JS visitors get a working website rather than a blank screen.

The 24-hour `/c/*` retention floor applies to both shapes. On a static host it means not deleting old
module files during the skew window when you re-upload.

### Static export decision tree

Static export replays synthetic GET `Request`s through the same request handler; there is no second
render path. Use it when every exported route is L0/L1: platform behavior, pure islands, static
assets, and no per-request server truth.

Use a server deploy instead when the app needs mutations, guarded routes, parameterized queries
without an enumerable path set, raw endpoints, webhooks, typed reads, or per-request data. Exported
documents should not assume server refetches will exist later; the no-JS HTML document is the
artifact. The detailed static-export constraints and diagnostic ownership live in
[Static export](/guides/static-export/).

### Run a Node server entrypoint

The server receives the opaque value returned by your app contract's one `assemble()` call. That
value closes the routes, mutations, queries, database, and auth providers. `toNodeHandler()` adapts
its Web-standard `Request -> Response` handler to a
`node:http` listener. Define the host-independent handler first:

```ts
// handler.ts
import { createRequestHandler } from '@kovojs/server/custom-adapters';
import app from './app.js';

export const handler = createRequestHandler(app);
```

Then keep the host listener in a separated adapter entry:

```ts
// server.ts
import '@kovojs/server/runtime-bootstrap';

import { createServer } from 'node:http';
import { toNodeHandler } from '@kovojs/server/node';
import { handler } from './handler.js';

createServer(toNodeHandler(handler)).listen(Number(process.env.PORT ?? 3000));
```

That side-effect import must be the literal first import in a custom entry module. It locks Kovo's
classifier-reviewed runtime controls before Node, your app, or any package dependency evaluates.
Kovo-generated deployment entries install the same bootstrap automatically. The public handler's
boot check detects an omitted bootstrap; it cannot authenticate earlier evaluation in the same
JavaScript realm. Importing app or package code first and bootstrapping later is unsupported
privileged-host misuse, not a repair path—restart the process with the documented order.
The separate handler module is required: it keeps `node:http` host authority out of the
request-reachable closure while `defineKovo()` lifecycle callbacks and the handler graph remain
compiler roots.

The generated `app` already carries its database provider, session provider, CSRF configuration,
routes, queries, and mutations. Keep signing secrets and system database handles inside the
framework-owned generated boundary instead of rebuilding that configuration in the runner.

The instance pins nothing per-request: `sessionProvider` reads whatever your auth layer stored (a
signed cookie, a session row), so any instance answers any request and you scale by adding instances.

### Prove one public Cloud Run deploy

Kovo's release journey uses Google Cloud Run as its named Node host. It creates the starter from
authenticated packed packages, selects `--deployment node --retention retained-24h`, builds the
generated Node output, and probes the public app and its stylesheet.

Run it from `main` after the `g11-cloud-run` GitHub environment has its five Google Cloud variables:

```sh
gh workflow run g11-cloud-run.yml --ref main -f confirm=DEPLOY_G11
gh run watch
```

Each run gets a new `kovo-g11-<run>-<attempt>` service URL. The workflow leaves that exact service
untouched for 26 hours, probes it on an hourly schedule, and deletes it only after the 24-hour floor.
That version-addressed URL keeps the document, `/c/__v/...` modules, and typed reads on one build.

The catch is that this is a release probe, not a stable custom-domain topology. Updating one Cloud
Run service in place does not route an old document's module and `Kovo-Build` requests back to its
old revision. Use a build-token-aware ingress plus retained assets before making the same retention
claim on a stable production origin.

A minimal container, with `/c/*` artifacts baked into the image so they're served immutably:

```dockerfile
FROM node:24-slim@sha256:cb4e8f7c443347358b7875e717c29e27bf9befc8f5a26cf18af3c3dec80e58c5
ENV NODE_ENV=production
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN corepack pnpm install --prod --frozen-lockfile --ignore-scripts
COPY --chown=node:node dist ./dist # output of `pnpm run build:prod`, including /c/* client modules
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/server/server.mjs"]
```

The container needs database and auth secrets from the environment. None of these is
instance-specific — the same image runs behind a load balancer unchanged.

| Variable                           | Owner              | Required when                                                                        |
| ---------------------------------- | ------------------ | ------------------------------------------------------------------------------------ |
| `DATABASE_URL`                     | Your `db` provider | The app opens a remote database connection.                                          |
| `KOVO_CSRF_SECRET`                 | Kovo CSRF          | Strong fallback signing secret; at least 32 characters in generated auth apps.       |
| `BETTER_AUTH_SECRET`               | Better Auth        | Strong signing secret; at least 32 characters when set.                              |
| `BETTER_AUTH_URL`                  | Better Auth        | Canonical HTTPS origin; plaintext is local-dev-only on exact loopback.               |
| `KOVO_NODE_ORIGIN`                 | Node preset        | Recommended behind TLS termination; the exact canonical public origin.               |
| `KOVO_NODE_TRUSTED_PROXY`          | Node preset        | Alternative exact value `1`; trusts the immediate proxy's forwarded scheme.          |
| `PORT`                             | Node preset        | Optional; defaults to `3000`.                                                        |
| `HOST`                             | Node preset        | Optional; defaults to `0.0.0.0` in emitted Node servers.                             |
| `NODE_ENV`                         | Runtime posture    | Set to `production` for secure-cookie and boot-secret floors.                        |
| `KOVO_PRESET`                      | `kovo build`       | Optional override: `node`, `vercel`, or `cloudflare`.                                |
| `VERCEL`, `CLOUDFLARE`, `CF_PAGES` | Host detection     | Read by `kovo build` when no preset is configured.                                   |
| `KOVO_SQL_GUARD`                   | Raw SQL migration  | Temporary fail-open escape for unmanaged raw SQL sinks; managed sinks still enforce. |

`KOVO_CSRF_SECRET` is the scaffold's local-development name. The generated auth boundary reads
`BETTER_AUTH_SECRET ?? KOVO_CSRF_SECRET` after bootstrap and does not return the raw value to app
source. Do not set `BETTER_AUTH_SECRETS` or `BETTER_AUTH_TRUSTED_ORIGINS`; generated apps reject
those upstream override variables.

### Pin the public origin behind TLS termination

The generated standalone Node server listens with plain HTTP. When a reverse proxy terminates TLS,
pin the public origin that every app `Request` should see:

```dotenv
BETTER_AUTH_URL=https://app.example.com
KOVO_NODE_ORIGIN=https://app.example.com
```

This is the recommended posture. Kovo ignores `X-Forwarded-Proto` and `X-Forwarded-Host` and uses
the fixed scheme, host, and effective port. The value must be one canonical HTTP(S) origin with no
path, credentials, query, fragment, explicit default port, or trailing slash.

If the immediate proxy is trusted to replace `X-Forwarded-Proto` and preserve the external `Host`,
you may use the narrower proxy mode instead:

```dotenv
KOVO_NODE_TRUSTED_PROXY=1
```

Do not set both variables. Never enable proxy mode when clients can reach the Node listener directly
or can supply the forwarded-scheme header unchanged. Kovo still ignores `X-Forwarded-Host`; a host
or port that differs from `BETTER_AUTH_URL` is rejected before auth reads cookies or writes session
state.

## Liveness in the technical preview

Kovo has three shipped liveness paths, each with a different operational cost:

- **BroadcastChannel rebroadcast** — a mutation's `<kovo-query>` response is rebroadcast to the user's
  other open tabs. Add to cart in tab A, and the badge in tab B ticks. Zero server cost, no
  infrastructure. Envelopes carry a principal fingerprint so another user on the same origin cannot
  receive private query data after an account switch.
- **Refetch on focus/visibility** — the loader re-runs queries over the typed read endpoint
  (`GET /_q/…`) when a stale tab returns, with per-query opt-out. One conditional in the loader fakes
  a lot of live UX.
- **Enhanced mutation responses** — the submitting tab gets server truth in the POST response, using
  the same `<kovo-query>` / `<kovo-fragment>` vocabulary that powers later refreshes.

SSE live queries are roadmap, not part of the technical preview. Do not deploy `live: true`,
`<kovo-live>`, `defineKovo({ live })`, or live emitters in preview apps. See
[Live queries](/guides/live-queries/) for the shipped paths and the roadmap caveat.

## Observe the request shell

The app server gives operators two stable handles before you add product-specific metrics:
name-shaped framework paths and the `onError` hook.

Access logs can group by endpoint without parsing a framework envelope:

- `POST /_m/<mutation-key>` — mutation submissions, including no-JS form posts.
- `GET /_q/<query-key>` — typed reads, refetch-on-focus, and stale-tab recovery.
- `/c/__v/<representation-digest>/<module>` — immutable client modules.
- Declared `endpoint()` and `webhook()` paths — raw machine ingress.

Use `defineKovo({ onError })` for runtime exceptions from the request shell. It receives the thrown
error plus a `ServerErrorDiagnosticContext` with the failing operation and any known route,
mutation, query, target, status, URL, or request identity. The hook is diagnostic only: errors thrown
inside it are swallowed, and it cannot change Kovo's stable 403/404/500 responses.

```ts
import { defineKovo } from '@kovojs/server';

const app = defineKovo({
  onError(error, context) {
    console.error('kovo request failed', { error, ...context });
  },
});

export default app.assemble({ mutations, queries, routes });
```

Keep build-time and runtime signals separate. `kovo check`, `kovo explain`, and the
source/sink audits answer "what can this app do?" before deploy. `onError`, access logs, and rate
limit counters answer "what happened in production?" after deploy.

## Pre-deploy gates

The verification surface is browser-free by design. In the generated starter, the scaffold already
ships the right scripts for CI and for the emitted server:

```sh
pnpm run check       # source-only: type/lint + sound-subset + current compiler/security proof
pnpm run build:prod  # kovo build ./src/app.tsx
pnpm run check:endpoint-posture # exercise the emitted production server after build
pnpm run start       # NODE_ENV=production node dist/server/server.mjs
```

`pnpm run check` is the starter's current-source proof script, not just a typecheck alias. It runs
the formatter, linter, sound-subset classifier, and source-backed `kovo check` without requiring a
deployment-retention claim or writing `dist`. `pnpm run build:prod` adds the selected preset,
artifact, least-privilege, and §14 retention gates; only after it succeeds can the endpoint-posture
audit exercise the emitted Node server. Add app-owned graph assertions only after your app owns a
matching script. See [reading kovo check & kovo explain](/guides/kovo-explain/).

## Checklist

- [ ] App server is stateless; no instance affinity configured anywhere.
- [ ] `/c/*` published additively, served immutable, retained across deploys.
- [ ] HTML responses are not cached as immutable (documents change per deploy; modules don't).
- [ ] 103 Early Hints / preload wired from route page hints if your edge supports it.
- [ ] Speculation Rules prefetch only on routes that opted in — it is per-route, default off.
- [ ] CI runs `pnpm run check`, `pnpm run build:prod`, and then
      `pnpm run check:endpoint-posture` before deploy.
- [ ] App-owned graph assertions run before deploy, if the app has a script for them.

## Next

- [Background tasks](/guides/background-tasks/) — deploy a preset that can actually drain durable jobs.
- [Configuration & environment](/guides/configuration-environment/) — map the app-facing env vars to the deploy shape.
- [Reading kovo check & kovo explain](/guides/kovo-explain/) — the gates in that checklist.
- [Live queries](/guides/live-queries/) — shipped liveness paths and the roadmap SSE caveat.
- [Static export](/guides/static-export/) — deciding whether a route can ship without a server.
- [Streaming & defer](/guides/streaming/) — response streaming and what it needs from the edge.

<details>
<summary>Spec & diagnostics</summary>

The stateless-server guarantee, BroadcastChannel, and refetch-on-focus: SPEC §9.3. `Kovo-Targets`
and the mutation round-trip: SPEC §9.1. Session providers: SPEC §6.5. The typed read endpoint:
SPEC §9.4.
`keepalive` and bfcache hygiene, plus per-route Speculation Rules: SPEC §8. Immutable versioned
module URLs, prior-token typed reads, the 24-hour retention floor, and deploy-skew recovery:
SPEC §6.6 and §14. Static export through the request shell: SPEC §9.5; see
[Static export](/guides/static-export/) for exportability diagnostics. Schema-validated old-form
recovery via the 422 path: SPEC §9.2. The request lifecycle: SPEC §10.3. Browser-free pre-deploy
gates: SPEC §11.4. `defineKovo({ onError })` and `ServerErrorDiagnosticContext` report
request-shell runtime failures without changing stable error responses: SPEC §9.2. KV417 reports a
serving layer that cannot meet the skew-retention floor.

API reference: [@kovojs/server](/api/server/).

</details>
