# Deployment

> Voltro Cloud (coming soon) — the managed runtime for your Voltro project. Today the Free control plane registers + observes your self-hosted apps.



---

<!-- source: en/deployment/voltro-cloud.md -->
## Voltro Cloud

_Voltro Cloud (coming soon) — the managed runtime for your Voltro project. Today the Free control plane registers + observes your self-hosted apps._

Voltro Cloud is the managed tier of the same runtime you run yourself. It's not a fork — `voltro start` is what powers your production instances, just with custom domains, multi-region routing, provisioned Postgres / Redis / object storage, and a control plane that handles deploys.

**Status:** managed cloud **deploy + provisioning is coming soon** and is not yet available. What's live today is the **Free control plane**: sign up and use it to **register and observe your self-hosted apps** (hosted observability, quotas, governance, teams). The managed-tier features described below are the planned shape of Voltro Cloud — see the per-section notes.

## When to use Cloud vs self-host

| | Cloud (coming soon) | Self-host (today) |
|---|---|---|
| Managed Postgres + Redis + pgvector | ✓ | You provision |
| Multi-region + edge routing | ✓ | You set up Caddy / Cloudflare / your CDN |
| Custom domains + automatic SSL | ✓ | You manage certs |
| AI provider gateway (key rotation, fallback) | ✓ | You wire `AI_PROVIDER` directly |
| Per-tenant vanity domains | ✓ (Enterprise) | Possible but you build the routing |
| Observability (logs, traces, metrics) | ✓ | You wire OpenTelemetry to your sink |
| Audit log retention | ≥ 90 days | Whatever you configure |
| You own the data | ✓ — export anytime | ✓ |
| You own the source code | ✓ | ✓ |

Pre-release: the **Free tier is live** — sign up (passwordless, an email one-time code) and use the control plane to register + observe your **self-hosted** apps. Managed cloud **deploy + provisioning is coming soon** (`voltro cloud deploy` prints `control-plane deploy pipeline pending — not yet available` until it ships); paid tiers open with it at launch.

## The `voltro cloud` client

The control-plane client is `voltro cloud`, not a top-level `voltro deploy`. It authenticates against the control plane (base url via `VOLTRO_CLOUD_URL`) and manages projects + env vars:

```bash
voltro cloud login [--token <t>]          # store a session token in ~/.voltro/credentials.json
voltro cloud whoami                       # the current account
voltro cloud projects                     # list your projects
voltro cloud env list [--project P]       # list a project's env vars
voltro cloud env set <KEY> <VALUE> [--project P]
voltro cloud env pull [--project P]       # write .env.cloud from the project's env
voltro cloud import                       # scaffold voltro.cloud.toml from app.config.ts
```

### Scaffolding the deploy config — `voltro cloud import`

`voltro cloud import` reads the app's `app.config.ts` (name + type) and writes a `voltro.cloud.toml` next to it. It refuses to overwrite an existing file. The generated shape:

```toml
# voltro.cloud.toml — generated by `voltro cloud import`.
# Describes how this app deploys to the framework cloud control-plane.

[project]
name = "acme"
type = "api"

[deploy]
# Target region + replica count. Tune before `voltro cloud deploy`.
region = "auto"
replicas = 1

[env]
# Project env-var names resolved from the control-plane at deploy time.
# Use `voltro cloud env set <KEY> <VALUE>` to populate them.
```

### Deploy is pending

`voltro cloud deploy` exists but the control-plane deploy pipeline is not yet available — running it prints `control-plane deploy pipeline pending — not yet available`. Build, upload, migrate, traffic-shifting, and one-click rollback are the planned shape of that pipeline; until it lands, use the `voltro cloud` env + project commands above to prepare a project.

## Environments

Per-project environments (`preview` / `staging` / `production`), each with its own env vars + DB + storage bucket, are part of the planned control-plane surface. Manage env vars today with `voltro cloud env list/set/pull`.

## Custom domains

*Coming soon with managed cloud hosting — not yet available.* When it ships, you'll add a domain in the Cloud UI and the platform handles:

- DNS verification (TXT record)
- ACME-issued SSL (Let's Encrypt or Google Trust Services)
- Auto-renewal
- Per-tenant vanity domains (Enterprise) — `tenant1.your-product.com` routes to the right tenant scope automatically

## Observability

*Available today for your registered self-hosted apps* (this is the live Free-tier control plane). OpenTelemetry-native:

- **Logs** flow to the project's log stream (search + filter in-UI; export to Datadog / Loki / etc.)
- **Traces** for every mutation, query, workflow, agent run
- **Metrics** for request rates, error rates, p50/p95/p99 latencies, AI token spend per tenant

## Provisioning

*Coming soon with managed cloud hosting — not yet available.* When it ships, Cloud will provision managed Postgres, Redis, R2, Resend, etc. on your behalf. You'll also be able to BYO (point at your own Neon, Upstash, etc. via env vars) — billing for those goes through your account with the provider. Today you provision these yourself and self-host.

See the [Cloud Premium Matrix](https://github.com/voltro-cloud/cloud-public/blob/main/PREMIUM.md) for the full feature breakdown.

## Migration from self-hosted to Cloud

*Deploying onto Cloud is coming soon — not yet available.* You can prepare a project today (scaffold the config, populate env), but there is no managed destination to deploy to yet.

```bash
voltro cloud import   # scaffold voltro.cloud.toml from the app's app.config.ts
```

`voltro cloud import` reads the app's `app.config.ts` and writes a `voltro.cloud.toml` (project name + type + a deploy/env skeleton). Populate the project env with `voltro cloud env set`. When managed hosting ships, your local Postgres data won't be migrated for you — you'll use `pg_dump` + `pg_restore` against the target database directly.



---

<!-- source: en/deployment/self-hosting.md -->
## Self-hosting

_Run Voltro on your own infra — Docker compose, env vars, reverse proxy, scaling._

The runtime that powers Voltro Cloud is the same runtime you run yourself with `voltro start` — one codebase, one license. Self-hosting is fully supported — no separate "lite" runtime, no feature gates around the core API.

## The shape of a self-hosted deploy

You need:

1. **Node.js 24+** to run the api + web processes.
2. **Postgres 16+** with `pgvector` extension (for AI / search) and `wal_level=logical` (for subscriptions). Managed Postgres at Neon, Supabase, RDS, or your own server all work.
3. **A reverse proxy** (Caddy / nginx / Cloudflare). Terminates TLS, routes paths, fronts the framework's processes.
4. **(Optional) Redis** for cross-instance subscription fan-out when you scale beyond one api process.
5. **(Optional) Object storage** (R2, S3, GCS) — required if you use `@voltro/plugin-storage`.

Voltro doesn't require Kubernetes or any orchestrator. A single Docker host + Caddy is enough for ≥99% of deployments.

## The supported path: `voltro baseline`

You don't have to hand-write any of the deploy plumbing below — the CLI ships baselines that generate it. Pick one at project creation or switch later:

```bash
voltro create-project acme --api=api-backend --web=frontend-blank --baseline=compose
# or, on an existing project:
voltro baseline set compose       # generates docker/{api,web}.Dockerfile + docker-compose*.yml
voltro baseline set helm          # generates charts/voltro-app/ for Kubernetes
voltro baseline status            # which baseline is active
```

- `bare` — `.env.example` + a sample systemd unit. You bring your own Postgres + deploy.
- `compose` — `docker/{api,web}.Dockerfile`, `docker-compose.yml` (postgres infra), `docker-compose.dev.yml` (HMR), `docker-compose.prod.yml` (built images).
- `helm` — a `charts/voltro-app/` chart with per-env values + Deployment / Service / Ingress / Postgres StatefulSet.

The hand-rolled compose + Caddyfile below are a reference for *what `voltro baseline set compose` generates* — read them to understand the shape, but prefer the generated files.

## Docker compose

```yaml
# docker-compose.yml
services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: voltro
    command: postgres -c wal_level=logical -c max_replication_slots=10
    volumes:
      - pg-data:/var/lib/postgresql/data

  api:
    build: ./apps/api
    environment:
      DB_DIALECT: postgres
      DB_URL: postgres://postgres:change-me@postgres:5432/voltro
      VOLTRO_SESSION_SECRET: ${VOLTRO_SESSION_SECRET}
      AI_PROVIDER: anthropic
      AI_API_KEY: ${ANTHROPIC_API_KEY}
    depends_on: [postgres]

  web:
    build: ./apps/web
    environment:
      PORT: 5173
    depends_on: [api]

  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile

volumes:
  pg-data:
```

## Caddyfile

```caddyfile
your-product.com {
  # The api serves the rpc WebSocket upgrade at /ws, the one-shot HTTP rpc
  # at /rpc, and introspection under /_voltro/*. Everything else is the web app.
  reverse_proxy /ws         api:4000
  reverse_proxy /rpc        api:4000
  reverse_proxy /_voltro/*  api:4000
  reverse_proxy *           web:5173
}
```

Single-origin deployment — no CORS, `SameSite=strict` cookies, no `Domain` attribute needed. The cleanest production layout. (Adjust the paths if your app mounts the rpc transport on a custom path — see below.)

### Custom WebSocket path — `transport.wsPath`

By default the browser connects the realtime rpc WebSocket to a same-origin path derived per api (`/ws/<name>`), and the api serves it at `/ws`. When your edge only allows WebSocket **upgrades** on a *specific* path (a WAF / CDN rule), or you must match a path a **previous stack** used, set `transport.wsPath` on the **api** app. The api then serves the socket there AND the web client **auto-derives the same path** — declare it once, no web-side config, no drift:

```ts
// apps/api/app.config.ts
export default {
  type: 'api' as const,
  name: 'api',
  store: 'postgres' as const,
  // Serve the rpc WebSocket on a fixed absolute path. The web app that
  // consumes this api reads this value and connects here — nothing to set on
  // the web side.
  transport: { wsPath: '/api/sync' },
}
```

Then route that path to the api in your proxy — everything else is unchanged:

```caddyfile
your-product.com {
  reverse_proxy /api/sync   api:4000   # the custom WS path (served by the api)
  reverse_proxy /rpc        api:4000
  reverse_proxy /_voltro/*  api:4000
  reverse_proxy *           web:5173
}
```

The path is **relative** in the bundle, so the host resolves at runtime — **one built image works across every environment**; only the path is fixed. Keep the socket **same-origin** with the web app: the session cookie rides the WS upgrade, and a cross-origin socket wouldn't carry it (every subscription would run anonymous). For an api on a genuinely separate origin, use the api spec's absolute `url` on the web side plus a token-based `headers` resolver instead of a cookie. (One custom `wsPath` per origin — the framework fails the build if two same-origin apis would collide on it.)

### Split web/api deployment — the SSR api origin — `serverUrl`

When `web` and `api` deploy as **separate services** (e.g. different k8s namespaces, one ingress routing `/api*` → api), the **browser** reaches the api by its relative `wsPath` against the page origin — but a `renderMode:'ssr'` page's `ctx.query` runs in the **web pod**, which has no page origin and must reach the api by **internal DNS**. These are genuinely different endpoints, so `url` (which is *also* the browser's ws origin) can't express the internal one without shipping an unreachable host to the browser.

Set the server-only `serverUrl` (or the env override) to the api's internal origin:

```ts
// web/app.config.ts
apis: {
  app: {
    package: '@app/api',
    transport: { wsPath: '/api/sync' },        // browser → relative, same-origin
    serverUrl: process.env.VOLTRO_API_ORIGIN,  // web pod → internal DNS (SSR only)
  },
},
```

```bash
# or set it purely from ops, no app.config edit — per-api, name upper-cased:
VOLTRO_API_ORIGIN_APP=http://api.my-namespace.svc.cluster.local
# or a single shared origin for every api:
VOLTRO_API_ORIGIN=http://api.my-namespace.svc.cluster.local
```

`serverUrl` / `VOLTRO_API_ORIGIN*` is used **only** for the SSR `POST /rpc` and is **never** emitted into the browser bundle. Resolution order: env > `serverUrl` > (under `voltro dev` only) the dev proxy target > an external api's absolute `url`. Under `voltro start`, a package api with **none** of these set makes the SSR query **fail loud** — naming the api and the config to set — instead of dialing the dev `localhost` port and surfacing a cryptic `ECONNREFUSED` in a 500.

### `allowedHosts` — fronting the web app on a real domain

Vite 8's DNS-rebind guard returns HTTP **403** for any request whose `Host`
header isn't an IP or `localhost`. So a Voltro web app served through a
reverse proxy / ingress on a real domain (e.g. `app.dev.example.com`) 403s
in the browser until that host is allow-listed. Add the new optional field on
the **web** app's `app.config.ts`:

```ts
// apps/web/app.config.ts
export default {
  type: 'web' as const,
  name: 'web',
  // string[] | true — default: unset = Vite default (only IPs + localhost).
  allowedHosts: ['app.dev.example.com'],   // a leading-dot entry like
                                           // '.example.com' matches all subdomains
  // allowedHosts: true,                   // disable the check entirely —
                                           // only behind a trusted proxy, since
                                           // it removes the rebind protection
}
```

This applies to the dev / Vite-served surface — a production `voltro start`
SSR/SPA host fronted by the proxy. If you see a 403 in the browser but the
proxy logs show the request reaching the app, this is almost always the
cause.

## Env vars

| Var | Required | Notes |
|---|---|---|
| `DB_DIALECT` | for SQL stores | `postgres` (default) / `mysql` / `mariadb` / `mssql` / `sqlite` / `turso` |
| `DB_URL` | for SQL stores | Database connection string (falls back to `DB_PRIMARY_URL`; or the discrete `DB_*` / `PG_*` fields) |
| `VOLTRO_SESSION_SECRET` | when using `@voltro/plugin-auth` | 32+ random bytes from a real CSPRNG — the session-cookie signing key (`voltro secret generate session`) |
| `VOLTRO_DATA_TRANSFER_SECRET` | to enable `--target api` export/import | Gates `POST /_voltro/admin/{export,import}`; ≥16 chars or the routes stay unmounted (`voltro secret generate data-transfer`) |
| `VOLTRO_BUNDLE_KEY` | for encrypted `.vbundle` exports | Passphrase for at-rest bundle encryption — a DEDICATED key, not the transfer secret (`voltro secret generate bundle-key`) |
| `VOLTRO_FIELD_ENCRYPTION_KEY` | with `governancePlugin({ fieldEncryption: true })` | Key for `.encrypted()` columns; boot fails loudly if missing while such columns exist (`voltro secret generate field-encryption`) |
| `VOLTRO_STORAGE_SECRET` | optional (`@voltro/plugin-storage`) | Signs private-file grant tokens; falls back to the session secret if unset |
| `AI_PROVIDER` | when using `@voltro/ai` | `anthropic` / `openai` / `mock` |
| `AI_API_KEY` | with `AI_PROVIDER` | Provider's API key |
| `SSR_CACHE` | optional | `memory` (default) or `postgres` for the ISR cache. `postgres` needs a database in the WEB process's env (`DB_URL` / `DB_HOST` / `PG_HOST`) — without one it aborts the boot in production rather than falling back to memory |
| `VOLTRO_API_ORIGIN` / `VOLTRO_API_ORIGIN_<NAME>` | split web/api deploy with SSR pages | The api's internal origin the WEB pod uses for SSR `ctx.query` (`http://api.<ns>.svc.cluster.local`). Per-api `_<NAME>` (name upper-cased) wins over the shared one and over `serverUrl`. Never sent to the browser |
| `PORT` | optional | App's listen port. Outranks `--port` and `app.config.ts` `port:` — a platform that assigns a port sets this one, so it has to win. Unset, the app binds its declared `port:`, then 4000 (api) / 5173 (web). |
| `VOLTRO_INSPECT` | optional | `off` to disable `_voltro/inspect/*` in prod |
| `VOLTRO_INSPECT_TOKEN` | recommended | Bearer token guard on inspect endpoints |
| `VOLTRO_DASHBOARD_APPS` | dashboard only | JSON `[{name?,url,token?}]` — target apps + inspect tokens the deployed DevTools dashboard shows (served at runtime from `/api/dashboard/config`) |

## Scaling beyond one instance

When one api process isn't enough:

1. Run multiple api containers behind the reverse proxy. The proxy's load-balancing default (round-robin) is fine for HTTP — for WebSocket, use sticky sessions (Caddy: `lb_policy ip_hash`).
2. Cross-instance subscription invalidation: on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC) this is built in — nothing to install. On any other dialect, or when you prefer a broker, add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) with a Redis or NATS provider to `app.config.ts.plugins` so every replica sees every change.
3. Workflows: `@effect/cluster` shards work across instances by workflow ID. No further config — every instance pulls from the shared workflow queue.

