Common mistakes when reading config and env vars in Node.js Intermediate
Estimated reading time: 11 min read
This page collects the config-reading lines developers commonly write, what happens in production, and how to fix them. The output in each “Why” section is real, run on Node v24.17.0.
The root of nearly every mistake below: process.env only holds strings or undefined. The general fix is one config module that parses and validates at startup; see Parse and validate env.
Types: strings pretending to be numbers and booleans
Section titled “Types: strings pretending to be numbers and booleans”Falling back on important values with || or ??
Common
const secretKey = process.env.PAYMENT_API_SECRET_KEY ?? '';const dbUrl = process.env.DATABASE_URL || 'postgres://localhost:5432/app';await app.listen(process.env.PORT || 3000);Prefer
// config.ts: important keys have NO default; missing means exit(1) at startuppayment: { secretKey: read(env, 'PAYMENT_API_SECRET_KEY', { parse: secretOf(16) }, e) },db: { url: read(env, 'DATABASE_URL', { parse: url('postgres:', 'postgresql:') }, e) },http: { port: read(env, 'HTTP_PORT', { parse: int(1, 65535), default: '3000' }, e) },Why
Why:
- If the key is misnamed in the Secret, the app still starts, the health check returns 200, then every request to the provider gets
401. The error shows up under traffic, not at deploy time. - A
localhostdefault for the DB: in production it fails late on the first query; on a dev machine it may point at another project’s DB. - Defaults are only for values where a wrong guess does no harm (port, log level, pool size).
Comparing env to a number, adding a number to env
Common
if (process.env.HTTP_PORT === 3000) { /* never runs */ }const nextPort = process.env.HTTP_PORT + 1; // '30001'const timeout = process.env.PAYMENT_TIMEOUT || 5000; // string | numberPrefer
// parse once; the rest of the app only sees numbersconst config = loadConfig(process.env);config.http.port === 3000; // number === numberconfig.http.port + 1; // 3001Why
With HTTP_PORT=3000, Node 24 prints:
process.env.HTTP_PORT === 3000 : falseprocess.env.HTTP_PORT + 1 : 30001|| also produces a string | number type: with the env set it is '30000', without it 5000. Downstream code handles two different types depending on the environment.
'false' is truthy, and === true is never true
Common
if (process.env.FEATURE_NEW_CHECKOUT_ENABLED) enableNewCheckout();if (process.env.FEATURE_NEW_CHECKOUT_ENABLED === true) enableNewCheckout();const enabled = Boolean(process.env.FEATURE_NEW_CHECKOUT_ENABLED);Prefer
const bool = (v: string) => { if (v === 'true') return true; if (v === 'false') return false; throw new Error(`must be 'true' or 'false', got '${v}'`); // 'yes', '1' are config errors};newCheckout: read(env, 'FEATURE_NEW_CHECKOUT_ENABLED', { parse: bool, default: 'false' }, e),Why
With FEATURE_NEW_CHECKOUT_ENABLED=false, Node 24 prints:
if (env) takes branch : ONenv === true : falseBoolean(env) : true- Options 1 and 3: ops sets
falseto turn off a broken feature, and the feature stays on. The kill switch is useless exactly when it is needed. - Option 2: it can never be turned on, not even with
'true'.
parseInt and Number() swallow bad values
Common
const timeoutMs = parseInt(process.env.PAYMENT_API_TIMEOUT_MS); // no radix, no NaN checkconst poolMax = Number(process.env.DATABASE_POOL_MAX);setTimeout(abort, timeoutMs);Prefer
function intEnv(raw: string | undefined, key: string): number { if (raw === undefined || raw.trim() === '') throw new Error(`${key}: missing`); if (!/^\d+$/.test(raw.trim())) throw new Error(`${key}: must be an integer, got '${raw}'`); return Number(raw);}Why
Node 24, the same string through both functions:
| Env value | parseInt(v) |
Number(v) |
intEnv |
|---|---|---|---|
'30s' |
30 |
NaN |
error |
'0x1F' |
31 |
31 |
error |
'1e3' |
1 |
1000 |
error |
'' |
NaN |
0 |
error: missing |
parseIntreads up to the first non-digit character and stops silently: someone writes30sand the timeout becomes 30 ms. Adding radix10does not save this case.Number('')yields0: a key left blank becomes pool size0or timeout0.setTimeout(fn, NaN)does not throw: Node printsTimeoutNaNWarningand sets the timeout to 1 ms.
Number(x) || default wipes out a valid 0
Common
const poolMin = Number(process.env.DATABASE_POOL_MIN) || 5;const retryMax = Number(process.env.PAYMENT_RETRY_MAX) || 3;Prefer
// decide "missing" on the RAW STRING, not on the parsed numberconst raw = env.DATABASE_POOL_MIN?.trim();const poolMin = raw === undefined || raw === '' ? 5 : intEnv(raw, 'DATABASE_POOL_MIN');Why
With DATABASE_POOL_MIN=0, Number('0') || 5 yields 5 (Node 24). When ops wants to disable retries with PAYMENT_RETRY_MAX=0, the app still retries 3 times and may charge 3 times if the endpoint is not idempotent.
Passing the raw env string to listen()
Common
server.listen(process.env.PORT); // PORT='3000abc' from a typoPrefer
server.listen(config.http.port); // number validated in [1, 65535]Why
server.listen() accepts both a port and an IPC path. Tried on Node 24:
listen('3000abc')raises no error: the server listens on a Unix socket named3000abc, andaddress()returns'3000abc'. Port 3000 is not open, probes fail, and the logs show no error.listen(NaN)throwsERR_SOCKET_BAD_PORT: at least it dies immediately.
Structure: where env is read, and keyed on what
Section titled “Structure: where env is read, and keyed on what”process.env scattered across the codebase
Common
const res = await fetch(`${process.env.PAYMENT_API_BASE_URL}/refunds`, { headers: { Authorization: `Bearer ${process.env.PAYMENT_API_SECRET_KEY}` },});Prefer
// refund.service.ts: receives typed config via constructor/DIconstructor(private readonly payment: AppConfig['payment']) {}
const res = await fetch(`${this.payment.baseUrl}/refunds`, { headers: { Authorization: `Bearer ${reveal(this.payment.secretKey)}` },});Why
- With a missing key the URL becomes
undefined/refundsand the headerBearer undefined. The error blows up on the first refund request, possibly hours after the deploy. - Nobody can list which env vars the service needs without grepping the whole repo;
.env.exampledrifts. - Tests must mock the global
process.env, which easily leaks between tests.
Using NODE_ENV for business decisions
Common
const paymentBaseUrl = process.env.NODE_ENV === 'production' ? 'https://api.pay.example.com' : 'https://sandbox.pay.example.com';
if (process.env.NODE_ENV !== 'production') skipWebhookSignatureCheck = true;Prefer
// one key per behavior; each environment sets an explicit valuepayment: { baseUrl: read(env, 'PAYMENT_API_BASE_URL', { parse: url('https:') }, e) },webhook: { verifySignature: read(env, 'WEBHOOK_VERIFY_SIGNATURE_ENABLED', { parse: bool, default: 'true' }, e) },Why
- Many teams run staging with
NODE_ENV=productionto mirror prod. Then the branch above makes staging call the real payment API. - Conversely, any environment that sets
NODE_ENV=stagingskips webhook signature checks: anyone can send a fake webhook. - 12-factor calls this grouping config into “environments”, which does not scale as environments are added. Frameworks and libraries also read
NODE_ENV, so do not load business meaning onto it.
Assigning undefined to clear an env var in tests
Common
afterEach(() => { process.env.FEATURE_NEW_CHECKOUT_ENABLED = undefined;});Prefer
afterEach(() => { delete process.env.FEATURE_NEW_CHECKOUT_ENABLED;});// better still: loadConfig({ ...fakeEnv }) never touches process.envWhy
process.env converts every assigned value to a string. Node 24: after assigning undefined, the value is the string "undefined", and Boolean(...) yields true. The next test sees the flag on even though it was supposedly cleared; tests pass or fail depending on run order.
Secrets: image, ConfigMap, logs
Section titled “Secrets: image, ConfigMap, logs”Baking secrets into the image
Common
FROM node:24-slimARG PAYMENT_API_SECRET_KEYENV PAYMENT_API_SECRET_KEY=$PAYMENT_API_SECRET_KEYCOPY .env.production ./.envPrefer
FROM node:24-slim# no ARG/ENV/COPY of secrets; if you need a token at build time, use a secret mountRUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ciCOPY dist ./distCMD ["node", "dist/main.js"]Why
- Docker docs: build args and env vars “are inappropriate for passing secrets” because “they persist in the final image”. Anyone who can pull the image can read the key.
- An image containing
.env.productioncannot be promoted from staging to production: it must be built separately, which means running an untested artifact. - Rotation requires rebuilding the image instead of just updating the Secret.
Putting secrets in a ConfigMap
Common manifest
kind: ConfigMapmetadata: { name: payment-api-config }data: PAYMENT_API_BASE_URL: https://api.pay.example.com PAYMENT_API_SECRET_KEY: sk_live_...Manifest to write
kind: ConfigMapmetadata: { name: payment-api-config }data: PAYMENT_API_BASE_URL: https://api.pay.example.com---kind: Secret # + encryption at rest, narrow RBAC, values from a secret managermetadata: { name: payment-api-secrets }type: OpaquestringData: PAYMENT_API_SECRET_KEY: <injected from secret manager, not committed>Why
- Kubernetes docs: a ConfigMap “does not provide secrecy or encryption”. Someone with read access to ConfigMaps only for debugging can also read the key.
- ConfigMaps usually live in git; the secret then lands in git history forever.
- A Secret is also just base64, so you still need encryption at rest and RBAC (see Kubernetes and runtime).
Logging the whole config object at startup
Common
const config = { databaseUrl: process.env.DATABASE_URL, // postgres://app:p4ssw0rd@... paymentSecretKey: process.env.PAYMENT_API_SECRET_KEY, logLevel: process.env.LOG_LEVEL,};logger.info({ config }, 'config loaded'); // "for debugging"Prefer
const config = loadConfig(process.env); // secrets wrapped, toJSON → '[REDACTED]'// explicitly pick non-secret values to loglogger.info({ nodeEnv: config.nodeEnv, port: config.http.port, logLevel: config.logLevel }, 'config loaded');Why
- One log line puts the DB password and API key into the log store, which more people read than the Secret and which is kept for months. After you rotate the key, the old one is still in the logs.
- The demo on Parse and validate env shows that a secret wrapped in an object with
toJSONand a custom inspect prints[REDACTED]with bothJSON.stringifyandconsole.log. - To know what was loaded: log key names, or explicitly chosen non-secret values.
Editing a ConfigMap and assuming it is applied
Common action
kubectl edit configmap payment-api-config # set LOG_LEVEL=debug at 2 a.m.# ...then go to bed, believing the app now logs at debugAction to take
# change it in git, CI applies; the template has a checksum annotation → automatic rollout# or at minimum:kubectl rollout restart deployment/payment-apikubectl exec deploy/payment-api -- printenv LOG_LEVEL # check the real value in the podWhy
- Kubernetes docs: ConfigMaps consumed as env vars “are not updated automatically and require a pod restart”. Running pods still use the old value.
- Editing a ConfigMap does not change the pod template, so the Deployment does not roll out by itself.
- Manual edits on the cluster create drift: the next manifest apply overwrites them, and nobody knows why behavior changed.
Production checklist
Section titled “Production checklist”- One image for every environment, deployed by digest; the image contains no
.envor secrets. - Only one module reads
process.env; everything else receives typed config via DI. - Validate at startup, report every missing/bad key in one pass,
exit(1)beforelisten(). - No defaults for secrets, production URLs or
NODE_ENV. Defaults only for safe values. - Secrets are wrapped so
console.log/JSON.stringifydo not print them; never log the whole config object. .env.exampleis committed and matches the schema;.envis in.gitignore.- Kubernetes: secrets in a Secret (with encryption at rest and RBAC), not in a ConfigMap; config changes come with a rollout via a checksum annotation.
- Every feature flag has an owner and a removal date; secrets have a rotation process with a phase where two keys are valid at once.
Back to: Config and 12-factor overview.