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.Xscattered 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.
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, 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.
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.
Related
- Synchronizing Environment Variables Across Stages β the parent guide on where configuration comes from.
- Blocking Deploys on Config Drift with a Parity Gate β the key-set half of the same problem.
- Secrets Injection for Preview Environments β how the values reach the runtime being validated.
- Preview Environments & Environment Parity β the section overview.
β Back to Synchronizing Environment Variables Across Stages