For multi-region: you need to run Postgres logical replication between regions yourself (or move to Voltro Cloud which handles it).

### Scaling is replicas, not a service split

Voltro deliberately ships no microservice transports — no `@MessagePattern`-style service-to-service RPC, no broker-backed internal messaging layer. Splitting one app into services would contradict the architecture thesis this whole page rests on: **one monolith process, scaled by running more replicas of it**, with `@effect/cluster` sharding durable work across instances by workflow ID. Every capability that a service split usually buys already has a first-class path: an external system boundary is [REST routes](/docs/data/rest-routes) + [OpenAPI](/docs/plugins/openapi) (`@voltro/plugin-openapi`), and a reliable outbound side effect is the [transactional outbox](/docs/data/outbox). If you find yourself wanting an internal message bus between "services", the answer is more replicas of the same image — not a second process shape.

## Backups

Postgres is the source of truth. Use your provider's backup features (Neon PITR, RDS snapshots, `pg_dump` on a cron). Object-storage assets back up via the provider's lifecycle policies.

## Deploying the DevTools dashboard

The DevTools dashboard (route sitemap, rpc list, logs / traces, schedules, the per-plugin inspect panels) can run as its **own deployment on its own URL**. It reaches each observed app's `/_voltro/inspect/*` through its own same-origin proxy, so the target apps only need to be reachable from the dashboard pod — not the browser.

- **Image:** the official `docker.io/voltro/dashboard` — a web-only Voltro app served by `voltro start`.
- **Targets at runtime:** set `VOLTRO_DASHBOARD_APPS` to a JSON array of `{ name?, url, token? }`, where `url` is each app's origin and `token` is that app's `VOLTRO_INSPECT_TOKEN`. The dashboard serves it from `/api/dashboard/config` on boot, so **one image serves any environment** and the tokens stay server-side (never baked into the bundle):

  ```
  VOLTRO_DASHBOARD_APPS='[{"name":"prod","url":"https://app.example.com","token":"…"}]'
  ```

- **Gate it:** the dashboard is an ops surface — put it behind an IP allow-list and/or your SSO proxy. Each target app's inspect surface should itself be token-gated (`VOLTRO_INSPECT_TOKEN`); the dashboard forwards the configured token as `Authorization: Bearer`.

## Production checklist

- [ ] HTTPS terminates at the edge with HSTS preload
- [ ] `VOLTRO_SESSION_SECRET` rotated from CSPRNG, stored in a secrets manager (not env file in git!)
- [ ] `cookieSecure: true` in the `@voltro/plugin-auth` config
- [ ] Postgres `wal_level=logical`, `max_replication_slots≥10`
- [ ] `pgvector` extension if you use AI features
- [ ] OpenTelemetry exporter pointed at your sink (Datadog / Honeycomb / Grafana / Loki)
- [ ] `VOLTRO_INSPECT_TOKEN` set, or `VOLTRO_INSPECT=off` in production
- [ ] CORS allow-list is exact origins (no `*` with credentials)
- [ ] Database backups verified (a backup you've never restored isn't a backup)
- [ ] At least one staging deploy that mirrors production exactly

See `@voltro/plugin-auth/COOKIES.md` for the full cookie audit, and the [Why Voltro?](/docs/intro/why-voltro) page for what we won't build (and what we will).



---

<!-- source: en/deployment/scale-to-zero.md -->
## Scale to zero

_Dormancy — stop an idle app and bring it back on a request or a due cron/workflow, with no Kubernetes._

Dormancy lets an idle app **scale to zero** — the app process stops while nothing is happening and comes back automatically on the next request, or when a scheduled job or a sleeping workflow is due. Idle apps cost almost nothing.

It is the same runtime as always; an always-on **wake-orchestrator** sits in front and manages the app's lifecycle. Nothing about your queries, mutations, routes, schedules, or workflows changes.

## Turn it on

Two pieces: opt the app into sleep mode, then run the orchestrator in front of it.

```typescript
// apps/api/app.config.ts
export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  dormancy: 'sleep',   // 'always-on' (default) | 'sleep'
}
```

In `dormancy: 'sleep'`, self-scheduled crons and durable-workflow waits register a row in the `_voltro_wakeups` table instead of holding an in-process timer — so the orchestrator knows exactly when the app must be awake.

```bash
# Single-node, no Kubernetes — requires a SQL store (postgres/mysql/mariadb/mssql)
voltro dormancy
voltro dormancy --port 4000 --app-port 4001 --idle-grace-ms 60000
```

`voltro dormancy` owns the public port, spawns `voltro serve` on an internal port, and proxies to it. It **wakes** the app on the first request / WebSocket upgrade or when a `_voltro_wakeups` row falls due, and **stops** it once it has been idle — no clients, no in-flight requests, no actively-running workflow, no imminent wakeup — for the grace window.

A reconnecting reactive client transparently wakes the app: the WebSocket upgrade brings it up, then the client re-subscribes from its cursor.

> The orchestrator and the app share the app's database (the `_voltro_wakeups` table), so dormancy needs a **SQL store** — an in-process `memory` store can't be shared across the two processes.

## What keeps an app awake

The app is only stopped when, continuously for the grace window:

- no WebSocket/reactive clients are connected,
- no HTTP request is in flight,
- no workflow is actively **executing** — a *suspended/sleeping* workflow is durable in the database and resumes on wake, so it does **not** keep the app awake,
- no wakeup is due within the lookahead horizon.

A long-sleeping workflow (`sleep('3 days')`) is exactly the case dormancy is built for: the app stops, and the orchestrator brings a fresh process up when the sleep is due — the workflow resumes from its journal.

