Skip to content
Knowledge Hub

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.

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.

Loading diagram…

payment.client.ts
const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | number
const key = process.env.PAYMENT_API_SECRET_KEY ?? ''; // still runs when empty
// checkout.service.ts
if (process.env.FEATURE_NEW_CHECKOUT) { /* ... */ } // 'false' is truthy
// main.ts
await 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(), another parseInt, another does not parse at all.
// 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.ts
let 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:

  1. Take env as a parameter. Tests pass an object in; no need to mock process.env.
  2. 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.
  3. Error messages never print secret values. PAYMENT_API_SECRET_KEY: too short is enough to fix it.

The demo passes a simulated env into loadConfig; it does not read the machine’s real environment variables:

Terminal window
npx --prefix examples ts-node examples/src/config-twelve-factor/demo.ts

Real 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 8080
features.newCheckout : false
JSON.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 : false
raw.HTTP_PORT + 1 : 30001
Boolean('false') : true
if ('false') takes branch : ON
Number('') : 0
Number('3000abc') : NaN | parseInt: 3000

Reading 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 toJSON and a custom inspect, so both JSON.stringify and console.log print [REDACTED]. Only reveal() gets the value; grepping for reveal( tells you exactly where secrets are used.
  • Part 4 is why you should not use parseInt or Boolean() on env: parseInt('3000abc') silently yields 3000, Number('') yields 0. Each trap is covered in Common mistakes.

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 .env and the real environment, the real environment wins (per the docs). Keep .env out of the production image, or set ignoreEnvFile: true.
  • In services, use configService.getOrThrow() instead of get(): getOrThrow throws when the value is undefined, while get silently returns undefined or a default.
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/app is even worse: in production it fails late, on a dev machine it points at the wrong DB.

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
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:

Terminal window
node --env-file=.env dist/main.js
node --env-file-if-exists=.env dist/main.js # no error if the file is missing

What the Node docs state explicitly:

  • --env-file exists since v20.6.0; --env-file-if-exists since 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-file flags, later files override earlier ones.
  • --env-file throws 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.