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:

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:

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.

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

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

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

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/liveness200 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/readiness503 until the app port is serving, then 200.
  • GET /internal/startup200 JSON { phase, ready, tablesTotal, elapsedMs } (phase is bootingmigratingready, 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:

# 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) rejects an over-Content-Length body with 413 before buffering it into memory:

VOLTRO_MAX_RPC_BODY_BYTES=8388608   # default 8 MiB

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.

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

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:

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

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

Structured logs as JSON:

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:

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): it stops schedulers, detaches subscribers / reactions / aggregates, drains the analytics sink, stops accepting new connections while finishing already-accepted requests, and closes the SQL connection pool last (waiting for in-flight transactions). Verified under concurrent load — accepted requests complete.

But the app cannot drain a rolling update by itself. The runtime (NodeRuntime) owns the SIGTERM signal and closes the HTTP listener promptly — so a request that arrives during shutdown is refused. Failing readiness from inside the process doesn't help: the listener is already closing. Draining is a k8s-layer job, done with a preStop hook:

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 drops the small window of requests that route to a terminating pod before k8s finishes removing it from the Service endpoints. With it, that window is drained. terminationGracePeriodSeconds must be larger than the sleep plus the app's own teardown, or k8s SIGKILLs mid-drain.

8. Multiple replicas

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

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.

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 sane; ingress caps body size + per-IP rate
  • 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
  • CACHE_BACKEND / KV_BACKEND + a cross-replica change bus when running >1 replica