## Diagnosing cold-start latency

The flip side of scale-to-zero is a cold start on the first hit after an idle period. Every `voltro serve` boot logs how long it took to become ready:

```text
serve: ready in 1910ms
```

To see WHERE those milliseconds go, set `VOLTRO_BOOT_TIMING=1` — the same line gains a per-phase breakdown:

```text
serve: ready in 1910ms  { bootMs: 1910, phases: { modules: 1872, config: 0, discover: 18, store: 9, plugins: 1, workflow: 1, ready: 9 } }
```

The phases, in boot order:

| Phase | What it covers |
| --- | --- |
| `modules` | node init + loading and compiling the JS module graph (the framework + your app). On a scale-to-zero container this is almost always the dominant phase — it is the cost of evaluating the dependency graph on a cold process, and it scales inversely with your CPU allotment. **Precompiling the app (`voltro build`) collapses this phase** — see [Precompiled boot](#precompiled-boot) below. |
| `config` | loading `app.config.ts` + the typed-env gate |
| `discover` | walking the app + loading discovered primitives (plus the precompiled bundle, when `voltro build` produced one) |
| `store` | opening the data store + cache/kv facades — a real SQL dialect includes the connection handshake here |
| `plugins` | plugin bind + lifecycle activation |
| `workflow` | the durable-workflow engine |
| `ready` | serveApi's own setup — handler layers, subscribers, reactions, aggregates, the HTTP listener |

Read this from the container's own logs at its real CPU allotment, not a beefy dev machine: a `modules` phase that dwarfs everything else means the cold start is dominated by evaluating the dependency graph (raise the container's CPU, or keep one instance warm); a fat `store` phase points at the database connection instead. `VOLTRO_BOOT_TIMING` adds a handful of `performance.now()` calls and one log line — it is safe to leave on in production.

A **web app** (`voltro start`) reports its own phases under the same flag, on its ready line:

```text
boot phases (ready in 640ms)  { bootMs: 640, phases: { modules: 590, config: 6, scan: 12, provider: 22, routes: 4, cdc: 0, ready: 6 } }
```

| Phase | What it covers |
| --- | --- |
| `modules` | node init + module-graph load/compile — the dominant cold-start phase, same as the api. Note the web start path has **no serve-bundle equivalent** yet, so `voltro build` does not collapse this phase for a web app the way it does for `voltro serve`; it is the framework graph evaluated unbundled on every cold process |
| `config` | `app.config.ts` + the typed-env gate |
| `scan` | reading the built shell, resolving the api origin, the ISR cache backend, and walking `src/pages` |
| `provider` | importing the precompiled SSR bundle (`dist/server/ssrEntry.js`). Stays small because page modules load lazily — a fat `provider` phase means the SSR bundle regressed to eager imports |
| `routes` | building per-route metadata. Cheap from the build-time `pageMeta` manifest; fat only when it fell back to importing every page module |
| `cdc` | postgres `LISTEN` wiring for ISR cache invalidation (zero without it) |
| `ready` | the HTTP listener flip |

**What boot timing cannot see:** `performance.now()` starts at *process* start, so everything before node runs — the container's scale-from-zero scheduling and image pull — is invisible to it. If a request that took many seconds end-to-end shows a `ready in` well under a second, the tail is infrastructure (image size, cold scheduling), not framework boot — split it with your platform's own request/container timestamps, not with more marks.

## Precompiled boot

`voltro build` precompiles the whole serve path — the framework, Effect, and your app — into a single **serve bundle**, and `voltro serve` boots from it directly. Instead of resolving and compiling the full module graph on every cold process, the boot loads one prebuilt file with your app modules as lazy chunks. This collapses the `modules` phase — `serve: ready` drops from ~1000 ms to ~180 ms; the win is larger on a scale-to-zero container with a cold filesystem, where per-module resolution costs the most. It applies whether your app uses a SQL driver or the memory store: your declared driver (e.g. `@voltro/sql-postgres`) is inlined into the bundle, and only its native binding (`pg`) stays external.

```bash
voltro build ./apps/api    # produces .framework/dist-api/serveBundle/
voltro serve ./apps/api    # boots from the bundle automatically
```

Building an API app produces the bundle, and `voltro serve` boots from it. In **production** (`NODE_ENV=production`) the bundle is **required** — you run `voltro build` before `voltro serve`, and a bundle-build failure is fatal: production **never transpiles on demand**, so it fails loud rather than silently falling back to the slow tsx path. (`voltro dev` and a non-production local `voltro serve` still fall back to tsx as a convenience.) The generated Dockerfiles already do this: `voltro build` at build time, `voltro serve` at start.

Because production never transpiles, the serve image needs none of the build toolchain. The framework declares `tsx`, `esbuild`, `vite`, and Tailwind as **optional** dependencies of `@voltro/cli`, and the production Dockerfiles isolate the app with `pnpm --prod --no-optional deploy` — which drops that whole tree (and its native binaries) from the image. A serve image ships only what it runs at runtime: your app, the framework, and the one SQL driver you declared.

And it shrinks further, the same way a web image does. The serve bundle is directly executable, so the production API container runs it with `node .framework/dist-api/serveBundle/serveEntry.js` — not `pnpm voltro serve` — with no pnpm process and no `@voltro/cli` bin to resolve at boot. That lets `voltro prune-runtime` drop `@voltro/cli` and the whole inlined framework tree from the runtime image too; what stays is the native SQL driver your app actually declared (traced + kept automatically) — a memory-store api's `node_modules` fell from ~146 MB to ~11 MB in a fixture. The serve entry chdir's to the app root before its app-module registry keys are computed, so the pruned, relocated tree still resolves every module.

