Environment variables

Typed, schema-validated environment variables with a structural public/secret boundary. Declare once in app.config.ts; read public vars in the browser, secrets only on the server — and let the build fail loudly if you ever cross the line.

@voltro/env replaces scattered process.env.X! reads with a single typed declaration in app.config.ts. You get three things process.env can't give you:

  1. Boot-time validation — every variable is validated once at startup; a missing or malformed value aborts the boot with all problems listed at once, not a confusing crash deep in a request.
  2. Typed accessors — the coerced, narrowed type (number, boolean, a literal union), not string | undefined.
  3. A structural public/secret boundary — a secret variable can never reach the browser. It's enforced by the compiler and the bundler, not by discipline: a secret read in browser code is a compile error.

Declare

// apps/api/app.config.ts — on an API, `public` = NON-SENSITIVE server config
// (there is no browser bundle), so these stay UNPREFIXED. `secret` = server-only.
import { defineEnv, envVar } from '@voltro/env'

export const env = defineEnv({
  LOG_LEVEL:   envVar.enum(['debug', 'info', 'warn'], { access: 'public', default: 'info' }),
  MAX_BATCH:   envVar.number({ access: 'public', default: '50' }),
  STRIPE_KEY:  envVar.string({ access: 'secret' }),                 // server-only
  WEBHOOK_URL: envVar.url({ access: 'secret', optional: true }),
})

export default { type: 'api' as const, name: 'myApi', store: 'postgres' as const, env }
// apps/web/app.config.ts — on a WEB app, `public` vars ARE browser-bundled, so
// they MUST be named `VOLTRO_PUBLIC_*` (the framework's default public prefix).
export const env = defineEnv({
  VOLTRO_PUBLIC_SENTRY_DSN: envVar.string({ access: 'public', description: 'Browser Sentry DSN' }),
  VOLTRO_PUBLIC_APP_NAME:   envVar.string({ access: 'public', default: 'Voltro' }),
})

access is mandatory

Every field declares access: 'public' | 'secret'. There is no default — choosing it is a security decision (the same reason cache.scope is required). public means different things by app type:

  • public — non-sensitive. On an api it is server-side config (there is no browser bundle), so the var stays unprefixed. On a web app it is baked into the browser bundle at build time and read via publicEnv — and there it MUST be named VOLTRO_PUBLIC_* (see below).
  • secret — must never reach the browser. Read only on the server via serverEnv / getSecret. Never bundled, never logged, never reported by value over the inspect surface. Resolved through the configured secrets: backend (env / Vault / Doppler / http), so a remote secret works without extra wiring.

The VOLTRO_PUBLIC_ prefix invariant (web)

A browser-exposed public var (web app access: 'public') MUST be named VOLTRO_PUBLIC_* — the framework's default public prefix. A secret var may never carry that prefix. The boot gate rejects a mismatch, so the variable name and its access tag can't silently disagree, and anyone reading .env can tell at a glance what ships to the browser. Override or opt out with defineEnv(map, { publicPrefix: 'MYAPP_PUBLIC_' | false }).

Vite's own VITE_import.meta.env channel is disabled by the framework (its envPrefix is pinned to a non-matching sentinel in every web Vite config), so public config reaches the browser ONLY through @voltro/env (VOLTRO_PUBLIC_*), never a raw .env var. (import.meta.env.DEV, a build-time constant, still works.)

Field builders

Builder Value type Coercion
envVar.string(opts) string
envVar.number(opts) number "5"5
envVar.port(opts) number "5"5, range 1..65535
envVar.boolean(opts) boolean true/1/yes/onfalse/0/no/off/""
envVar.url(opts) string validated with URL
envVar.enum([…] as const, opts) literal union must be one of the values
envVar.secret(opts) string server-only, required, length floor

envVar.secret — for values that must not be guessable

env: defineEnv({
  // Ours to invent → `voltro dev` mints one per project.
  VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url' }),
  // Someone else's to issue → must be fetched, never invented.
  STRIPE_SECRET_KEY:     envVar.secret({ minLength: 20 }),
})

