Skip to content
Knowledge Hub

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 startup
payment: { 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 localhost default 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 | number

Prefer

// parse once; the rest of the app only sees numbers
const config = loadConfig(process.env);
config.http.port === 3000; // number === number
config.http.port + 1; // 3001

Why

With HTTP_PORT=3000, Node 24 prints:

process.env.HTTP_PORT === 3000 : false
process.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 : ON
env === true : false
Boolean(env) : true
  • Options 1 and 3: ops sets false to 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 check
const 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
  • parseInt reads up to the first non-digit character and stops silently: someone writes 30s and the timeout becomes 30 ms. Adding radix 10 does not save this case.
  • Number('') yields 0: a key left blank becomes pool size 0 or timeout 0.
  • setTimeout(fn, NaN) does not throw: Node prints TimeoutNaNWarning and 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 number
const 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 typo

Prefer

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 named 3000abc, and address() returns '3000abc'. Port 3000 is not open, probes fail, and the logs show no error.
  • listen(NaN) throws ERR_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

refund.service.ts
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/DI
constructor(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/refunds and the header Bearer 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.example drifts.
  • 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 value
payment: { 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=production to mirror prod. Then the branch above makes staging call the real payment API.
  • Conversely, any environment that sets NODE_ENV=staging skips 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.env

Why

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.

Baking secrets into the image

Common

FROM node:24-slim
ARG PAYMENT_API_SECRET_KEY
ENV PAYMENT_API_SECRET_KEY=$PAYMENT_API_SECRET_KEY
COPY .env.production ./.env

Prefer

FROM node:24-slim
# no ARG/ENV/COPY of secrets; if you need a token at build time, use a secret mount
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN npm ci
COPY dist ./dist
CMD ["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.production cannot 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: ConfigMap
metadata: { name: payment-api-config }
data:
PAYMENT_API_BASE_URL: https://api.pay.example.com
PAYMENT_API_SECRET_KEY: sk_live_...

Manifest to write

kind: ConfigMap
metadata: { 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 manager
metadata: { name: payment-api-secrets }
type: Opaque
stringData:
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 log
logger.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 toJSON and a custom inspect prints [REDACTED] with both JSON.stringify and console.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

Terminal window
kubectl edit configmap payment-api-config # set LOG_LEVEL=debug at 2 a.m.
# ...then go to bed, believing the app now logs at debug

Action to take

Terminal window
# change it in git, CI applies; the template has a checksum annotation → automatic rollout
# or at minimum:
kubectl rollout restart deployment/payment-api
kubectl exec deploy/payment-api -- printenv LOG_LEVEL # check the real value in the pod

Why

  • 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.
  1. One image for every environment, deployed by digest; the image contains no .env or secrets.
  2. Only one module reads process.env; everything else receives typed config via DI.
  3. Validate at startup, report every missing/bad key in one pass, exit(1) before listen().
  4. No defaults for secrets, production URLs or NODE_ENV. Defaults only for safe values.
  5. Secrets are wrapped so console.log / JSON.stringify do not print them; never log the whole config object.
  6. .env.example is committed and matches the schema; .env is in .gitignore.
  7. 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.
  8. 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.