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:
- 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.
- Typed accessors — the coerced, narrowed type (
number,boolean, a literal union), notstring | undefined. - A structural public/secret boundary — a
secretvariable 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 viapublicEnv— and there it MUST be namedVOLTRO_PUBLIC_*(see below).secret— must never reach the browser. Read only on the server viaserverEnv/getSecret. Never bundled, never logged, never reported by value over the inspect surface. Resolved through the configuredsecrets: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/on ↔ false/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:
accessis 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-mesigns 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(orvoltro env check) — print the manifest (app + plugin + framework variables, each withisSet); exits non-zero if a required variable is unset (a CI gate).voltro env sync— (re)generate.env.examplefrom the manifest. Secrets are left blank; public vars carry their example/default.voltro env types— (re)generateenv.generated.d.ts(theServerEnv/PublicEnvtype augmentation) fromapp.config.ts'sdefineEnv, WITHOUT a fullvoltro devboot. Run it in CI beforetscso a standalone typecheck resolvesserverEnv.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 forturbo.jsonglobalPassThroughEnv.GET /_voltro/inspect/env— a value-free manifest (secrets reportisSetonly, 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.