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.js
const 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 with Number() 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.example documenting every key, and put .env in .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");
}
ConcernDoDo not
When to readOnce at startup, in one moduleInside every function that needs a value
Missing valueThrow with the variable nameSilently fall back to a dev default
TypesConvert and validate at the boundaryTrust that an env var is a number
SecretsInject from the platform's secret storeCommit them or print them in logs
EnvironmentsSame build, different environmentOne 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.argv is a raw string; parsing is your job unless you use node:util parseArgs.
  • Validate argument values the same way you validate configuration — a bad --limit should 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 --help and 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.

Errors, logging and debugging Security, performance and deployment

Last refreshed 2026-09-18.