The service boots, serves traffic for ten minutes, and then crashes on the first checkout with TypeError: Cannot read properties of undefined (reading 'split') because ALLOWED_CURRENCIES was never set; a Python worker treats RETRY_LIMIT="three" as truthy and loops forever; and when a developer fixes one missing variable, the next start reveals another, and then another. Configuration read ad hoc with process.env.X or os.environ.get("X") scattered through the code fails late, one variable at a time, with errors that do not mention the variable. This page validates all configuration once, at startup, against a typed schema, as part of environment variable validation.

The result is a single config object the rest of the code imports, and a startup that either succeeds with fully typed values or fails immediately listing every problem.

Diagnostic

Count how configuration is read today and what happens when a variable is missing:

#!/usr/bin/env bash
set -euo pipefail
echo "direct env reads: $(grep -rcE 'process\.env\.[A-Z_]+' src | awk -F: '{s+=$2} END {print s}') (node)  $(grep -rcE 'os\.(environ|getenv)' worker 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') (python)"
env -u ALLOWED_CURRENCIES -u PAYMENTS_WEBHOOK_SECRET node dist/server.js 2>&1 | head -3 &
sleep 3; curl -s -o /dev/null -w 'status after start: %{http_code}\n' http://localhost:3000/health || true
kill %1 2>/dev/null || true

Expected bad output:

direct env reads: 47 (node)  19 (python)
server listening on :3000
status after start: 200

The service starts and reports healthy with two required variables missing; the failures will come later, one at a time, in whichever code path reads them first.

When Ad-Hoc Config Errors Surface Timeline of a service starting with missing variables and failing later at first use of each one. When Ad-Hoc Config Errors Surface 00:00 starts, reports healthy 00:10 first checkout crashes 00:25 restart, next var missing 00:40 third var, third crash
Without startup validation, each missing variable is discovered separately and late.

Root cause

Environment variables are untyped strings or undefined. Code that reads them where they are used spreads configuration knowledge across dozens of files, converts types inconsistently ("false" is truthy in JavaScript, bool("0") is True in Python), and only fails when a particular path runs. Startup appears successful because nothing touched the missing value yet, so healthchecks pass and the failure surfaces under real traffic, with a stack trace pointing at a string method rather than at configuration. Fixing one variable reveals the next because nothing enumerates them all. A schema that describes every variable — type, format, default, whether it is required — validated in one place at startup, turns configuration into a single, checkable contract.

The local-development angle matters as much as the production one. A new hire whose .env is missing three keys should learn that in the first second of docker compose up, with the three names printed, not from three separate crashes spread over their first afternoon. Startup validation is effectively the most precise onboarding check a service can offer: it knows exactly what the service needs, because it is the service. Pairing it with the doctor script — which can call the same validation without starting the server — gives new developers one clear list of what to fix before anything else runs.

Scattered reads also make configuration impossible to review. Nobody can answer "which variables does this service need?" without grepping the codebase, and the answer changes silently with every pull request. A single schema file makes the full contract visible in one place and every change to it a reviewable diff.

Resolution

  1. Node: define a zod schema and export one parsed config object.
import { z } from 'zod';

const schema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  DATABASE_URL: z.string().url(),
  ALLOWED_CURRENCIES: z.string().transform((s) => s.split(',').map((c) => c.trim().toUpperCase())).pipe(z.array(z.string().length(3)).min(1)),
  PAYMENTS_WEBHOOK_SECRET: z.string().min(16),
  FEATURE_NEW_CHECKOUT: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  const issues = parsed.error.issues.map((i) => `  - ${i.path.join('.')}: ${i.message}`).join('\n');
  console.error(`Invalid configuration:\n${issues}`);
  process.exit(1);
}
export const config = parsed.data;

Save as src/config.ts and import config everywhere instead of reading process.env. z.coerce.number() turns "3000" into 3000; the explicit 'true' | 'false' enum avoids truthy-string bugs.

  1. Python: use pydantic-settings the same way.
