--- title: Environment variables description: How configuration is declared, validated and composed. type: reference --- # Environment variables ## The pattern Every package that needs configuration exports a `keys()` factory built with `@t3-oss/env-nextjs` and Zod: ```ts // 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`: ```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 with `bunx @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 1. Add it to the owning package's `keys.ts`, under `server` or `client`. 2. Add it to `runtimeEnv` — this step is easy to forget and the value is silently `undefined` without it. 3. Add it to the relevant `.env.example` files with an empty value. 4. 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`. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)