Environment variables
How configuration is declared, validated and composed.
The pattern
Every package that needs configuration exports a keys() factory built with
@t3-oss/env-nextjs and Zod:
// packages/rate-limit/keys.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const keys = () =>
createEnv({
skipValidation: process.env.SKIP_ENV_VALIDATION === 'true',
server: {
REDIS_URL: z.string().optional(),
},
runtimeEnv: {
REDIS_URL: process.env.REDIS_URL,
},
});Each app composes the ones it actually uses in its env.ts:
// apps/web/env.ts
export const env = createEnv({
extends: [analytics(), cms(), core(), email(), rateLimit()],
// ...
});Validation runs at boot. A malformed value fails immediately with a message naming the variable, rather than surfacing as a confusing runtime error later.
Where files live
| File | Contents |
|---|---|
apps/app/.env.local | Auth, database, analytics, email |
apps/web/.env.local | CMS, email, rate limiting |
apps/api/.env.local | Auth, database, payments |
packages/database/.env | DATABASE_URL |
.env.docker | The containerised stack |
init seeds all of them from the matching .env.example.
Required
Only two.
DATABASE_URL— Postgres connection string.BETTER_AUTH_SECRET— at least 32 bytes, generated withbunx @better-auth/cli secret. It signs session cookies, so it must be identical across every container that reads sessions and different in every environment.
Optional
Everything else. A missing key disables its feature rather than breaking the
build: clients are undefined and call sites use optional chaining.
| Variable | Enables |
|---|---|
NEXT_PUBLIC_POSTHOG_KEY / _HOST | Analytics and feature flags |
NEXT_PUBLIC_GA_MEASUREMENT_ID | Google Analytics |
RESEND_TOKEN / RESEND_FROM | Outbound email |
STRIPE_SECRET_KEY / _WEBHOOK_SECRET | Payments |
BASEHUB_TOKEN | Blog and legal content |
REDIS_URL | Rate limiting |
S3_* | File storage |
KNOCK_SECRET_API_KEY | Notifications |
GITHUB_ / GOOGLE_CLIENT_ID & _SECRET | Social sign-in |
Inter-app URLs
NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_WEB_URL are required; the API and docs
URLs are optional. They default to the local ports and should become real
origins in production.
Adding a variable
- Add it to the owning package's
keys.ts, underserverorclient. - Add it to
runtimeEnv— this step is easy to forget and the value is silentlyundefinedwithout it. - Add it to the relevant
.env.examplefiles with an empty value. - If it is a new external origin, extend the CSP in
packages/security/proxy.ts.
CI and Docker builds
Both run with SKIP_ENV_VALIDATION=true, because neither has secrets and
neither needs them: builds are static, and validation happens when the
container boots.
NEXT_PUBLIC_* values are inlined into the client bundle at build time. In
Docker they are baked in as placeholders and substituted when the container
starts, so a single image serves every environment. Adding a new public
variable means adding it to both the Dockerfile's builder ENV block and
docker-entrypoint.sh.