from pydantic import Field, PostgresDsn, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
import sys

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    database_url: PostgresDsn
    retry_limit: int = Field(default=3, ge=0, le=10)
    queue_name: str = "jobs"
    feature_new_checkout: bool = False

try:
    settings = Settings()
except ValidationError as err:
    print("Invalid configuration:", file=sys.stderr)
    for e in err.errors():
        print(f"  - {'.'.join(str(p) for p in e['loc']).upper()}: {e['msg']}", file=sys.stderr)
    sys.exit(1)

retry_limit: int rejects "three"; bool accepts true/false/1/0/yes/no consistently.

  1. Never print secret values in errors. Both examples print only variable names and messages. Zod's messages do not include values for min or url failures, and pydantic's errors() messages omit input values when you print only loc and msg.

  2. Replace direct reads with the config object and block new ones:

#!/usr/bin/env bash
set -euo pipefail
git grep -nE 'process\.env\.' -- 'src/**' ':!src/config.ts' && { echo "read config from src/config.ts instead of process.env"; exit 1; } || echo "all config goes through src/config.ts"
One Validated Config Object Flow from raw environment variables through a schema to a typed config object or an aggregated error. One Validated Config Object process env strings schema parse types, rules all errors listed, exit 1 typed config imported everywhere
Every variable is checked before the server listens, and all problems are reported together.

Expected output

$ env -u ALLOWED_CURRENCIES -u PAYMENTS_WEBHOOK_SECRET PORT=abc node dist/server.js
Invalid configuration:
  - PORT: Expected number, received nan
  - ALLOWED_CURRENCIES: Required
  - PAYMENTS_WEBHOOK_SECRET: Required
$ echo $?
1

The service refuses to start and lists all three problems in one message; with valid configuration it starts with typed values, and no other module reads the environment directly.

Because the container exits with code 1 before listening, Compose's healthcheck never passes and docker compose up --wait fails with the service named — so a misconfigured local stack is caught at up time rather than on the first request. In production the same behaviour stops a deployment from rolling out a pod that would fail under traffic.

Prevention

  1. Run the config check in CI with the CI environment: a step that imports the config module and exits proves every required variable is present in the pipeline too.

  2. Generate .env.example from the schema so the example and the validation cannot disagree; see keeping .env.example files current.

  3. Keep the lint against direct reads so new code uses the config object.

Ad-Hoc Reads vs Startup Schema Comparison of reading environment variables where used against validating a schema at startup. Ad-Hoc Reads vs Startup Schema process.env where used schema at startup fails on first use fails before listening one error per restart all errors at once string truthiness bugs typed booleans, numbers 47 read sites one config module
The schema moves every configuration failure to startup and lists them together.

Platform caveats

Serverless and edge runtimes: some platforms inject environment variables lazily or per request; validate at module load in the handler file so cold starts fail fast rather than individual requests.

Python with .env files: pydantic-settings reads .env only if env_file is set; real environment variables take precedence over the file, which matches Compose behaviour.

TypeScript builds: frontends that inline import.meta.env at build time need validation at build time too; run the schema in the build script so a missing public variable fails the build.

Rollback

Keep the schema but downgrade failures to warnings temporarily if a deploy is blocked on a variable that is optional in practice:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- src/config.ts
npm run build

Frequently Asked Questions

Why validate at startup instead of where the variable is used?

Startup validation reports every problem at once, before the service accepts traffic, with the variable's name in the message. Validation at use time fails one variable at a time, under load, with unrelated stack traces.

How do I handle booleans in environment variables?

Parse them explicitly. In zod, accept 'true' | 'false' and transform to a boolean; in pydantic, declare the field as bool. Never rely on the truthiness of the raw string.

Will validation errors leak secrets into logs?

Not if you print only variable names and messages, as in the examples. Avoid logging the raw environment or the full error object, which may include input values.

Can the same schema drive documentation?

Yes. Descriptions and defaults in the schema can generate .env.example and a configuration reference, so documentation stays in step with validation.