Validating Environment Variables at Build Time

A missing STRIPE_WEBHOOK_SECRET becomes a 500 on the checkout page forty minutes after deploy. A typo’d NEXT_PUBLIC_API_URL becomes a blank screen only some users see. Both are configuration errors that a fifteen-line schema catches before the artifact is built.

When to use this pattern

  • Configuration errors reach production or preview and present as runtime failures.
  • Variables are read with process.env.X scattered across the codebase, with no single list.
  • You have several environments and no way to tell whether one is missing a key.

Prerequisites

Where the failure surfaces, with and without the check

The cost of a configuration error is almost entirely a function of how late it is discovered.

Where a Missing Variable Is Caught Without validation, the build succeeds, the deploy succeeds, and the failure appears as a 500 error in production after forty minutes, requiring investigation to attribute. With validation, the build job fails after eleven seconds with a message naming the missing variable and the environment. WITHOUT BUILD-TIME VALIDATION build βœ“ deploy βœ“ traffic served βœ“ 500 on /checkout, 40 minutes later stack trace points at Stripe, not at config Time to attribution: 20–90 minutes, and the deploy has already reached users. WITH BUILD-TIME VALIDATION build βœ— after 11s nothing was published βœ— STRIPE_WEBHOOK_SECRET: Required βœ— NEXT_PUBLIC_API_URL: Invalid url β€” got "htps://api.example.com" Time to attribution: zero. The error names the variable, the constraint, and the value it received. Both errors are reported together β€” a schema parse does not stop at the first failure.

Complete working example

// src/env.ts β€” the single place environment variables are read.
import { z } from 'zod';

