Environment variables
Env is schema-driven with varlock. Every key the apps read is declared once, with its type, whether it is secret, and whether it is required, in a committed .env.schema; values come from the schema’s public defaults, a gitignored .env.local, or the platform (Cloudflare, Fly, CI). bunx varlock load names anything missing or malformed before an app boots; bunx varlock load --agent prints the resolved env with secrets redacted, which is the only way an agent should inspect it.
.env.schema # shared: Supabase, durable streams, PostHog, LLM keys, Daytona, Giphyapps/www/.env.schema # + OpenRouter management key, feedback key, release identityapps/backstage/.env.schema # + DATABASE_URL, director overrides, worker idpackages/sandbox/src/daytona/proofs/.env.schema # + proof knobs (stream url, house/thread ids).env.local # gitignored secrets for every app (also apps/<app>/.env.local for app-only ones)Each app schema imports the root directory (@import(../../)), so the root schema’s defaults and the root .env.local reach every app; an app’s own .env.local wins over both. Secrets are anything not prefixed PUBLIC_ unless the schema marks it @public. The environment flag is APP_ENV, resolved from the platform when unset (main → production, PRs → preview, vitest → test, laptop → development).
How each surface loads it:
- www dev/build —
@varlock/vite-integrationinapps/www/vite.config.tsvalidates the schema and injects the resolved env before SvelteKit reads$env/static/*and$env/dynamic/*.hooks.server.tsstill re-checks the six core vars at module load. - www prod (Cloudflare) —
apps/www/scripts/deploy-worker.tsdeploys withvarlock-wrangler, not plainwrangler: it resolves the schema from the Builds environment, uploads non-sensitive values as worker vars and sensitive ones as secrets, and injects the__VARLOCK_ENVblob the SSR loader reads at boot. Keys read via$env/static/privatemust still exist as Builds secrets or the build fails withMISSING_EXPORT;OPENROUTER_API_KEYis read both ways. The worker’s runtime env now comes from the schema, so there are no hand-managed wrangler secrets; varlock also passes--keep-vars=false, dropping any var not in the schema.RELEASE_SHAis put in the process env by the deploy script so varlock uploads it. PR previews need the same treatment: the non-production Builds command must bebun run --filter '@arbe/www' deploy:preview, not plainwrangler versions upload. - backstage prod (Fly) — every value is a Fly app secret; the Docker entrypoint is plain
bun run, so the schema is not consulted there yet.apps/backstage/src/index.tsnames missing vars at boot. Locallybun run startgoes throughvarlock run. - scripts —
bunx varlock run -- bun run <script>from the directory whose schema applies (repo root for most; the proofs dir forprove-*.ts). Bun’s own.envautoload is off at the root (bunfig.toml). - CI —
bun run check:envvalidates root, www, and backstage schemas before the builds, with placeholder secrets from the workflow env.
Gotchas that the schema comments also carry: SUPABASE_JWT_SECRET is the raw HS256 secret from Supabase → Settings → API → JWT Secret, not a JWT and not the service role key (wrong value → “No suitable key”). PostHog must be set on www and Fly, or HTTP-side spans stay visible while chat $ai_generation spans disappear (analytics). LLM keys (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY) are overridden per house by a house secret of the same name; GITHUB_TOKEN is only ever a house/sandbox secret. RELEASE_SHA is a wrangler --var, and ARBE_RELEASE_SHA takes precedence over Cloudflare’s WORKERS_CI_COMMIT_SHA when deploying from a source archive.
Secret storage: plaintext .env.local values can be encrypted in place with bunx varlock encrypt --file .env.local (device-bound, revealed with bunx varlock reveal <NAME>).
Code: .env.schema and the three app schemas, apps/www/vite.config.ts, apps/www/src/hooks.server.ts, apps/backstage/src/index.ts.
See system/deployment, system/development, system/auth, system/environments.