**Web apps get the same treatment.** `voltro build` also precompiles the `voltro start` runtime into a **start bundle** (`.framework/dist-web/startBundle/startEntry.js`, framework inlined) — so a cold web boot loads one artefact instead of resolving the whole framework graph. This collapses the web `modules` phase the same way the serve bundle does (~17× on a small app in practice), which matters most exactly where it hurts: a scale-from-zero container on a fraction of a vCPU. In production the container runs that bundle **directly** — `node .framework/dist-web/startBundle/startEntry.js`, not `pnpm voltro start` — so there is no pnpm process and no `@voltro/cli` bin to resolve at boot; the bundle is a self-contained, relocation-safe entrypoint (its main-guard chdir's to the app root so cwd-based resolution holds anywhere). In development `voltro start` imports the same bundle; if it ever fails to build or load, that path falls back to the ordinary per-module boot — slower, never broken.

**And the image itself shrinks — to almost nothing.** Because the SSR bundle, the start bundle, and the precompiled config all inline + tree-shake the framework, *and the production entrypoint is the bundle itself rather than the CLI*, a booted web app needs from `node_modules` only the runtime-external **native** leaves it actually reaches (a SQL driver an ISR or config path touches) — everything else is already compiled into the bundles. `voltro prune-runtime` traces the real reachable set from that entrypoint (`@vercel/nft`, the same tool behind Next.js `output: standalone`) and drops the rest — including `@voltro/cli` and the whole inlined effect/React tree, which nothing at runtime imports any more. It's automatic — a native driver you use is traced and kept, one you don't is dropped, no per-app allow-list — and for a static/SSR marketing site with no native runtime dependency, `node_modules` collapses to **zero** (measured on a fixture: 151 MB → ~0 B): the image is just the node base plus `.framework`. A build-time **boot smoke** then starts the *pruned* tree with the real `node …/startEntry.js` entrypoint and fails the build unless it reaches `start: ready`, so a slimmed image that can't boot never ships. A smaller image is a faster cold pull on a fresh node — the other half of the scale-from-zero latency the boot bundle can't touch.

## Tiers (Voltro Cloud — coming soon)

Managed cloud hosting is not yet available; the Pro / Enterprise rows below are the planned managed tiers. Today you self-host and run `voltro dormancy` yourself.

| Tier | Behavior |
| --- | --- |
| Free / Self-Host (today) | aggressive sleep — `voltro dormancy`, honest cold-start on first hit per idle period; idle ≈ \$0 |
| Pro (coming soon) | keep-warm — sub-second wake via the managed warm-pool/snapshot adapter |
| Enterprise (coming soon) | always-on / dedicated — never scales down |

On Voltro Cloud (when it ships) the wake adapter and grace values will be chosen for you per tier; on self-host you run `voltro dormancy` and tune the knobs yourself. The runtime is identical in every case.



---

<!-- source: en/deployment/serverless-functions.md -->
## Serverless functions

_Run isolated, independently-scaled work as *.serverless.ts functions — self-hosted (Node) or offloaded to Cloudflare Workers / Scaleway._

A **`*.serverless.ts`** function is a self-contained unit run + deployed SEPARATELY
from your api. It's written ONCE, Effect-first and schema-typed; the framework runs
it on your own infra (`node`) or adapts it to an edge host's entrypoint
(`fetch(request, env, ctx)` on Cloudflare Workers, `handle(event)` on Scaleway) —
you never write platform boilerplate.

## First: do you actually need one?

For an always-on api, **reach for [`defineAction`](/docs/data/actions) first.** An
action runs in-process and shares the rpc schema, session/tenant resolution, the
DB handle, typed errors, CDC, tracing, and the WebSocket transport — all of which a
serverless function THROWS AWAY (it gets only `HttpClient` + `ctx.env`: no DB, no
session, no tracing). A serverless function is the right tool only when you
specifically want one of:

- **process isolation** — a heavy/risky dependency (native module, fat ML lib,
  untrusted code) you don't want in the api's memory or crash domain;
- **independent scaling** — a spiky endpoint scaled (or scaled-to-zero) separately
  from the always-on api;
- **independent release cadence** — deploy/restart that one unit without bouncing
  the api and its live WebSocket connections.

If none of those apply, write an action. The rest of this page assumes one does.

## Writing a function

```ts
// api/functions/resizeImage.serverless.ts
import { Effect, Schema } from 'effect'
import { HttpClient } from '@effect/platform'
import { defineServerless } from '@voltro/serverless'

export default defineServerless({
  name: 'resize-image',                        // kebab-case, DNS-safe — the deployed id
  method: 'POST',                              // default POST; GET reads input from the query string
  input:  Schema.Struct({ url: Schema.String, width: Schema.Number }),
  output: Schema.Struct({ resized: Schema.String }),
  runtime: { memoryMb: 512, timeoutSeconds: 30, region: 'fr-par' }, // best-effort host hints
  handler: ({ url, width }, ctx) =>
    Effect.gen(function* () {
      const http = yield* HttpClient.HttpClient    // provided by the framework base layer
      // ctx.env.SOME_SECRET  → the host's env / secret bindings
      // ctx.waitUntil(p)     → background work that outlives the response (Cloudflare)
      return { resized: `${url}?w=${width}` }
    }),
})
```

- **One default export per file** — a `defineServerless({ … })`.
- `input` decodes the request (JSON body, or the query string for a `GET`).
  `output` encodes the JSON response. A malformed input returns **400**.
- The handler is Effect-first and may `yield* HttpClient.HttpClient` — the **only**
  service the base layer provides. A serverless function is standalone: it has no
  app database, plugins, or session. If your work needs those, it belongs in the
  api as a mutation/action, not here.
- To control the HTTP status, fail with `ServerlessHttpError({ status, message })`;
  any other failure becomes a **500**.

> **Start from a template.** The [`edge-functions`](/docs/reference/templates)
> template ships eight runnable `*.serverless.ts` examples across the common
> shapes (pure compute, geo from request headers, outbound HTTP, Web Crypto HMAC,
> an LLM call, status-controlled errors). For the static-site-plus-form combo,
> [`frontend-contact`](/docs/reference/templates) wires a static page's island
> form to a serverless email function. `voltro add-app fns --template
> edge-functions` drops the library into any project.

## Develop locally

Run one function on a real http port — same decode → run → encode the deployed
function uses:

```bash
voltro serverless dev api/functions/resizeImage.serverless.ts --port 8910
# POST http://localhost:8910/  with a JSON body matching `input`
```

Environment comes from `process.env` (load a `.env` with `node --env-file` if you
like). `voltro serverless list` shows every discovered function.

**CORS works locally.** `voltro serverless dev` / `serve` send
`Access-Control-Allow-Origin` and answer the OPTIONS preflight — the same headers
Cloudflare / Scaleway add at the edge. So a browser form on a static dev site
(`localhost:5190`) can POST to the function (`localhost:8910`) cross-origin out of
the box. The default is permissive (`*`); lock it down via the `cors` option on
`serveServerless` when you self-host.

## Self-hosted (default) — run it on your own infra

The default target is **`node`** — no vendor, no cloud. Two shapes:

```bash
# Run ALL functions in ONE process — the cheap "one sidecar" case.
# Drop it behind your baseline's reverse proxy (Caddy/nginx) next to the api.
voltro serverless serve --port 8910 --host 0.0.0.0
#   ▸ each function mounts at its `path` (or /<name> when several share /)
#   ▸ GET /internal/liveness + /internal/readiness probes, like `voltro start`

# OR build a standalone per-function kit (process isolation / independent scaling):
voltro serverless build  --target node      # → index.mjs + Dockerfile + README (a complete kit)
#   then: node index.mjs   (PORT from env)   — or `docker build` the emitted Dockerfile
```

`build --target node` produces a COMPLETE, self-hostable folder: the bundled
`index.mjs` server **plus a `Dockerfile` and a `README`** with the exact run /
docker / compose / reverse-proxy commands — drop it on any box with Node, or any
container platform. (`deploy --target node` is the same, with the run plan
printed.)

`serve` is the everyday self-hosted runner; `build --target node` is the
per-function kit you scale on its own. Either way the function runs the SAME
`serverlessWebHandler` it would on the edge.

## Edge offload (optional) — Cloudflare / Scaleway

To save money on spiky or globally-distributed work, push a function to an edge
host instead. The framework owns the bundle (esbuild) and the platform entry; the
host's official CLI does only the upload, so those CLIs must be on PATH +
authenticated.

```bash
# Cloudflare Workers — needs `wrangler` + CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
voltro serverless deploy --target cloudflare
voltro serverless deploy --target cloudflare --node-compat   # add nodejs_compat for node:* builtins

# Scaleway Functions — needs `scw` + SCW_ACCESS_KEY + SCW_SECRET_KEY + a namespace
voltro serverless deploy --target scaleway --namespace-id <ns-id> --runtime node22

# Inspect the plan without uploading:
voltro serverless deploy --target cloudflare --dry-run
```

Build output lands in `.voltro/serverless/<target>/<name>/` — add `.voltro/` to
your `.gitignore`.

## How the three targets differ

| Aspect | Node (self-hosted) | Cloudflare Workers | Scaleway Functions |
|---|---|---|---|
| Where it runs | your own infra | V8 isolate (web-standard, **not** Node) | Scaleway's Node.js (node20 / node22) |
| `node:*` builtins | native | only with `--node-compat` | native |
| Env / secrets | `process.env` | the `env` binding | `process.env` (`scw` args) |
| `ctx.waitUntil` | best-effort | real (after the response) | best-effort |
| Scaling | you (compose replicas / helm) | isolate, near-zero cold start | scale-to-zero, real cold starts (`min-scale ≥ 1` keeps warm) |
| Runs the whole set | `serve` (one process) | one Worker per function | one function per namespace entry |

Your handler code is identical across all three — only the target changes.

## Notes

- **Effect on the edge:** the framework pins `effect ≥ 3.20.0`, which carries the
  fix for cross-request context isolation under concurrency. Don't pin an older
  `effect` for a function you deploy to Workers.
- **Browser/edge safety:** a Worker bundle must not pull `node:*` unless you pass
  `--node-compat`. Keep function dependencies edge-safe, or deploy to Scaleway.
- This is distinct from [scale-to-zero](/docs/deployment/scale-to-zero), which
  idles your WHOLE api on a single node. Serverless functions split INDIVIDUAL
  units off to a per-invocation host.



---

<!-- source: en/deployment/static-sites.md -->
## Static-site deploy

_Ship the static dist/ from `voltro build` to a cheap CDN (Cloudflare Pages, any S3-compatible bucket, Netlify) instead of serving it off the app server._

`voltro build` emits a static `dist/` — pre-rendered HTML plus content-hashed
assets. Serving that off your app server wastes the server on work a CDN does for
pennies. `voltro static` ships `dist/` to a cheap external host, off the app
server, so you save money at the edge.

```bash
voltro static hosts                                          # list supported hosts
voltro static deploy --host cloudflare-pages --project-name my-site --spa
voltro static deploy --host s3 --bucket my-bucket --endpoint https://s3.fr-par.scw.cloud
voltro static deploy --host netlify --site-id <site-id>
voltro static deploy --host cloudflare-pages --project-name my-site --dry-run
```

The framework owns the policy that's easy to get wrong; the host's official tool
does the transfer (so it must be on PATH and authenticated).

> **Static templates to start from:** [`frontend-static-blog`](/docs/reference/templates)
> (SSG from a content source via `getStaticPaths` + islands),
> [`frontend-landing`](/docs/reference/templates) (zero-JS marketing),
> [`frontend-spa`](/docs/reference/templates) (client-rendered, `--spa`), and
> [`frontend-contact`](/docs/reference/templates) (a static page + a serverless
> form — the page deploys here, the function via `voltro serverless`).

## Self-hosting already serves `dist/`

You don't NEED `voltro static`. When you [self-host](/docs/deployment/self-hosting),
`voltro start` serves `dist/` end-to-end (static HTML + SSR/ISR on demand), and
your baseline's reverse proxy (Caddy/nginx) can serve the files directly too. So
`voltro static` is a pure **cost-offload**: push the immutable assets to a cheap
CDN while your api + Postgres stay on your own box.

The split works because the browser reaches the self-hosted api over its own URL.
Point the web app's api config at the api's public WebSocket endpoint:

```ts
// apps/web/app.config.ts
apis: { app: { package: '@app/api', url: 'wss://api.yourdomain.com/ws' } }
```

The api stays self-hosted (it must — WebSocket subscriptions + Postgres are
always-on); only the static bytes move to the edge. `POST /rpc` forwards the
session cookie exactly as the WS path, so SSR loaders + auth resolve identically.

## What the framework decides for you

- **Content-Type** per file, from its extension (including the ones generic tools
  get wrong: `.wasm`, `.woff2`, `.svg`, `.webmanifest`, `.avif`).
- **Cache-Control:** content-hashed assets (`assets/index-a1b2c3d4.js`) →
  `public, max-age=31536000, immutable`; HTML → revalidate on every request, so a
  redeploy is visible immediately.
- **SPA fallback** (`--spa`): serve `index.html` for unknown routes — for a
  single-page app. Leave it off for per-route SSG output.

For hosts that read them (Cloudflare Pages, Netlify) this is emitted as
`_headers` / `_redirects`. **If you already ship your own `_headers` or
`_redirects` in `dist/`, the framework leaves them untouched** — your intent wins.

## The render-mode gate

A pure static CDN can only serve pre-rendered HTML. So before it uploads,
`voltro static deploy` checks the app's render-mode profile and **blocks** if the
app isn't static-safe:

- **`ssr` / `isr` pages** — these need a runtime to render per request; a CDN
  can't run them.
- **dynamic routes without `getStaticPaths`** — the build emits no artifact for
  them, so they'd be 404 on a CDN.

```bash
voltro static deploy --host cloudflare-pages --project-name my-site
# → voltro static deploy: "." is NOT pure-static —
#     • 1 ssr page(s): need a runtime; a CDN can't render them
#     → Serve those on a runtime (`voltro start` / a container), OR add
#       getStaticPaths / set the pages to `static` / `spa`. To ship anyway
#       (ssr/isr become client-rendered), pass --allow-dynamic.
```

Three ways forward:

1. **Keep it static** — give dynamic routes a `getStaticPaths`, set pages to
   `static` / `spa`. Then the deploy passes.
2. **Ship anyway** — `--allow-dynamic`. The ssr/isr pages become client-rendered
   (no server first-paint); fine if they degrade gracefully.
3. **Use a runtime** — if the app genuinely needs SSR/ISR, it belongs on `voltro
   start` / a container, not a pure CDN. See
   [self-hosting](/docs/deployment/self-hosting). `voltro deploy plan` tells you,
   per app, which tier it belongs to.

`voltro build` records the profile to `dist/.voltro-build.json`, so the gate (and
the cloud control-plane) classify without re-scanning. Point the scan at a
non-default app root with `--app <dir>`.

## Hosts

| Host | `--host` | Mechanism | Auth |
|---|---|---|---|
| Cloudflare Pages | `cloudflare-pages` | `wrangler pages deploy` (built-in hash dedup) | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` |
| S3-compatible | `s3` | `aws s3 sync` (two-pass cache) | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` |
| Netlify | `netlify` | `netlify deploy` (SHA1 digest deploy) | `NETLIFY_AUTH_TOKEN` |

### Cloudflare Pages

```bash
# one-time: wrangler pages project create my-site
voltro static deploy --host cloudflare-pages --project-name my-site --spa
```

The project must exist first. Uploads are incremental (wrangler dedups by hash);
new deploys auto-invalidate the edge.

### S3-compatible (AWS, Scaleway, R2, B2, MinIO)

One code path for every S3-compatible bucket — only the endpoint and keys change.
Omit `--endpoint` for AWS; set it for the others.

```bash
# Scaleway Object Storage (has native static-website hosting)
AWS_ACCESS_KEY_ID=<scw-access> AWS_SECRET_ACCESS_KEY=<scw-secret> \
  voltro static deploy --host s3 --bucket my-bucket \
    --endpoint https://s3.fr-par.scw.cloud \
    --website-url https://my-bucket.s3-website.fr-par.scw.cloud
```

The sync runs two passes: immutable hashed assets first, then HTML with
`no-cache`, and `--delete` prunes removed files. SPA fallback for a raw bucket is
bucket-website / CDN configuration (and R2/B2/MinIO need a router in front
entirely) — `--spa` warns rather than silently doing nothing here.

### Netlify

```bash
voltro static deploy --host netlify --site-id <site-id> --spa
```

Netlify infers content types server-side and its digest-based deploy is
incremental and atomic.

## CI

All three host CLIs run headless with a token in the environment — drop the
deploy command into your pipeline after `voltro build`. Use `--dry-run` to print
the exact plan in a PR check without uploading.



---

<!-- source: en/deployment/production-hardening.md -->
## Production hardening

_The production checklist — session secret, health probes, request limits, tenant isolation, observability, graceful shutdown, and multi-replica config._

The defaults are tuned for `voltro dev`, where fast iteration wins. Before you point real traffic at `voltro serve`, walk this checklist — most items are a single env var or a one-line config, but skipping them is how a service leaks across tenants, forges sessions, or falls over under load.

## 1. Session secret (REQUIRED)

In production (`NODE_ENV=production`) `voltro serve` **refuses to boot** unless `VOLTRO_SESSION_SECRET` is set to a real value. It rejects three cases explicitly:

- a **missing** value,
- the built-in **public dev fallback** (it is committed in the framework source),
- any value **shorter than 32 chars** — which catches placeholders like `changeme`.

Generate a real one:

```sh
voltro secret generate session
```

Store it in your secrets manager and inject it as an env var — never commit it. Why the hard fail: a forgotten secret would otherwise sign session cookies with a key that is **public in the framework source**, letting anyone forge any session.

**Rotation** is zero-downtime. Set the new value as `VOLTRO_SESSION_SECRET` and move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS`:

```sh
VOLTRO_SESSION_SECRET=<new>            # signs new cookies
VOLTRO_SESSION_SECRET_PREVIOUS=<old>   # still verifies live cookies
```

Keep `_PREVIOUS` in place for one session-TTL window, then drop it. Existing cookies verify against `previous` until they naturally expire — no live session is invalidated.

> **If you embed the framework's middleware yourself** rather than booting through `voltro serve`, there is no boot gate to catch a missing secret. In that case a presented `voltro:session` cookie that cannot be verified now logs, once per process and at error level, that no credential-expiry bound is being imposed — so realtime subscriptions on that connection will not be cut off when the session expires. It is a diagnostic, not a refusal: a stale `voltro:session` cookie from another app on the same host is a normal thing for a browser to carry, and failing the request would turn that into a denial of service.

## 2. Kubernetes health probes

`voltro serve` exposes two unauthenticated endpoints, both handled **before** any rate-limit interceptor:

- `GET /internal/liveness` — always `200 ok`. The process is up.
- `GET /internal/readiness` — `200 ready` only after full boot, `503` before.

Readiness **also** runs a DB ping (`SELECT 1`) on SQL stores. So a pod whose connection pool has died reports `503` and is pulled from the Service endpoints — instead of staying in rotation and erroring every request.

```yaml
# k8s Deployment — probes
livenessProbe:
  httpGet:
    path: /internal/liveness
    port: 4000
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /internal/readiness
    port: 4000
  periodSeconds: 5
  failureThreshold: 3
```

> **Run `voltro serve` in serving pods — not `voltro dev`.** `voltro dev` is the
> local-iteration supervisor: file-watch, respawn, codegen, and a boot-time
> auto-migrate that introspects the whole schema. It does **not** expose the
> probes above and binds its port only after that boot work finishes — so a TCP
> probe can't tell "still booting" from "dead", and a large-schema migrate can
> blow past a fixed startup window and get a healthy pod killed. Production pods
> run `voltro serve`.

## 3. Schema migration at deploy (don't migrate in the serving pod)

`voltro serve` does **not** auto-migrate — schema changes go through an explicit
deploy step, never on the serving pod's boot. The full contract (per-env
fingerprint check, refuse-to-boot on mismatch, apply timing relative to the image
swap) is [Prod pipeline](/docs/database/migrations/prod-pipeline); the k8s wiring
is here.

Run the apply as a **pre-deploy Job** (or `initContainer`) that holds the
migration credentials and executes `voltro db apply`. It re-diffs the deployed
code's declared schema against the live DB and applies the resulting plan — so
it's a clean no-op on an already-current DB, which makes it safe to re-run and to
run in every pod of a stateless deploy. A bare apply **refuses under
`NODE_ENV=production`** by design (auto-apply on prod is not allowed), so the Job
runs with `NODE_ENV` unset or `staging`; the serving pods keep
`NODE_ENV=production`:

```yaml
# Helm pre-install/pre-upgrade Job — runs to completion BEFORE the new pods roll.
apiVersion: batch/v1
kind: Job
metadata:
  name: myapp-migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-5"
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: myapp:{{ .Values.image.tag }}
          command: ["voltro", "db", "apply", "--note", "release {{ .Values.image.tag }}"]
          env:
            - { name: NODE_ENV, value: "staging" }   # bare apply is refused under "production"
            # …plus the same DB_* connection env as the serving pods
```

```yaml
# Serving Deployment — auto-migrate OFF; the Job already applied the schema.
env:
  - name: VOLTRO_AUTO_MIGRATE
    value: "0"
```

For the reviewed-exact-diff flow — generate `voltro db plan --json > plan.json`
in CI, apply it with `voltro db apply --plan plan.json` — see
[Prod pipeline](/docs/database/migrations/prod-pipeline). That variant IS allowed
under `NODE_ENV=production` (it refuses unless both fingerprints still match the
reviewed plan). A Job runs **once per release** vs an `initContainer`'s once per
replica, so it's the better fit for a multi-replica rollout.

### Expand/contract — the migration that's safe while old pods still serve

The pre-deploy Job applies the schema **before the new pods roll** — so during a
rolling update, old pods (old code) run against the already-migrated schema for
the length of the rollout. A migration that DROPS or RENAMES a column, NARROWS a
type, or ADDS a constraint breaks those old pods mid-rollout: they 500 reading a
column that's gone, or their writes are rejected by the new constraint. The
migration "succeeded" and the app served errors anyway.

`voltro db plan` flags these — the operations unsafe under a rolling deploy are
listed with a `⚠`, separately from the data-safety (lossy / blocked) gate, since
the two are orthogonal: a `dropped()` column is blessed for data loss and *still*
breaks an old reader.

```text
⚠ 1 operation(s) UNSAFE under a rolling deploy
  (old + new instances overlap → old code breaks against the new schema):
    • drop-column: old instances still SELECT/INSERT "orders"."legacy_total"; …
      → stop reading the column in code and deploy that first; drop it in a LATER deploy
```

Two ways to handle it:

1. **No overlap window** — a maintenance-window or **scale-to-zero** deploy (old
   pods gone before new ones start) has no simultaneous old code, so a single-step
   drop/rename is fine. The advisory doesn't apply; ignore it.
2. **Zero-downtime rollout** — split the breaking change into two releases, each
   of which keeps *both* code versions working (**expand/contract**):
   - **Expand** (release N): add the new shape — a nullable column, a new table,
     a backfill, dual-write from the new code. Old code ignores it.
   - **Cut over**: the new code reads/writes the new shape; deploy it.
   - **Contract** (release N+1): once no pod runs the old code, drop/rename/narrow
     the now-unused old shape. This step's `db plan` is clean.

Renaming `orders.total` → `orders.amount` under zero downtime is: add `amount`
(expand) → backfill + dual-write → cut reads over → drop `total` (contract) — three
releases, never one, so no in-flight pod ever references a column that isn't there.

**Make it a hard gate if you always rolling-deploy — `VOLTRO_ROLLING_DEPLOY=1`.**
The `db plan` ⚠ is advisory by default, because a maintenance-window / scale-to-zero
deploy has no overlap window and the framework can't tell which you run. If your
pipeline is *always* a rolling update, set `VOLTRO_ROLLING_DEPLOY=1` in the migration
Job's env: `voltro db apply` then **refuses** (exit 2) a plan containing a
rolling-unsafe operation instead of warning, so an un-split breaking change fails the
deploy rather than breaking pods at runtime. Override a specific apply with `--force`.
Unset (the default) leaves today's advisory behaviour untouched.

### If you DO run `voltro dev` in a cluster (dev / staging only)

`voltro dev` binds a small **boot-health surface** on its own port so a probe can
watch the slow boot (codegen + migrate) it otherwise couldn't see:

- `GET /internal/liveness` → `200 ok` from the moment the process is up, through
  the whole boot. Point `startupProbe` **and** `livenessProbe` here so migration
  time counts as *alive*, not dead.
- `GET /internal/readiness` → `503` until the app port is serving, then `200`.
- `GET /internal/startup` → `200` JSON `{ phase, ready, tablesTotal, elapsedMs }`
  (`phase` is `booting` → `migrating` → `ready`, or `error` with a message) — for
  humans and dashboards watching progress.

The port defaults to **app port + 1**; override with `VOLTRO_DEV_HEALTH_PORT`
(set `0` to disable). A generous `startupProbe.failureThreshold` on
`/internal/liveness` then gives a large-schema first migrate minutes instead of a
fixed TCP window:

```yaml
# dev/staging pod running `voltro dev` — probe the boot-health port (4001)
startupProbe:
  httpGet: { path: /internal/liveness, port: 4001 }
  periodSeconds: 10
  failureThreshold: 60        # up to 10 minutes for a first cold migrate
livenessProbe:
  httpGet: { path: /internal/liveness, port: 4001 }
readinessProbe:
  httpGet: { path: /internal/readiness, port: 4001 }
```

Even a local `voltro dev` boots faster on reboots now: the auto-migrate skips the
full introspect when the declared schema is unchanged (a fingerprint check — one
indexed query instead of scanning every table). Force a full re-introspect with
`VOLTRO_MIGRATE_FORCE=1`.

## 4. Request limits & DoS

`POST /rpc` (the buffered JSON endpoint the SSR loaders use) is capped, and the cap is enforced **as bytes arrive**:

```sh
VOLTRO_MAX_RPC_BODY_BYTES=8388608   # default 8 MiB
VOLTRO_MAX_BODY_BYTES=8388608       # default 8 MiB
```

A declared `Content-Length` over the cap is refused up front, so an honest client
gets its **413** without uploading anything — a courtesy, not the enforcement,
since a `Transfer-Encoding: chunked` body declares no length at all. The byte
counter is the enforcement: it stops accumulating the moment the running total
crosses the limit, drains the rest of the upload rather than dropping the
connection, and answers **413**. Both shapes therefore end in the same status
code, and the refusal is logged under the `voltro:security` scope with the cap
and the byte count at which the server stopped reading.

Scope: this guards the `/rpc` JSON path only. File uploads ride separate storage routes with their own `limits.maxBytes`, and WebSocket frames are capped by the `ws` library default (100 MiB).

**Put per-IP rate limiting and the primary body-size cap at the ingress** — that is the correct layer: it holds per-IP state and works across replicas, which an in-process limit can't.

```yaml
# nginx ingress — annotations on the Ingress resource
nginx.ingress.kubernetes.io/proxy-body-size: "8m"
nginx.ingress.kubernetes.io/limit-rps: "20"
```

For **app-level** throttling (per-subject / per-tenant, e.g. an expensive action), use `@voltro/plugin-ratelimit` and the plugin `onHttpRequest` interceptor seam. It complements the ingress cap — it does not replace it.

**There is no rate limit in the box.** The body cap above is the only request guard the runtime applies by default; `@voltro/plugin-ratelimit` is opt-in, so an app that has not installed and configured it has no per-IP, per-API-key or per-tenant cap on `/rpc` at all. The one on-by-default throttle anywhere in the framework is `plugin-auth`'s [brute-force lockout](/docs/authentication/passwords#brute-force-lockout), which covers sign-in credential attempts and nothing else. Treat the ingress limit as required, not as belt-and-braces.

### Per-IP limits need a trusted proxy

The address the runtime rate-limits, geo-blocks and audits by is
`socket.remoteAddress` — **not** `x-forwarded-for`, which any client can write.
Behind an ingress that means every request counts against the proxy's address —
and every `sessions.ipAddress` row records the proxy — until you declare the hop:

```ts
// app.config.ts
export default {
  security: {
    trustedProxies: ['private'],   // or: ['loopback'] · ['10.0.0.0/8'] · ['2'] · ['*']
  },
}
```

The same setting decides whether `x-forwarded-proto` is believed, which is what
lets the runtime emit HSTS behind a TLS-terminating load balancer. Override it
on a running deployment with `VOLTRO_TRUSTED_PROXIES=private` (comma-separated).

### Cross-site protection and its allowlist

Every state-changing request — `POST /rpc`, the `/ws` upgrade, every REST route
projected from a `publicApi:` mutation, everything in `apiConfig.restRoutes`,
and `POST /v1/api-keys` — refuses a browser request whose `Origin` is neither
the `Host` it was sent to nor an allowlisted origin. **A split web/api
deployment must declare its web origin** or the browser gets a 403 on every
mutation, REST write and socket:

```ts
// app.config.ts
export default {
  security: {
    allowedOrigins: ['https://app.example.com'],
  },
}
```

The environment override is `VOLTRO_ALLOWED_ORIGINS` (comma-separated).
Server-to-server callers (SSR loaders, mobile SDKs, other services) send no
`Origin` and are unaffected. Full detail in
[Security](/docs/security/overview#cross-site-requests-are-refused).

## 5. Multi-tenant isolation

Tables carrying the `tenant()` mixin are auto-scoped to the request's tenant — nothing more to do there. The gap is the **anonymous** request that matches no auth strategy: by default it resolves to a tenant-less anonymous Subject that can read any non-`tenant()` table across the DB.

For an app where **every** request must be tenant-scoped (no anonymous public data), close that door:

```ts
// app.config.ts
export default {
  // ...
  auth: {
    anonymousTenantRequired: true,
  },
}
```

A request that matches no auth strategy **and** sends no `x-tenant` header is then rejected with `Unauthenticated` instead of resolving to a tenant-less Subject. It applies identically under `voltro dev` and `voltro serve`.

> **Do NOT enable this if the app serves legitimate anonymous public data** — public read endpoints, reference tables — it would reject those callers. In that case, rely on putting `tenant()` on every private table instead.

## 6. Observability (wire it or fly blind)

By default nothing is exported. Wire it before you need it.

**Traces + metrics** ship to any OTLP collector (Tempo / Jaeger / Honeycomb / Grafana Agent) from one env var:

```sh
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_SERVICE_NAME=my-api
```

**Structured logs** as JSON:

```sh
VOLTRO_LOG_FORMAT=json
VOLTRO_LOG_LEVEL=info
```

**Error reporting** — add `sentryPlugin()` from `@voltro/plugin-sentry`. It stays inert until `SENTRY_DSN` is set, and reported errors correlate to the request `traceId`:

```ts
import { sentryPlugin } from '@voltro/plugin-sentry'

export default {
  // ...
  plugins: [sentryPlugin()],
}
```

Durable in-DB traces are **off in production by default** — traces belong in your OTLP backend, not your OLTP database.

## 7. Graceful shutdown

On `SIGTERM` / `SIGINT`, `voltro serve` shuts down cleanly (exit 0), in this
order: it stops accepting new connections and lets **in-flight requests finish
against a fully-alive app** (bounded — the request drain gets 60% of the
shutdown grace, so the teardown behind it always fits inside the deadline),
then stops schedulers, detaches subscribers / reactions / aggregates, drains
the analytics sink and mirror, ends any remaining WebSocket, and closes the
SQL connection pool **last** (waiting for in-flight transactions). Verified
against a real serve under signal: a request in flight when `SIGTERM` lands
completes with a full response before the process exits — no preStop hook, no
orchestrator. A bare `voltro serve` under docker compose drains itself.

The **transactional-outbox worker** is part of that sequence: its poll timer and
its change subscription are released, and a delivery already in flight is
awaited, before the pool closes. An outbox row that was still pending is not
lost — it is durable, and the next process's first pass picks it up, which is
one of the three reasons that poll exists.

**What an orchestrator still adds: routing.** The app finishes every request
it has *accepted* — but a request that *arrives* after `SIGTERM` is refused
(the listener closes immediately, on purpose), and only the layer that routes
traffic can stop sending it. Failing readiness from inside the process doesn't
help: the listener is already closed. Under k8s, close that window with a
**preStop hook**:

```yaml
spec:
  # Must exceed the preStop sleep + the app's own shutdown.
  terminationGracePeriodSeconds: 30
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            # k8s removes the pod from the Service endpoints AND runs this
            # BEFORE sending SIGTERM. The sleep holds the pod up (listener open,
            # finishing in-flight) while endpoint removal propagates — so no new
            # request lands on a pod that's about to close.
            command: ["sh", "-c", "sleep 5"]
```

Without the preStop hook, a rolling update refuses the small window of requests
that still route to a terminating pod before k8s finishes removing it from the
Service endpoints — refused with a connection error, not truncated mid-response
(everything already accepted completes either way). With it, that window is
served too. `terminationGracePeriodSeconds` must be larger than the sleep plus
the app's own teardown, or k8s SIGKILLs mid-drain. Environments with no
endpoint removal at all — docker compose above all — need nothing: there is no
routing layer to lag behind the shutdown, so the built-in drain is the whole
story.

**Bound the app's own teardown with `VOLTRO_SHUTDOWN_GRACE_MS`.** After
`SIGTERM`, the runtime runs its finalizers (pool close, plugin `onDeactivate`,
analytics flush, trace persist) and then exits — but installing the signal
handler removes node's default kill, so a finalizer that *never* completes (a
pool drain against a database that is already gone, a wedged `onDeactivate`)
would otherwise hang the process forever. A hard deadline caps that: teardown
gets until the deadline, then the process exits regardless. It defaults to
**10s**; set `VOLTRO_SHUTDOWN_GRACE_MS` (clamped to 1s–5min) to sit JUST UNDER
your `terminationGracePeriodSeconds` minus the preStop sleep — so the app drains
and exits *cleanly on its own* before k8s SIGKILLs it mid-drain:

```yaml
spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: api
      env:
        # preStop sleep (5s) + app teardown (≤22s) < 30s grace, with headroom.
        - name: VOLTRO_SHUTDOWN_GRACE_MS
          value: "22000"
```

Live WebSockets are ENDED promptly at shutdown — the drain never lets an open
socket hold the process to the deadline — and the web client's supervisor
treats any close as a reconnect signal, so an open dashboard re-attaches to a
healthy replica across a rolling deploy without a page reload.

## 8. Multiple replicas

Cache and KV default to **in-process** (per-replica). For a shared backend across replicas:

```sh
CACHE_BACKEND=redis
KV_BACKEND=redis
```

**Cross-replica reactivity** is the subtle one: a write on one pod never surfaces on another pod's open subscriptions unless the replicas share a change bus. Use:

- **postgres** — LISTEN/NOTIFY (built in),
- **mariadb** — binlog CDC (built in),
- **other dialects** — `@voltro/plugin-broadcast` (Redis).

Schedules and aggregates auto-coordinate via an advisory lock on SQL stores — no extra config to keep them from double-firing across replicas.

### The prerequisites you learn at the SECOND pod

Every item below is invisible on one replica and breaks on two. They are
collected here because an operator reported each of them separately, each found
the same way: the first pod proved the configuration worked.

**The connection pool multiplies, the database limit does not.**

```sh
DB_MAX_CONNECTIONS=10       # per replica — the fleet opens up to this × replicaCount
```

The framework opens ONE pool per process. At 4 replicas a pool of 10 is 40
connections against a database that still allows whatever it allowed before you
scaled. An operator's second pod died on `Connection timed out` for exactly
this. `voltro serve` now prints the number and the arithmetic at boot:

```
db pool: max=10 per replica (DB_MAX_CONNECTIONS) + 1 = 11 × 4 replicas = up to 44 connections.
Check that against your database's limit. PLUS 1 outside the pool (CDC LISTEN consumer) —
those do not come out of the pool budget, they come out of the DATABASE's.
```

Set `REPLICA_COUNT` from your deployment (Helm: `{{ .Values.replicaCount }}`) and
the line does the multiplication for you; without it the line still names the
formula.

**`voltro dev` prints it too, when the environment says it is not a laptop.**
A bare `voltro dev` stays silent — one process, no replicas, nothing to
multiply. But `voltro dev` is a supported way to RUN an app, and a deployment
that uses it needs this line as much as any other. So it prints whenever
`REPLICA_COUNT`, `DB_MAX_CONNECTIONS` / `PG_MAX_CONNECTIONS`, or
`DB_REPLICA_URLS` is set — each of which means somebody has already decided
something about the number.

**Some connections are not in the pool, and the count is per process.** A
connection that speaks a long-lived protocol cannot be returned to a pool, so
the driver opens a standalone one. There are four such places and a full
deployment can hold several at once:

| Process | Connection | When |
|---|---|---|
| api `voltro serve` / `dev` | CDC `LISTEN` consumer | postgres, `changeStrategy: 'cdc'` (the default) |
| api `voltro serve` / `dev` | binlog CDC reader | mysql / mariadb, `changeStrategy: 'cdc'` |
| web `voltro start` | ISR invalidator `LISTEN` | any page declares `cacheInvalidatesOn` |
| web `voltro start` | postgres ISR cache client | `SSR_CACHE=postgres` |

SQL Server is the one CDC dialect that costs nothing here: Change Tracking is
read with ordinary queries through the pool, so its out-of-pool count is a
verified zero rather than an omission.

Two of the four are not a `LISTEN` at all — the binlog reader speaks the
replication protocol, and the ISR cache client is an ordinary client — which is
why counting `LISTEN` rows in `pg_stat_activity` undercounts. Each process prints its own number in the boot
line above — including `No connections outside the pool in this process` when
there are none, so "counted, zero" is distinguishable from "not counted".

**And a rolling update needs the surge pod's connections too.** A budget sized
for `replicaCount` is exactly full at steady state and short during every
deploy: `maxSurge` adds a pod that opens a full pool of its own. If that pod
cannot connect it never becomes ready, so the rollout does not complete and the
cluster stays at the higher pod count — the deploy cannot free itself. Size for
`(replicaCount + maxSurge) × (DB_MAX_CONNECTIONS + out-of-pool)`.

**`POD_IP` is each replica's identity, not only a workflow setting.**

```yaml
env:
  - name: POD_IP
    valueFrom:
      fieldRef:
        fieldPath: status.podIP
```

Without it every replica registers under the same host, so they are one runner
as far as the cluster is concerned. The boot warning for it fires only on SQL
cluster storage, so a deployment that has not adopted durable workflows yet gets
no signal at all — inject it as a matter of course.

**Derive the broadcast namespace from something that cannot be forgotten.**

```yaml
- name: VOLTRO_BROADCAST_NAMESPACE
  value: {{ .Release.Namespace }}
```

Staging and production of the same app share a name, code and fingerprint, so
the auto-derived namespace does NOT separate them — only this variable does. An
operator's own guidance, and better than ours was: a value taken from the
release namespace cannot be left out of one environment's config file, because
there is no file to forget.

### The framework's own background pollers

Two framework tasks ride the coordinated scheduler and write a row into
`_voltro_schedule_claims` on every tick they win: the workflow **admission
drainer** and the offloaded-**inference dispatcher**.

**Where a peer replica's write is visible here, they do not poll at all.** Each
runs one tick at startup — not optional; it is what finds work a previous
process left behind — and then stops until something arrives. The wake comes
from the change events their queue tables already emit, which is the same
mechanism the rest of the framework's reactivity runs on. Measured against a
real Postgres on a deployment that never uses either queue: **2 claim rows in
five minutes**, one per task, both written at boot.

That "where" is the whole condition, and it is satisfied by Postgres
LISTEN/NOTIFY or by a broadcast broker (Redis/NATS — which a multi-replica
deployment already runs for cross-replica reactivity). Without either, a
*remote* replica's enqueue produces no local event, so stopping would mean
sleeping through it. There the tasks back off to a ceiling instead:

```sh
VOLTRO_POLL_CEILING_MS=30000   # how long an arrival can wait when NOTHING woke the task
```

Nothing is lost in that case either — the replica that enqueued always sees its
own write inline and drains it itself. What the ceiling covers is the narrower
case of a *crashed* writer's lease being reclaimed by someone else.

A number worth knowing before you tune anything: on a two-replica deployment
that had never enqueued into either queue, these two tasks accounted for **99.3 %
of the claim ledger** — 2 506 rows an hour against 18 from the app's own eight
schedules. A fixed interval has no way to learn a queue is empty. That is what
changed; the ceiling is the fallback, not the fix.

### Tuning the cadence

The intervals are declarable, with defaults most apps never change:

```ts
// app.config.ts
export default {
  scheduling: {
    admissionDrainMs: 1000,   // workflow admission drainer
    inferenceTickMs:  250,    // offloaded-inference dispatcher
    cancelSweepMs:    2000,   // cancelOn sweep
    pollCeilingMs:    30000,  // idle ceiling, where nothing can wake a task
  },
}
```

Each has a matching env var — `VOLTRO_ADMISSION_DRAIN_MS`,
`VOLTRO_INFERENCE_TICK_MS`, `VOLTRO_CANCEL_SWEEP_MS`, `VOLTRO_POLL_CEILING_MS` —
which **overrides** the config field, the same way `VOLTRO_TENANT_ISOLATION`
overrides `tenancy.isolation`. The config is what a project declares; the env var
is what an operator changes on a running deployment without a rebuild.

Lowering an interval does **not** make anything more responsive: an arrival
already wakes the task at once. It only bounds the case where nothing announced
the work. A value of `0` or a non-number is ignored rather than honoured — a
zero interval would turn an idle task into a spin.

### Workflow failover across replicas

On a **SQL store** (postgres / mysql / mariadb / mssql), durable workflows survive a replica crash: completed `step({...})` activities are checkpointed in the cluster journal, so when a replica dies mid-run, a **surviving replica takes over the run and continues it from the last completed step** — it replays the completed steps rather than re-running them. (On sqlite the engine is single-process — durable within one replica, no cross-replica failover.) Two requirements:

- **Inject `POD_IP`** (K8s downward API, `fieldRef: status.podIP`) or set `VOLTRO_WORKFLOW_RUNNER_HOST`. This is each replica's cluster **identity** — without a distinct value, every replica registers as the *same* runner and they stop distributing shards (and cross-pod resume degrades). The boot logs a warning if it sees `localhost` with SQL storage.
- **Make step side effects idempotent.** Failover is *at-least-once at the step boundary*: a crash between a side effect and its journal write re-runs that step. A step's own [`retry:`](/docs/workflows/retries) does not change this — it's about the step you're inside, not the replica handoff.

**Is polling the bottleneck? No — reclaim is a lease, not a poll.** A crashed replica keeps its shards until its heartbeat goes stale; only then can a survivor claim them. So takeover latency is bounded by the **lease TTL (~35s by default)**, not by any message-poll interval, and a push mechanism (LISTEN/NOTIFY) does **not** move it. Two knobs tune it:

```sh
VOLTRO_WORKFLOW_FAILOVER_LEASE=15       # seconds a dead replica's work stays locked (default 35)
VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT=5    # lease-refresh cadence (default 10; keep ≈ lease/3)
```

Lower the lease for **faster failover**, at the cost of **false-positive reclaims**: if a *healthy* replica is paused longer than the lease by a GC pause or a DB-latency spike, another replica may briefly also claim its shards. Keep the heartbeat around a third of the lease so one slow refresh doesn't trip a reclaim. For crash detection that doesn't depend on the timeout at all, pair it with a K8s **liveness probe** so a dead pod is removed promptly.

(A separate concern is *new*-message pickup: a workflow triggered on the replica that owns its shard starts immediately, but one owned by ANOTHER replica is otherwise picked up on that replica's next storage poll — up to 10s. **If you run a broadcast broker (Redis/NATS — which a multi-replica deployment already does for cross-replica reactivity), this is automatic and near-instant**: a trigger pushes a "wake" over the bus and the shard owner re-polls at once, on any SQL dialect. **Without a broker, the change stream does the same job wherever remote changes reach the spine** — a Postgres-only multi-replica fleet (LISTEN/NOTIFY CDC) is the common case: a remote replica's signal, start context, or run transition arrives as a change event and triggers an immediate, coalesced re-poll, so signal/step latency stops being poll-bounded there too. Only with *neither* a broker *nor* CDC does the poll interval remain the bound — tune `VOLTRO_WORKFLOW_POLL_INTERVAL=2` then. Unrelated to the failover path above.)

## Checklist

- [ ] `VOLTRO_SESSION_SECRET` set from `voltro secret generate session`, in a secrets manager
- [ ] Rotation window uses `VOLTRO_SESSION_SECRET_PREVIOUS`
- [ ] Liveness / readiness probes point at `/internal/liveness` + `/internal/readiness`
- [ ] Serving pods run `voltro serve` (not `voltro dev`), with `VOLTRO_AUTO_MIGRATE=0`
- [ ] Schema applied by a pre-deploy Job / initContainer (`voltro db apply`), not in the serving pod
- [ ] `VOLTRO_MAX_RPC_BODY_BYTES` + `VOLTRO_MAX_BODY_BYTES` (plugin routes/webhooks) sane; ingress caps body size + per-IP rate
- [ ] `VOLTRO_TRUSTED_PROXIES` set if you run behind an ingress AND rate-limit per IP
- [ ] `VOLTRO_ALLOWED_ORIGINS` set if the web app is on a different origin than the api
- [ ] Security headers reviewed (`VOLTRO_SECURITY_HEADERS`, `VOLTRO_CSP`); HSTS reaching the browser over https
- [ ] Every `*.webhook.tsx` declares its verification, and each signature-verified one has its `VOLTRO_WEBHOOK_SECRET_<ID>`
- [ ] `auth.anonymousTenantRequired: true` (unless the app serves anonymous public data)
- [ ] `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_SERVICE_NAME` pointed at your collector
- [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
- [ ] `terminationGracePeriodSeconds` generous for graceful drain; `VOLTRO_SHUTDOWN_GRACE_MS` set just under it (minus the preStop sleep)
- [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
- [ ] `DB_MAX_CONNECTIONS` set so `pool × replicaCount` fits your database's limit — read the `db pool:` boot line before raising `replicaCount`
- [ ] `POD_IP` injected via the downward API on EVERY multi-replica deployment, not only for durable workflows
- [ ] `VOLTRO_BROADCAST_NAMESPACE` derived from the release namespace — staging and production do not separate themselves
- [ ] For durable workflows on >1 replica: SQL store + `POD_IP` injected; tune `VOLTRO_WORKFLOW_FAILOVER_LEASE` if a 35s takeover is too slow; step side effects idempotent



---

<!-- source: en/deployment/seats-and-team.md -->
## Seats and team management

_How teams are invited, what a seat is, exactly which actions occupy one, and how machine credentials stay seat-free._

A Voltro seat is **one identified natural person**. Not a login, not a token, not a machine — a human. Everything on this page follows from that single definition.

This page is the published boundary: what occupies a seat, what does not, and how the product makes both visible before an invoice does.

## Membership is not a seat

Two separate things, deliberately:

| | What it answers | Cost |
|---|---|---|
| **Membership** | May this person act in this org? | Free — invite as many as you like |
| **Seat** | Does this person consume one of your purchased seats? | Counted |

A seat is occupied by **active work**. Watching is free: a manager following a deploy, a product owner reading logs, an accountant paying the invoice — all need a membership, none need a seat.

## What occupies a seat

The list below is the complete one the server consults. It is an **allowlist**, which has a consequence worth stating: an endpoint that is not on it is free, so a newly shipped feature can never quietly start costing you money.

**Occupies a seat**

- Deploy an app · roll a deployment back
- Create or delete a project · create an app · create an environment
- Register a custom domain
- Change environment variables
- Submit or approve a migration plan
- Create a service token · invite a teammate

**Free — no seat**

- Read logs, view metrics, follow deployment status
- View usage and invoices
- View the team

The same list is rendered inside the dashboard, on the team page, next to your live seat count. There is exactly one list — the server and the UI read the same file — so what you are told and what you are charged for cannot drift apart.

### One person, one seat

Occupancy is keyed on `(organisation, person)` and enforced by a unique constraint in the database, not by application logic. In practice:

- Five machines, three rotated CLI tokens, two browser sessions → **one seat**.
- A contractor working in five client organisations → **five seats**, one per client. Whoever works inside an org occupies a seat of that org; there are no cross-org seat pools.
- Activity is recorded coarsely and debounced. Forty deploys in an afternoon are one write, not forty — the ledger records *that* you worked, not every request.

## Inviting people

Invite by **personal work email** — one address per person.

1. An owner or admin sends the invitation. A pending invitation is created and a **single-use, expiring link** is emailed.
2. The invitee opens the link. They can read who invited them, to which organisation, and in which role **without signing in** — the token is the authorisation for that preview, and it grants nothing else.
3. They sign in (or sign up) with the invited address and accept.

**The accepting account's email must equal the invited address.** This is where "personal accounts" stops being a clause and becomes a check: forwarding a link to a colleague, or into a shared inbox that another account reads, cannot produce a membership. Plus-tagged variants (`name+something@`) count as a different address — loosening that match is exactly how one invitation would start covering two people.

Two supporting behaviours:

- **Shared-looking addresses warn, but are not blocked.** `info@`, `team@`, `dev@` raise an inline warning. They are not refused, because `dev@` genuinely is one person at a two-person shop, and address patterns miss `buero@` and `kontakt@` anyway. The email match at acceptance is what actually enforces the rule.
- **Resending rotates the token.** The old link stops working. That keeps revocation total and covers the usual reason for a resend — the first link landed somewhere the person no longer reads.

Invitations can be revoked at any time. Revocation is immediate: acceptance requires a *pending* invitation.

## Service tokens — CI without a seat

CI needs a credential. If the only credential were user-bound, every team would paste a human's token into their pipeline — which is precisely the credential sharing the terms forbid. So machines get their own class:

> **Service token:  org-scoped · no seat · individually revocable · automation scopes only**

Valid scopes are `deploy`, `build`, `register` and `inventory` — an automation-only set. **Interactive development is not among them and never will be**: requesting it is rejected outright. That boundary is what stops the seat model from being bypassed by routing every developer's work through one machine credential.

A token's value is shown **once**, at creation. Only its hash is stored, so there is no "show it again" and no support path that could reveal it. Revoking is a soft flip: the token stops working immediately, and the record of what it did survives.

## Going over your seats

Nothing is blocked. Person 21 on a 20-seat plan starts working immediately; the extra seat appears on the next invoice as the contractual true-up.

Because nothing blocks, the warnings are the safeguard, not a nicety:

| State | What you see |
|---|---|
| Below 80 % | Your seat count, plainly |
| From 80 % | An advance warning on the team page |
| Every seat occupied | The next person to work will be an additional seat |
| Over | How many seats beyond your purchase, and that they will be invoiced |

Suspension exists only for non-payment or abuse, through the contractual process. A developer is never locked out mid-sprint over a seat count.

### Where your seat count comes from

Two numbers can define your limit, and the more specific one always wins:

| Situation | Your limit is |
|---|---|
| You have a paid subscription | Its **seat quantity** — what you bought |
| No subscription | The seats **included** with your plan |
| Free or Enterprise | Uncapped — Free is gated on projects × apps, Enterprise contractually |

The team page names which of the two applies, so "of 40 on your subscription" and "of 10 included with Team" are never confused. If you buy 40 seats on a plan that includes 10, your limit is 40 — the number you paid for governs.

A cancelled subscription stops granting its quantity and you return to the free tier's limits. A **failed payment does not**: while a card is being retried your team keeps working, because locking an organisation out over a bank decline is a support incident, not enforcement.

## Self-hosted

Seats work the same way when you run Voltro yourself, and self-hosted development is **attributed to a person** — which is what makes the model measurable rather than aspirational.

> **Signing in.** Voltro Cloud accounts are authenticated by WorkOS — always, in every environment. There is no password, no fixed code, and no local fallback: if WorkOS cannot verify you, you are not signed in.

### Getting a personal credential

Generate one from your team page ("Your CLI credential"), then store it:

```bash
voltro cloud login --token <token>
```

It is **org-scoped and personal**. Org-scoped because a contractor working in five client organisations occupies five seats, one per client — so generate one per organisation you work in. Personal because the control plane records who it belongs to, and that link is the whole basis of self-hosted attribution.

### What is sent

`voltro dev` reports a coarse **daily heartbeat**:

> **organisation · person · project slug · CLI version · UTC day**

Never source, never schema, never data, never file names, never command arguments. That is the complete list, and it is asserted by a test that fails if a field is added — so growing it takes a deliberate decision, not a careless commit. What we do with it, on what legal basis, and for how long is set out in the [Privacy Policy](https://voltro.cloud/legal/privacy) under "Developer and usage data".

If you license Voltro for a team, note that you are responsible for informing the people concerned. Administrators see who occupies a seat and the date of last activity — never what anyone did.

Three properties you can rely on:

- **Debounced.** At most one report per day per project. Restarting your dev server forty times is one heartbeat.
- **Offline-tolerant.** A failed send is remembered and flushed on a later run. Working on a train is a gap in a chart, not a licence problem.
- **It never fails a command.** Every error path is swallowed. Telemetry that can break `voltro dev` eventually will.

Not logged in? Nothing is sent, nothing is warned about, and nothing is slower. That is the normal case for most local development.

### Machine credentials are refused

`voltro dev` requires a **personal** credential. A service token belongs to no person, so it is rejected with an explanation rather than silently counted:

```
voltro dev needs a personal login — the stored credential belongs to no user.
```

Without that boundary, a team could route every developer's work through one shared machine token and occupy zero seats. Deploying with a service token stays fine — deploying is not developing.

### From activity to an invoice

A nightly job closes the loop, and your team page shows both halves side by side:

| | What it is |
|---|---|
| **Live count** | Who occupies a seat right now — moves as people work |
| **Billing period** | The rolled-up figure for the month, including the self-hosted share |

The rollup is idempotent, so a retried job cannot double-count. The two numbers differ between runs by design: one is what your team is doing, the other is what a bill would say, and showing only one of them is how a customer ends up arguing with a dashboard.

Air-gapped and enterprise deployments attest seats contractually instead; no telemetry path is required.

## Related

- [Voltro Cloud](./voltro-cloud.md) — the control plane these features live in
- [Self-hosting](./self-hosting.md) — running Voltro yourself



---

<!-- source: en/deployment/platforms.md -->
## Platform recipes

_Deploy the same container to Fly.io, Railway, Render, or a Hetzner VM — one image, four wrappers._

Every recipe on this page deploys the **same artifact**: the production image
from the compose baseline's `docker/api.Dockerfile` (`voltro baseline set
compose` writes it into your project). The platforms differ only in the wrapper
— how they build it, which env vars they inject, and how they health-check it.

**What is verified, stated exactly.** The container itself is the tested part:
it builds from a clean context, boots `voltro serve` from the precompiled serve
bundle, survives the `pnpm deploy` relocation, and answers
`/internal/readiness` with 200 — that loop runs in this repo's own validation.
The platform wrapper files below are written against each platform's current
config format and have **not** been executed against a live account of that
platform; if one drifts from what the platform ships today, the container is
still right and the fix is in the wrapper.

Three properties of the image every platform relies on:

- **`PORT` wins.** The port precedence is `PORT` > `--port` > `app.config.ts` —
  deliberately, because platforms assign through `PORT`. You never configure a
  port in the wrapper beyond telling the platform which one the app answers on.
- **A missing secret refuses to boot.** `VOLTRO_SESSION_SECRET` unset is a
  clean, named boot refusal — not a server that signs with a default. Set
  secrets in the platform's secret store before the first deploy, or read the
  refusal message; both are correct outcomes.
- **`/internal/readiness` flips to 200 only after the whole boot.** Use it as
  the health check everywhere; routing traffic on process-up instead of
  readiness is how a deploy serves 502s for the first seconds.

## Fly.io

```toml
# fly.toml
app = "my-voltro-api"
primary_region = "fra"

[build]
  dockerfile = "docker/api.Dockerfile"
  build-args = { APP_PATH = "apps/my-app/api" }

[env]
  DB_DIALECT = "postgres"

[http_service]
  internal_port = 4000
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 0

  [[http_service.checks]]
    path = "/internal/readiness"
    interval = "10s"
    timeout = "2s"
```

```sh
fly secrets set VOLTRO_SESSION_SECRET=$(openssl rand -base64 32) DB_URL=<from fly postgres attach>
fly deploy
```

The one Fly-specific decision: `auto_stop_machines` gives you scale-to-zero,
and a cold start pays the container boot. The serve bundle exists for exactly
this — the framework-boot slice of a cold start is ~180–210 ms instead of ~1 s.
Read [Scale to zero](/docs/deployment/scale-to-zero) before choosing
`min_machines_running = 0` for an api that owns schedules: a machine that is
never awake fires no cron.

## Railway

Railway detects the Dockerfile; point it at the right one and set the build
context to the repo root (the Dockerfile copies the whole workspace for
`pnpm install`).

```json
// railway.json
{
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "docker/api.Dockerfile"
  },
  "deploy": {
    "healthcheckPath": "/internal/readiness",
    "restartPolicyType": "ON_FAILURE"
  }
}
```

Set `VOLTRO_SESSION_SECRET` and `DB_URL` as service variables; Railway injects
`PORT` and the image binds to it — no port config anywhere. The
`APP_PATH` build arg goes into the service's build settings.

## Render

```yaml
# render.yaml
services:
  - type: web
    name: my-voltro-api
    runtime: docker
    dockerfilePath: ./docker/api.Dockerfile
    dockerContext: .
    healthCheckPath: /internal/readiness
    envVars:
      - key: DB_DIALECT
        value: postgres
      - key: VOLTRO_SESSION_SECRET
        sync: false
      - key: DB_URL
        fromDatabase:
          name: my-voltro-db
          property: connectionString

