Parse and validate env at startup: report every error, then exit Intermediate
Estimated reading time: 8 min read
This page answers: how to write one config module so that every bad key surfaces at startup, which keys may have defaults, and how to run locally with .env.
Env vars are strings
Section titled “Env vars are strings”process.env only holds strings (or undefined). Every number, boolean, enum and URL has to be parsed.
Node even converts on assignment: per the docs, assigning process.env.X = 3000 turns it into '3000', and this implicit conversion is deprecated.
The config loading flow
Section titled “The config loading flow”Loading diagram…
Before: process.env scattered everywhere
Section titled “Before: process.env scattered everywhere”const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | numberconst key = process.env.PAYMENT_API_SECRET_KEY ?? ''; // still runs when empty
// checkout.service.tsif (process.env.FEATURE_NEW_CHECKOUT) { /* ... */ } // 'false' is truthy
// main.tsawait app.listen(process.env.PORT || 3000);Problems:
- Errors appear at use time, not at startup. The empty key only shows up on the first refund request.
- Nobody can answer “which env vars does the service need” without grepping the whole repo.
- Each place parses differently: one uses
Number(), anotherparseInt, another does not parse at all.
After: one typed, frozen config module
Section titled “After: one typed, frozen config module”// config.ts (trimmed from the demo below)export function loadConfig(env: Env) { const e: string[] = []; const config = { nodeEnv: read(env, 'NODE_ENV', { parse: oneOf('development', 'test', 'production') }, e), http: { port: read(env, 'HTTP_PORT', { parse: int(1, 65535), default: '3000' }, e), }, db: { url: read(env, 'DATABASE_URL', { parse: url('postgres:', 'postgresql:') }, e), // no default }, payment: { baseUrl: read(env, 'PAYMENT_API_BASE_URL', { parse: url('https:') }, e), // no default secretKey: read(env, 'PAYMENT_API_SECRET_KEY', { parse: secretOf(16) }, e), // no default }, features: { newCheckout: read(env, 'FEATURE_NEW_CHECKOUT_ENABLED', { parse: bool, default: 'false' }, e), }, }; if (e.length > 0) throw new ConfigError(e); // collect every error before throwing return Object.freeze(config);}
// main.tslet config: AppConfig;try { config = loadConfig(process.env); // the ONLY place that reads process.env} catch (err) { console.error((err as Error).message); process.exit(1); // before listen(): no request has arrived yet}Three decisions worth making:
- Take
envas a parameter. Tests pass an object in; no need to mockprocess.env. - Collect every error before throwing. Reporting errors one at a time means fix one key, deploy, wait for the crash, fix the next key. 6 bad keys means 6 deploy rounds.
- Error messages never print secret values.
PAYMENT_API_SECRET_KEY: too shortis enough to fix it.
A real demo
Section titled “A real demo”The demo passes a simulated env into loadConfig; it does not read the machine’s real environment variables:
npx --prefix examples ts-node examples/src/config-twelve-factor/demo.tsReal output (Node v24.17.0):
=== 1. bad env ===Invalid config (6 errors): - NODE_ENV: must be one of development|test|production, got 'prod' - HTTP_PORT: must be an integer, got '3000abc' - DATABASE_URL: protocol must be postgres:|postgresql:, got mysql: - PAYMENT_API_BASE_URL: protocol must be https:, got http: - PAYMENT_API_SECRET_KEY: too short (needs >= 16 characters) - FEATURE_NEW_CHECKOUT_ENABLED: must be 'true' or 'false', got 'yes'→ production would process.exit(1) here, before listen()
=== 2. env missing entirely ===Invalid config (4 errors): - NODE_ENV: missing (required) - DATABASE_URL: missing (required) - PAYMENT_API_BASE_URL: missing (required) - PAYMENT_API_SECRET_KEY: missing (required)
=== 3. good env ===typeof http.port : number 8080features.newCheckout : falseJSON.stringify(payment) : {"baseUrl":"https://pay.example.com","secretKey":"[REDACTED]"}console.log(payment) : { baseUrl: 'https://pay.example.com', secretKey: [REDACTED] }reveal(secretKey).length : 24
=== 4. traps when reading raw process.env ===raw.HTTP_PORT === 3000 : falseraw.HTTP_PORT + 1 : 30001Boolean('false') : trueif ('false') takes branch : ONNumber('') : 0Number('3000abc') : NaN | parseInt: 3000Reading the output:
- Run 2 reports only 4 errors: keys with safe defaults (
HTTP_PORT,LOG_LEVEL…) are not required. Keys without defaults are the ones where a wrong guess causes harm. - Run 3: the secret is wrapped in an object with
toJSONand a custom inspect, so bothJSON.stringifyandconsole.logprint[REDACTED]. Onlyreveal()gets the value; grepping forreveal(tells you exactly where secrets are used. - Part 4 is why you should not use
parseIntorBoolean()on env:parseInt('3000abc')silently yields3000,Number('')yields0. Each trap is covered in Common mistakes.
With NestJS: @nestjs/config
Section titled “With NestJS: @nestjs/config”ConfigModule.forRoot() (package @nestjs/config, installed separately) supports two ways to validate. Per the docs, if validation fails the app does not bootstrap.
| Option | Use when | Notes from the docs |
|---|---|---|
validationSchema |
You already use Joi or a Standard Schema library (Zod, Valibot, ArkType) | Joi needs v18 or later; with Joi, defaults are allowUnknown: true, abortEarly: false (report every error) |
validate |
You want to write it yourself, like loadConfig above |
Takes the env object, returns validated config, throws to block bootstrap |
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true, validate: (raw) => loadConfig(raw), // raw: merged env, returns typed config }), ],})export class AppModule {}- When a key exists in both
.envand the real environment, the real environment wins (per the docs). Keep.envout of the production image, or setignoreEnvFile: true. - In services, use
configService.getOrThrow()instead ofget():getOrThrowthrows when the value isundefined, whilegetsilently returnsundefinedor a default.
Defaults: only for unimportant values
Section titled “Defaults: only for unimportant values”| May have a default | Never has a default |
|---|---|
HTTP_PORT=3000, LOG_LEVEL=info |
Secrets, passwords, API keys |
| Sensible pool size, timeout | Production URLs (DB, payment, webhook) |
Off flag (false) for a new feature |
NODE_ENV (missing must be reported, not guessed) |
- A default for a secret or production URL means it still runs with missing config, using a value someone wrote in the code last year. That is exactly the “alive and wrong” failure.
- A default DB URL like
postgres://localhost/appis even worse: in production it fails late, on a dev machine it points at the wrong DB.
Naming env vars
Section titled “Naming env vars”The detailed conventions are in Naming. Summary for config:
| Rule | Do | Avoid |
|---|---|---|
UPPER_SNAKE_CASE, prefix by group |
DATABASE_, PAYMENT_API_, FEATURE_ |
dbUrl, PayKey |
| Unit suffix | PAYMENT_API_TIMEOUT_MS=30000 |
TIMEOUT=30 (seconds or ms?) |
| Booleans in the affirmative | FEATURE_NEW_CHECKOUT_ENABLED |
DISABLE_OLD_CHECKOUT |
| One key, one meaning | CACHE_TTL_SECONDS, RETRY_MAX |
MODE=fast silently enabling three behaviors |
Local dev: .env and node --env-file
Section titled “Local dev: .env and node --env-file”| File | Commit? | Contents |
|---|---|---|
.env.example |
Yes | Every key the app needs, fake or empty values, comments with units |
.env |
No (.gitignore) |
Real values on the dev machine |
.env.example is living documentation: a newcomer copies it, fills it in, and can run the app. You can add a CI test that matches the keys in .env.example against the loadConfig schema.
Node has a built-in loader, so you do not need the dotenv package for simple cases:
node --env-file=.env dist/main.jsnode --env-file-if-exists=.env dist/main.js # no error if the file is missingWhat the Node docs state explicitly:
--env-fileexists since v20.6.0;--env-file-if-existssince v22.9.0. Both are no longer experimental as of v24.10.0 and v22.21.0.- Variables already in the environment win over values in the file. With multiple
--env-fileflags, later files override earlier ones. --env-filethrows if the file does not exist.process.loadEnvFile(path)(since v21.7.0, v20.12.0) does the same from inside code.
Do not use .env in production. There, env vars come from the orchestrator (ConfigMap, Secret, secret manager).
Next: Kubernetes and runtime.