It differs from envVar.string({ access: 'secret' }) in three ways, each closing a specific failure:

  • access is forced to 'secret' — a value with a length floor is never something you meant to bundle into a browser.
  • There is no default. A default secret is not a secret: every deployment that forgot to set the variable would share it.
  • A length floor (default 32). Presence alone does not catch the real failure mode — a variable that is set but reads change-me signs forgeable cookies while looking completely healthy.

generate opts a variable into per-project minting. On first voltro dev, any declared-but-unset mintable secret is written to a gitignored .env.local and the boot continues. That is why no Voltro template ships a secret value: a placeholder in a template is a signing key published to everyone who downloads it, and it passes every check you could write.

Leave generate off for anything a third party issues. A WorkOS API key is just as secret and just as required, but inventing one produces a value that merely looks right and authenticates nobody — better that the boot gate fails and a human fetches the real one.

Minting is development only. voltro serve, build and start never mint: in production a missing secret is a boot failure, which is the whole point. Generate deployment values with voltro secret generate <purpose>.

Every builder takes { access, optional?, default?, description?, example? }. Use default for a fallback (the value type stays present, A); use optional: true when the variable may legitimately be absent (the value type becomes A | undefined). Use one or the other, not both.

Read values — two entry points

The boundary is enforced by the import graph, not by convention:

// SERVER — a mutation / action / query / loader / *.startup.tsx:
import { serverEnv, getSecret } from '@voltro/env/server'
const title = serverEnv.APP_TITLE          // typed via generated augmentation
const key   = getSecret('STRIPE_KEY')      // intent-signalling sugar for a secret

// BROWSER — a page / component / *.query.ts descriptor:
import { publicEnv } from '@voltro/env/public'
<Sentry dsn={publicEnv.VOLTRO_PUBLIC_SENTRY_DSN} />   // typed; publicEnv.STRIPE_KEY ⇒ compile error

@voltro/env/server imports @voltro/runtime, so importing it from a browser-reachable module breaks the build loudly — the same boundary guard the descriptor/executor split relies on. @voltro/env/public has zero server dependencies and its type only ever names public keys. A secret in the browser is therefore unrepresentable, not merely discouraged: the PublicEnv interface has no index signature, and the codegen augments it with the public keys only — so reading a secret or an undeclared key through publicEnv is a compile error.

When are values available?

Everything is validated and resolved once at boot, then read from a frozen snapshot. So:

  • Read env inside a handler / loader / startup hook — never at module top-level (that runs before the boot gate and throws a clear error).
  • A remote-backend secret rotation takes effect on the next restart. For a rare live re-read, use resolveSecretLive(key) from @voltro/env/server.

Web apps are public-only

On a type: 'web' app, defineEnv declares the public variables baked into the browser bundle, read with publicEnv. A secret declared on a web app is accepted but only reachable from server-side render code, and the boot warns — declare secrets on the api instead.

Tooling

  • voltro env (or voltro env check) — print the manifest (app + plugin + framework variables, each with isSet); exits non-zero if a required variable is unset (a CI gate).
  • voltro env sync — (re)generate .env.example from the manifest. Secrets are left blank; public vars carry their example/default.
  • voltro env types — (re)generate env.generated.d.ts (the ServerEnv / PublicEnv type augmentation) from app.config.ts's defineEnv, WITHOUT a full voltro dev boot. Run it in CI before tsc so a standalone typecheck resolves serverEnv.X / publicEnv.X — whose types come from that generated file, which otherwise only a dev boot writes. (getSecret('X') takes a string and is codegen-independent, so it typechecks without this step.)
  • voltro env turbo — list the framework variables for turbo.json globalPassThroughEnv.
  • GET /_voltro/inspect/env — a value-free manifest (secrets report isSet only, never a value). Drives the dashboard Env panel.

Plugins declare their env

A plugin that reads environment variables declares them via declaredEnv on its VoltroPlugin (metadata only — the plugin still reads its own values). This makes a plugin's env needs visible in the manifest, .env.example, and the dashboard. The first-party plugins already declare theirs, so the manifest is complete out of the box.

Anti-pattern

Don't reach for process.env.X in app code — it skips validation, the public/secret boundary, and the manifest. Declare the variable in defineEnv and read it via serverEnv / publicEnv. Framework internals (DB_*, CACHE_*, VOLTRO_*) are catalogued by the framework itself; you don't redeclare them.