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:

  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:

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.
  • composedocker/{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

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

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

  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. Install @voltro/plugin-cluster in app.config.ts.plugins. It sets up cross-instance subscription invalidation via Postgres NOTIFY (or Redis if you set CLUSTER_TRANSPORT=redis).
  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).

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? page for what we won't build (and what we will).