Validate environment variables once, before the server starts listening: check each is present, convert it to the right type, check it against allowed values, and throw with a clear message if anything is wrong. A service that refuses to start is safer than one that runs with a wrong setting.
Why startup is the right time
A bad value found at start fails a deploy, which is cheap and visible. The same value found on the first request fails a user, and a missing security setting may not fail at all. It may quietly choose a permissive default. Node.js stores every environment value as a string, so even a valid number needs conversion and a check.
Step by step
- List every variable the app reads. Search for
process.envand put the results in a table with name, type, allowed values and whether it is secret. - Centralize reads. One module reads
process.env. Everything else imports typed values from it. - Check presence. Treat both a missing variable and an empty string as missing.
- Convert and range check. Numbers must be finite and inside a sane range. Booleans must be exactly "true" or "false", not truthy strings.
- Check against allow lists. A mode or environment name should be one of a fixed set, so a typo cannot select an untested path.
- Collect all errors, then throw once. Reporting every problem in one run saves repeated redeploys.
- Never print secret values. Print names and the reason, not the value.
- Run the check in CI and in the image. Boot the app with a test config in a pipeline step.
A small example without a library
type Config = { port: number; mode: "dev" | "prod"; debug: boolean };
export function loadConfig(env: NodeJS.ProcessEnv): Config {
const errors: string[] = [];
const port = Number(env.PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
errors.push("PORT must be an integer from 1 to 65535");
}
const mode = env.APP_MODE;
if (mode !== "dev" && mode !== "prod") {
errors.push("APP_MODE must be dev or prod");
}
const debug = env.DEBUG;
if (debug !== "true" && debug !== "false") {
errors.push("DEBUG must be true or false");
}
if (errors.length > 0) {
throw new Error("Invalid configuration: " + errors.join("; "));
}
return { port, mode: mode as Config["mode"], debug: debug === "true" };
}
Passing env as an argument makes the function easy to test with plain objects. A schema library can replace the hand-written checks if you prefer one, and the steps stay the same.
Using env files
Node.js can load a file with --env-file. The CLI docs say that if a variable exists in both the environment and the file, the environment value wins, and that an error is thrown if the file is missing. --env-file-if-exists skips that error. The permission model docs also note --env-file is not subject to the permission model, since it is read at startup. For the comparison with the dotenv package see the env file flag versus dotenv.
What validation cannot do
It confirms a value has the right shape. It cannot tell you whether a secret is the correct secret, or whether it has leaked. Keep secrets in a manager where you can, and rotate them.
Quick check
Should a missing optional variable stop startup?
No, but give it an explicit default in code, and never default a security setting to the permissive value.
Is it safe to log the config object on startup?
Only if secrets are excluded. Log names and non-secret values, never keys or tokens.
Sources
Last reviewed: 19 October 2026.