Self-hosting
Run Voltro on your own infra — Docker compose, env vars, reverse proxy, scaling.
Voltro is open-core. The runtime that powers Voltro Cloud is the same runtime you run locally with voltro start. 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:
- Node.js 24+ to run the api + web processes.
- Postgres 16+ with
pgvectorextension (for AI / search) andwal_level=logical(for subscriptions). Managed Postgres at Neon, Supabase, RDS, or your own server all work. - A reverse proxy (Caddy / nginx / Cloudflare). Terminates TLS, routes paths, fronts the framework's processes.
- (Optional) Redis for cross-instance subscription fan-out when you scale beyond one api process.
- (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:
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 activebare—.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— acharts/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
# 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
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:
// 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:
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:
// 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)
},
},# 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.localserverUrl / 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:
// 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 (needs PG_* set too) for the ISR cache |
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 (overrides app.config.ts.port) |
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:
- 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). - Install
@voltro/plugin-clusterinapp.config.ts.plugins. It sets up cross-instance subscription invalidation via Postgres NOTIFY (or Redis if you setCLUSTER_TRANSPORT=redis). - Workflows:
@effect/clustershards 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).
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 byvoltro start.Targets at runtime: set
VOLTRO_DASHBOARD_APPSto a JSON array of{ name?, url, token? }, whereurlis each app's origin andtokenis that app'sVOLTRO_INSPECT_TOKEN. The dashboard serves it from/api/dashboard/configon 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 asAuthorization: Bearer.
Production checklist
- HTTPS terminates at the edge with HSTS preload
-
VOLTRO_SESSION_SECRETrotated from CSPRNG, stored in a secrets manager (not env file in git!) -
cookieSecure: truein the@voltro/plugin-authconfig - Postgres
wal_level=logical,max_replication_slots≥10 -
pgvectorextension if you use AI features - OpenTelemetry exporter pointed at your sink (Datadog / Honeycomb / Grafana / Loki)
-
VOLTRO_INSPECT_TOKENset, orVOLTRO_INSPECT=offin 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? page for what we won't build (and what we will).