databases:
  - name: my-voltro-db
    plan: basic-1gb
```

`sync: false` makes the secret a dashboard-entered value that never lands in
the blueprint file — the same "we ship no secret values" rule the framework
enforces on its own templates applies to yours.

## Hetzner (or any bare VM)

A VM is the compose baseline with a process manager on top — this is the one
recipe whose whole stack is the already-validated path from
[Self-hosting](/docs/deployment/self-hosting).

```sh
# once, on the VM
apt-get install -y docker.io docker-compose-plugin
git clone <your-repo> /srv/app && cd /srv/app
cp .env.example .env   # then fill in real values — nothing boots without them
docker compose up -d --build
```

```ini
# /etc/systemd/system/voltro.service — survive reboots
[Unit]
Description=voltro stack
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/srv/app
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target
```

Caddy (in the baseline compose) terminates TLS with an automatic Let's Encrypt
certificate — point the domain's A record at the VM and the certificate is
provisioned on first request. What a VM does NOT give you: rolling deploys
(compose restarts in place — expect seconds of downtime per deploy, or put two
VMs behind a load balancer), and managed Postgres backups —
[`voltro data backup`](/docs/cli/data) plus the restore drill is your baseline,
and the drill is the part people skip.

## Which one

| | scale-to-zero | managed DB | rolling deploys | cost floor |
| --- | --- | --- | --- | --- |
| Fly.io | yes (`auto_stop`) | Fly Postgres | yes | ~0 idle |
| Railway | usage-based sleep | built-in | yes | ~0 idle |
| Render | paid plans only | built-in | yes | fixed/instance |
| Hetzner VM | no | bring your own | no (single VM) | fixed, cheapest at steady load |

An api that owns cron schedules should not scale to zero. An api with bursty
traffic and no schedules is exactly what scale-to-zero is for. When in doubt,
the boring answer — one always-on instance — is also the cheapest to operate.

## Why there is no edge-SSR adapter

Every recipe above deploys a container, and that is deliberate: Voltro's SSR is
Node-first (`renderToPipeableStream` into a Node stream, `voltro start` as a
long-running Node HTTP server), not a Workers/edge runtime — so there is no
Vercel-/Netlify-edge SSR adapter, and none is planned as a posture. The edge
still gets first-class use where it fits the model: isolated
[`*.serverless.ts` functions](/docs/deployment/serverless-functions)
(`@voltro/serverless`, with Cloudflare / Scaleway / Node adapters) for
request-shaped work at the edge, and
[static / ISR pages](/docs/deployment/static-sites) served from a CDN for
everything that does not need a per-request render. If a page must render per
request, it renders in the container.
