Configuration, environment and CLI arguments
process.env, env files and --env-file, a config shape that fails fast, and parsing command-line arguments properly.
process.env and env files
# the shell way
export DATABASE_URL=postgres://localhost/app
PORT=8080 NODE_ENV=production node server.js
# Node 20.6+ reads a file itself, no dotenv import needed
node --env-file=.env server.js
node --env-file-if-exists=.env.local server.jsconst required = ["DATABASE_URL", "JWT_SECRET"];
for (const key of required) {
if (!process.env[key]) {
throw new Error("missing required environment variable: " + key);
}
}
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be a valid port number");
}- Every environment value is a string.
"false","0"and""are all truthy strings, so convert withNumber()or an explicit=== "true". - An unset variable is
undefined; an exported-but-empty one is"". Distinguishing the two matters for optional settings. - A child process inherits the parent environment, so anything exported is available to every tool you spawn.
- Commit a
.env.exampledocumenting every key, and put.envin.gitignore.
A config shape that fails fast
Read the environment once, at startup, and turn it into a validated object. Scattering process.env lookups through the codebase makes the required settings invisible and moves failures from boot to the middle of a request.
export const config = Object.freeze({
env: process.env.NODE_ENV ?? "development",
port: Number(process.env.PORT ?? 3000),
db: {
url: process.env.DATABASE_URL,
poolSize: Number(process.env.DB_POOL_SIZE ?? 10),
},
features: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === "true",
},
});
if (config.env === "production" && !config.db.url) {
throw new Error("refusing to start: DATABASE_URL is not configured");
}| Concern | Do | Do not |
|---|---|---|
| When to read | Once at startup, in one module | Inside every function that needs a value |
| Missing value | Throw with the variable name | Silently fall back to a dev default |
| Types | Convert and validate at the boundary | Trust that an env var is a number |
| Secrets | Inject from the platform's secret store | Commit them or print them in logs |
| Environments | Same build, different environment | One build artifact per environment |
⚠️
Never log
process.env wholesale or dump the config object at startup. A single debug line is enough to leak a database password or an API key into logs that many people can read.Command-line arguments
// node report.js --out=dist --verbose -- extra positional values
import { parseArgs } from "node:util";
const { values, positionals } = parseArgs({
options: {
out: { type: "string", short: "o", default: "dist" },
verbose: { type: "boolean", default: false },
limit: { type: "string" },
},
allowPositionals: true,
});
// process.argv[0] is the node binary, [1] is the script path
console.log(values.out, values.verbose, positionals);- Everything in
process.argvis a raw string; parsing is your job unless you usenode:utilparseArgs. - Validate argument values the same way you validate configuration — a bad
--limitshould fail on line one, not after an hour of work. - Exit with code 2 for a usage error and 1 for a runtime failure, so scripts and CI can tell them apart.
- Provide
--helpand print usage to stdout; print errors to stderr. - Use the
--separator when forwarding arguments to another tool so its flags are not eaten by your parser.
FAQ
Do I still need the dotenv package?
Only for older runtimes or for custom loading rules such as per-environment layering.
--env-file and --env-file-if-exists cover the common case with no dependency.How do I keep a secret out of the image but available at run time?
Inject it as an environment variable or mount it as a file from the platform's secret store. Either way, fail fast when it is missing and keep it out of logs, crash reports and error messages.
Related
Errors, logging and debugging Security, performance and deployment
Last refreshed 2026-09-18.