Setup

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

FileContents
apps/app/.env.localAuth, database, analytics, email
apps/web/.env.localCMS, email, rate limiting
apps/api/.env.localAuth, database, payments
packages/database/.envDATABASE_URL
.env.dockerThe 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.

VariableEnables
NEXT_PUBLIC_POSTHOG_KEY / _HOSTAnalytics and feature flags
NEXT_PUBLIC_GA_MEASUREMENT_IDGoogle Analytics
RESEND_TOKEN / RESEND_FROMOutbound email
STRIPE_SECRET_KEY / _WEBHOOK_SECRETPayments
BASEHUB_TOKENBlog and legal content
REDIS_URLRate limiting
S3_*File storage
KNOCK_SECRET_API_KEYNotifications
GITHUB_ / GOOGLE_CLIENT_ID & _SECRETSocial 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.