Every variable below exists because something broke without it. The problem comes first; the key second.
1. Core + auth: the silent dev-mode disaster
The problem. Your deploy goes live, logins work, everything looks fine — except the app is running with an empty session secret, sessions are forgeable, and nothing told you. This is the industry's most common auth incident: a missing variable that fails open instead of failing loud. Boring treats a missing NEXTAUTH_SECRET in production as a boot error, not a warning. next-auth itself throws MissingSecret in production — the framework agrees with us here.
The keys.
NEXTAUTH_URL — http://localhost:3000 locally; your prod domain in .env.prod. OAuth providers reject callbacks that don't match this exactly, including the trailing shape (https:// vs http://, apex vs www). If Google login loops back with redirect_uri_mismatch, this variable is wrong — not your code.
NEXTAUTH_SECRET — any random 32+ chars. Generate it properly:
openssl rand -base64 32
Never reuse it across projects, never commit it, rotate it the day anyone with access leaves. Rotating logs everyone out once — that is the point.
ADMIN_EMAILS — comma-separated owner addresses ([email protected],[email protected]). These unlock the admin store, vault, and user lists. Leave it empty and there is simply no admin — fail-closed, not fail-open.
GOOGLE_ID / GOOGLE_SECRET — optional. Commented out means Google login is off and magic links carry auth alone. That is a supported configuration, not a degraded one.
RESEND_API_KEY / RESEND_FROM — optional until you need email. Magic-link auth needs a mailer in production; in dev, links print to the terminal so you can click them without any key at all.
Verify. Start dev, open /auth, request a magic link. No key configured? The link appears in your terminal. That is the designed path — if instead you see a spinner that never resolves, check RESEND_API_KEY spelling first.
2. Database: one switch, three engines, zero rewrites
The problem. Teams pick Postgres on day one "to be safe," pay $30–50/month before their first user, then discover their queries would have run in microseconds on a file. Or they start on SQLite, grow, and face a rewrite because every query is hand-shaped SQL. Boring's answer: one variable selects the engine, every provider speaks the same verbs, and switching means changing a value — not rewriting callsites.
The keys.
DB_PROVIDER — sqlite (default), mongodb, or supabase. Anything else crashes at boot with the valid list — loud, immediate, greppable.
SQLITE_PATH — ./db/app.db locally. On the VPS it points at /var/data/app.db (persistent volume, survives deploys because ship.sh never syncs db/*.db*).
MONGODB_URI / SUPABASE_URL / SUPABASE_SERVICE_KEY — only read when their provider is selected. Commented out is fine; selecting the provider without them throws at first query, naming the missing key.
The agent rule (PocketOS lesson). Agents get least-privilege credentials only — never the service-role key, never the prod SQLite path. SQLite agents use the read-only getAgentDb() handle (no credential exists to leak). Supabase/Mongo agents get app_agent-scoped keys via AGENT_* vars, and SQLITE_PATH for agent sessions must point at ./db/staging.db, a seeded copy. If an agent asks for prod credentials, the answer is always no — the architecture assumes disobedience.
Verify. Flip DB_PROVIDER between values and run the same smoke test: register, create a board, post, vote. Identical results on all three is the parity contract. If one engine diverges, that's a bug — file it before working around it.
3. Migrations: staging by default, humans only in prod
The problem. An agent (or a tired founder) runs a migration against production at midnight. With most setups that's one command and no questions. Here prod refuses unless three things are simultaneously true: the --human flag, an interactive TTY, and MIGRATE_PROD_KEY — a secret that lives only in .env.prod, which is never mounted into agent sessions. Agents can fake flags; they can't fake a missing secret.
The keys.
MIGRATE_PROD_KEY — random 32+ chars, .env.prod only. Generate with openssl rand -base64 32. If it's missing, prod migrations refuse with the reason printed — including which of the three conditions failed.
Nothing else to set: npm run db:migrate targets staging, takes a backup checkpoint first, and applies additive-only DDL (ALTER TABLE ... ADD COLUMN). Destructive statements (DROP, TRUNCATE, unbounded DELETE) are blocked twice — by the SQL firewall at runtime and the --check scanner on migration files.
Verify. Run npm run db:migrate -- --check providers/db/supabase/schema.sql — exit 0 means additive-only. Deliberately test refusal: npm run db:migrate -- --target prod without the key must exit non-zero naming the missing gate.
4. Backups: the credentials that must not exist to agents
The problem. PocketOS lost data and backups, because both lived in the agent's reach. A backup the attacker can delete is decoration. So off-site credentials (BACKUP_*) live only in the human shell, are never synced to the server env, never mounted into agent sessions, and support Object Lock immutability — snapshots even a leaked credential can't delete for BACKUP_OBJECT_LOCK_DAYS.
The keys.
BACKUP_PROVIDER=r2, BACKUP_R2_ACCOUNT_ID, BACKUP_R2_ACCESS_KEY_ID, BACKUP_R2_SECRET_ACCESS_KEY, BACKUP_BUCKET_NAME — all five or nothing; partial config throws naming what's missing.
BACKUP_OBJECT_LOCK_DAYS=30 — optional but recommended. Immutability is the only defense that survives credential leakage.
Verify. Run boringpush db backup --dry-run — it prints what would upload without touching the network. Then a real local snapshot and confirm db/backups/ grows.
5. Billing: test mode is a trap with an expiry date
The problem. Two classic billing incidents: (a) launching in test mode and discovering real cards fail at the first real customer; (b) swapping providers and finding amounts, webhooks, and product IDs scattered across components. Here BILLING_PROVIDER selects dodo, stripe, or lemonsqueezy; amounts always come from the server product row (never the client, never the webhook); webhooks dedupe on paymentRef so retries grant exactly once.
The keys.
BILLING_PROVIDER=dodo (default). One value, whole checkout/portal/webhook surface follows.
DODO_MODE=test locally, live in prod. The single most important billing key: grep your prod env for test before every launch. Test-mode keys in prod fail at real cards with provider errors that look like your bug — they aren't.
DODO_PAYMENTS_API_KEY, DODO_PRODUCT_ID, DODO_WEBHOOK_KEY — all required at use-time; missing values throw Missing DODO_* naming the key instead of failing at the customer's card screen. Stripe/LemonSqueezy blocks follow the same pattern when selected.
Webhook endpoints verify signatures with the secret and reject unsigned payloads 401. If webhooks 401 in prod, the signing secret drifted (dashboard rotated, env stale) — compare both sides byte-for-byte, watching for trailing newlines from copy-paste.
Verify. Test checkout end-to-end in test mode, then repeat the same click path with DODO_MODE=live and a real card for $1 (refund it). If live fails where test passed, it's keys or mode — never code.
6. Analytics, storage, alerts, edge purge: the supporting cast
Analytics (ANALYTICS_ENABLED=true). Self-hosted page views — one first-party session cookie by default, none in cookie-free mode. No third-party script tax. ANALYTICS_DEBUG=true prints events to the terminal so you can watch a click land. If the dashboard shows zero visits while you're clicking, debug mode tells you in seconds whether events fire or the beacon is blocked.
Storage (STORAGE_PROVIDER=r2). Uploads presign direct-to-bucket so a 500MB file never touches your $5 server's RAM. R2 recommended ($0 egress); S3 identical interface. Missing bucket/creds now throw Missing S3_BUCKET_NAME (or R2 equivalents) at first upload — previously this surfaced as cryptic SigV4 403s three layers deep. If uploads 403 with keys present, the clock skew or region is wrong, not the keys.
Alerts (TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID, or DISCORD_WEBHOOK_URL). Payment failures and critical 500s go to your phone. Unset means console printing in dev — fine locally, silent in prod. Set these before launch; the first 3am outage without them is the one you remember.
Edge purge (CF_ZONE_ID / CF_CACHE_TOKEN). These live in your local shell (~/.zshrc), never in any .env — the deploy swap excludes .env*, so server envs can't leak them. After each deploy, changed URLs purge automatically; HTML edge TTL is 1 hour otherwise. If a fresh deploy looks stale, it's edge cache: open ?v=2 to confirm origin is correct, then purge that path.
When a key leaks: the 10-minute drill
Keys leak — pasted in a chat, screenshared, committed by accident. Speed matters more than secrecy theater:
Revoke at the provider first (Resend, Dodo, Cloudflare dashboards all rotate in one click), then update the env. Order matters: new key into a still-valid slot leaves a window; dead key first closes it.
Check git log -p -- .env* — if the secret ever touched git history, rotation alone isn't enough; treat it as public forever and rotate anything derived from it too.
Agent sessions get new staging keys, never the rotated prod ones. If an agent ever saw the old key, assume it can replay it until expiry — scope the blast radius by checking provider audit logs for calls you didn't make.
Write down what happened in one paragraph: which key, where it went, when revoked. The next leak goes faster because this one left a trail.
The 60-second audit
Before every launch, run this once:
grep -E "test|localhost|example|changeme" .env.prod && echo"STOP: placeholder values in prod" || echo"env looks prod-shaped"node bin/boringpush.mjs doctor
Placeholders in prod are the incident. The audit takes a minute; the outage takes a weekend.
Switchboard · one switch per concern — click them
your .env right now
DB_PROVIDER=sqlite· a file beside the app · $0 · microseconds
BILLING_PROVIDER=dodo· Merchant of Record · VAT handled
routes · components · providers — untouched. That's the deal.