Menu

Guides

View as Markdown

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

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:

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:

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:

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

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#

Spec & diagnostics

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