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 sessionStore 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 cookiesKeep _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— always200 ok. The process is up.GET /internal/readiness—200 readyonly after full boot,503before.
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: 3Run
voltro servein serving pods — notvoltro dev.voltro devis 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 runvoltro 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/liveness→200 okfrom the moment the process is up, through the whole boot. PointstartupProbeandlivenessProbehere so migration time counts as alive, not dead.GET /internal/readiness→503until the app port is serving, then200.GET /internal/startup→200JSON{ phase, ready, tablesTotal, elapsedMs }(phaseisbooting→migrating→ready, orerrorwith 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 MiBScope: 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-apiStructured logs as JSON:
VOLTRO_LOG_FORMAT=json
VOLTRO_LOG_LEVEL=infoError 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=redisCross-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_SECRETset fromvoltro 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(notvoltro dev), withVOLTRO_AUTO_MIGRATE=0 - Schema applied by a pre-deploy Job / initContainer (
voltro db apply), not in the serving pod -
VOLTRO_MAX_RPC_BODY_BYTESsane; ingress caps body size + per-IP rate -
auth.anonymousTenantRequired: true(unless the app serves anonymous public data) -
OTEL_EXPORTER_OTLP_ENDPOINT+OTEL_SERVICE_NAMEpointed at your collector -
VOLTRO_LOG_FORMAT=json;sentryPlugin()+SENTRY_DSNfor errors -
terminationGracePeriodSecondsgenerous for graceful drain -
CACHE_BACKEND/KV_BACKEND+ a cross-replica change bus when running >1 replica