// Server-only. Anything here must never reach the client bundle.
const serverSchema = z.object({
  NODE_ENV:               z.enum(['development', 'test', 'production']),
  DATABASE_URL:           z.string().url().startsWith('postgres://'),
  STRIPE_WEBHOOK_SECRET:  z.string().startsWith('whsec_').min(32),
  // Coerce, because every environment variable arrives as a string.
  SESSION_TTL_SECONDS:    z.coerce.number().int().positive().default(3600),
  // Optional with an explicit default beats an implicit undefined.
  LOG_LEVEL:              z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

// Client-exposed. The prefix is the contract: the bundler inlines these.
const clientSchema = z.object({
  NEXT_PUBLIC_API_URL:    z.string().url(),
  NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
});

// Guard against the mistake that actually leaks secrets: a private value
// accidentally given a public prefix, or referenced from client code.
for (const key of Object.keys(serverSchema.shape)) {
  if (key.startsWith('NEXT_PUBLIC_')) {
    throw new Error(`${key} is in the server schema but carries the public prefix`);
  }
}

function parse<T extends z.ZodTypeAny>(schema: T, label: string): z.infer<T> {
  const result = schema.safeParse(process.env);
  if (!result.success) {
    // Print EVERY problem, not just the first β€” one build should fix all of them.
    const lines = result.error.issues.map(
      (i) => `  βœ— ${i.path.join('.')}: ${i.message}`);
    throw new Error(`Invalid ${label} configuration:\n${lines.join('\n')}`);
  }
  return result.data;
}

export const env = parse(serverSchema, 'server');
export const publicEnv = parse(clientSchema, 'client');
// next.config.mjs β€” importing env.ts here runs the parse during the build itself.
import './src/env.ts';       // throws before a single page is compiled
export default { reactStrictMode: true };
# .github/workflows/validate-config.yml β€” check every environment before deploying.
name: Validate config
on: [pull_request, push]

jobs:
  validate:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false            # report all environments, not just the first broken one
      matrix:
        target: [preview, staging, production]
    environment: ${{ matrix.target }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci --ignore-scripts
      - name: Parse ${{ matrix.target }} configuration
        run: npx tsx src/env.ts
        env:
          NODE_ENV: production
          DATABASE_URL:           ${{ secrets.DATABASE_URL }}
          STRIPE_WEBHOOK_SECRET:  ${{ secrets.STRIPE_WEBHOOK_SECRET }}
          NEXT_PUBLIC_API_URL:    ${{ vars.NEXT_PUBLIC_API_URL }}

Step-by-step walkthrough

One module reads process.env, and nothing else does. The value of the schema comes from being exhaustive, and it is only exhaustive if no other file reads the environment directly. Enforce it with a lint rule β€” no-process-env in ESLint, with an exception for src/env.ts β€” or the list silently drifts out of date.

Coerce and constrain, do not merely check presence. SESSION_TTL_SECONDS arriving as the string "3600" and being used in arithmetic is a classic source of confusing behaviour. z.coerce.number().int().positive() converts it and rejects "soon". Similarly, DATABASE_URL being a URL is worth asserting, because a truncated value is present and useless.

Report every failure at once. A parse that throws on the first problem produces a fix-push-fail loop, one variable per round trip. Collecting all issues means one build tells you everything wrong with that environment.

Two Schemas, One Prefix Rule Server variables such as the database URL and webhook secret are parsed by the server schema and never leave the runtime. Public-prefixed variables are parsed by the client schema and inlined into the browser bundle. A guard rejects any server-schema key carrying the public prefix before the build proceeds. serverSchema DATABASE_URL STRIPE_WEBHOOK_SECRET clientSchema NEXT_PUBLIC_API_URL server runtime only never inlined inlined into the bundle visible to every visitor the guard a server key named NEXT_PUBLIC_SESSION_SECRET throws before the build starts β€” the exact mistake that ships a secret to browsers The prefix is a bundler contract, not a convention β€” which is why it can be enforced mechanically.

Two schemas, enforced by prefix. The public schema is what the bundler inlines into client JavaScript; the server schema must never be. The loop that rejects a public-prefixed name in the server schema catches the specific mistake β€” someone adding NEXT_PUBLIC_ to a private variable to β€œmake it work” β€” that turns a config error into a leaked secret.

Import it from the build config. Placing the import in next.config.mjs (or the equivalent) means the parse runs as the build starts. Validation that only runs at server startup is strictly later and catches strictly less.

Verification

# 1. A deliberately broken environment reports every problem at once.
DATABASE_URL=nope STRIPE_WEBHOOK_SECRET=short npx tsx src/env.ts; echo "exit=$?"

Expected:

Invalid server configuration:
  βœ— DATABASE_URL: Invalid url
  βœ— STRIPE_WEBHOOK_SECRET: String must start with "whsec_"
exit=1
# 2. Nothing outside src/env.ts reads process.env.
grep -rn 'process\.env\.' src --include='*.ts' --include='*.tsx' | grep -v 'src/env.ts' | head
#    β†’ no output

# 3. No private value made it into the client bundle.
npm run build
grep -rlE 'whsec_|postgres://' .next/static 2>/dev/null && echo "LEAK" || echo "clean"

The third check is the one worth wiring into CI permanently. It is a direct assertion about the shipped artifact rather than about intent, and it catches leaks introduced by any route β€” not only the prefix mistake the schema guards against.

How this relates to parity checking

Schema validation and the parity gate answer different questions and neither replaces the other. Validation asks β€œis this environment’s configuration internally valid?” β€” right types, right formats, nothing required missing. The parity check described in blocking deploys on config drift with a parity gate asks a different question: β€œdoes this environment have the same set of keys as production?”

An environment can pass validation and fail parity β€” every declared variable is present and well-formed, but production also has a key this one has never heard of, because it was added manually to production months ago. It can equally pass parity and fail validation: the key sets match exactly, and one value is a truncated URL. Running both, in that order, gives complete coverage of the configuration failure space, and it costs a few seconds in a job that is already running.

Schema Validation and Parity Check Cover Different Failures A two-by-two matrix. Passing both means the configuration is complete and well-formed. Passing validation but failing parity means an undeclared key exists in production. Passing parity but failing validation means a value is malformed. Failing both means the environment was never configured for this release. parity: key sets match parity: key sets differ schema valid safe to deploy complete and well-formed undeclared key in production schema alone would pass this schema invalid malformed value parity alone would pass this never configured a new environment, or a forgotten one Only the top-left cell is deployable, and only running both checks distinguishes it from the other three.

Common pitfalls

Defaulting a required secret. .default('') turns a hard failure into a subtle one β€” the application starts and misbehaves. Required means required.

Validating only in the deploy job. By then the artifact exists and the pipeline has spent its time. Validate at build, and re-validate at startup as a cheap backstop.

Letting the schema drift from reality. A variable removed from the schema but still read somewhere reintroduces the original problem. The no-process-env lint rule is what keeps the two in sync, and it belongs in the same pull request as the schema.

Keep the schema next to the code that consumes it rather than in a shared configuration package. A schema that lives in a separate repository drifts from the application within a release or two, and the drift is invisible until a variable the application still reads is quietly removed from the schema by someone tidying it.

← Back to Synchronizing Environment Variables Across Stages