Config & Environment
Twelve-factor apps store config in the environment. Use dotenv for local files, validate with zod (or similar) at boot, and fail closed on missing secrets.
Load .env only in development. Production should inject env via the platform.
| 1 | import "dotenv/config"; // or: |
| 2 | import dotenv from "dotenv"; |
| 3 | if (process.env.NODE_ENV !== "production") dotenv.config(); |
Parse once at startup; export a typed config object.
| 1 | import { z } from "zod"; |
| 2 | |
| 3 | const Env = z.object({ |
| 4 | NODE_ENV: z.enum(["development", "test", "production"]).default("development"), |
| 5 | PORT: z.coerce.number().int().positive().default(3000), |
| 6 | DATABASE_URL: z.string().url(), |
| 7 | LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), |
| 8 | }); |
| 9 | |
| 10 | export const env = Env.parse(process.env); |
- Strict separation of config from code
- No secrets in git — use a secret manager
- Different env per deploy (dev/stage/prod)
- Config is additive flags + connection strings
Also pin engines.node in package.json and enforce with CI or engine-strict.
Treat process.env as a contract with the OS and the Node.js event loop. Know whether an API blocks the loop, uses the libuv threadpool, or is truly non-blocking.
Debug questions: Am I holding the event loop? Leaking handles? Sharing state across processes? Handling partial failure? Timing out outbound I/O?
| Question | Healthy | Smell |
|---|---|---|
| CPU burn | Worker / child / addon | Sync loops on request path |
| I/O wait | Async fs/net/http | Sync fs in handlers |
| Crash survival | External store | RAM-only state |
| Shutdown | Drain + close | Hard exit mid-request |
Prefer promise APIs for process.env. Use node: import prefixes. Avoid mixing callback and async styles in one function.
| 1 | export function requireEnv(name) { |
| 2 | const v = process.env[name]; |
| 3 | if (!v) { |
| 4 | const err = new Error(`Missing env ${name}`); |
| 5 | err.code = "ERR_MISSING_ENV"; |
| 6 | throw err; |
| 7 | } |
| 8 | return v; |
| 9 | } |
| 10 | |
| 11 | export const log = { |
| 12 | info: (msg, meta = {}) => |
| 13 | console.log(JSON.stringify({ level: "info", msg, ...meta, t: Date.now() })), |
| 14 | error: (msg, meta = {}) => |
| 15 | console.error(JSON.stringify({ level: "error", msg, ...meta, t: Date.now() })), |
| 16 | }; |
note
Operational errors are expected at runtime (network, ENOENT). Programmer errors are bugs. Do not empty-catch.
| 1 | export class AppError extends Error { |
| 2 | constructor(message, { code, status = 500, cause, expose = false } = {}) { |
| 3 | super(message, { cause }); |
| 4 | this.name = "AppError"; |
| 5 | this.code = code; |
| 6 | this.status = status; |
| 7 | this.expose = expose; |
| 8 | } |
| 9 | } |
| 10 | |
| 11 | export function mapError(err) { |
| 12 | if (err?.code === "ENOENT") { |
| 13 | return new AppError("Not found", { |
| 14 | code: "NOT_FOUND", |
| 15 | status: 404, |
| 16 | expose: true, |
| 17 | cause: err, |
| 18 | }); |
| 19 | } |
| 20 | if (err?.code === "ETIMEDOUT") { |
| 21 | return new AppError("Upstream timeout", { |
| 22 | code: "TIMEOUT", |
| 23 | status: 504, |
| 24 | expose: true, |
| 25 | cause: err, |
| 26 | }); |
| 27 | } |
| 28 | return err; |
| 29 | } |
| Kind | Example | Action |
|---|---|---|
| Operational | ECONNRESET | Retry/backoff or 502 |
| Programmer | undefined path | Throw; fix |
| Operational | ENOSPC | Alert + fail write |
Servers, sockets, watchers, and timers keep the process alive. Close them on shutdown. Always set timeouts on outbound work.
| 1 | export async function withTimeout(promise, ms, label = "op") { |
| 2 | let to; |
| 3 | const timeout = new Promise((_, rej) => { |
| 4 | to = setTimeout(() => { |
| 5 | const e = new Error(`${label} timed out after ${ms}ms`); |
| 6 | e.code = "ETIMEDOUT"; |
| 7 | rej(e); |
| 8 | }, ms); |
| 9 | }); |
| 10 | try { |
| 11 | return await Promise.race([promise, timeout]); |
| 12 | } finally { |
| 13 | clearTimeout(to); |
| 14 | } |
| 15 | } |
warning
Node can open thousands of async operations. Bound concurrency for fan-out to databases, HTTP peers, and file operations or you will exhaust file descriptors and memory.
| 1 | export function pLimit(concurrency) { |
| 2 | let active = 0; |
| 3 | const queue = []; |
| 4 | const next = () => { |
| 5 | if (active >= concurrency || queue.length === 0) return; |
| 6 | active++; |
| 7 | const { fn, resolve, reject } = queue.shift(); |
| 8 | Promise.resolve() |
| 9 | .then(fn) |
| 10 | .then(resolve, reject) |
| 11 | .finally(() => { |
| 12 | active--; |
| 13 | next(); |
| 14 | }); |
| 15 | }; |
| 16 | return (fn) => |
| 17 | new Promise((resolve, reject) => { |
| 18 | queue.push({ fn, resolve, reject }); |
| 19 | next(); |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | const limit = pLimit(8); |
| 24 | export async function mapPool(items, fn) { |
| 25 | return Promise.all(items.map((item) => limit(() => fn(item)))); |
| 26 | } |
Exercise config-env with node:test, ephemeral ports, and deterministic fixtures.
| 1 | import { describe, it } from "node:test"; |
| 2 | import assert from "node:assert/strict"; |
| 3 | |
| 4 | describe("config-env", () => { |
| 5 | it("smoke", () => { |
| 6 | assert.equal(1 + 1, 2); |
| 7 | }); |
| 8 | }); |
pro tip
Emit structured logs, metrics (request rate, error rate, saturation), and traces. Correlate with a request id stored in AsyncLocalStorage.
| 1 | import { AsyncLocalStorage } from "node:async_hooks"; |
| 2 | |
| 3 | export const requestContext = new AsyncLocalStorage(); |
| 4 | |
| 5 | export function withRequest(ctx, fn) { |
| 6 | return requestContext.run(ctx, fn); |
| 7 | } |
| 8 | |
| 9 | export function getRequestId() { |
| 10 | return requestContext.getStore()?.requestId ?? "unknown"; |
| 11 | } |
info
- Document engines.node and run CI on that version
- Structured JSON logs with request ids
- SIGTERM drain + health/readiness probes
- Timeouts on every outbound dependency
- Redact secrets from logs
- Load-test the config-env hot path
- OpenTelemetry traces for latency
best practice
| Mistake | Why | Fix |
|---|---|---|
| Sync I/O on requests | Blocks loop | Async / workers |
| Swallow errors | Silent loss | Log + propagate |
| No timeouts | Hung sockets | AbortSignal |
| Unbounded fan-out | OOM / FD limit | p-limit |
| Trust raw input | Crashes | Zod validate |
| Hard exit | 502 on deploy | Graceful drain |
Anything touching paths, URLs, child processes, crypto, or network input needs validation. Never interpolate untrusted strings into shells or file paths near process.env.
- Validate and normalize paths with path.resolve + root jail
- Prefer spawn with argument arrays over shell exec
- Use timingSafeEqual for secret compares
- Set restrictive CORS and security headers on HTTP APIs
danger
Measure before optimizing config-env. Use node --cpu-prof, clinic.js, or OpenTelemetry. Watch event-loop delay metrics.
| 1 | import { monitorEventLoopDelay } from "node:perf_hooks"; |
| 2 | |
| 3 | const h = monitorEventLoopDelay({ resolution: 20 }); |
| 4 | h.enable(); |
| 5 | setInterval(() => { |
| 6 | console.log({ |
| 7 | meanMs: (h.mean / 1e6).toFixed(2), |
| 8 | p99Ms: (h.percentile(99) / 1e6).toFixed(2), |
| 9 | }); |
| 10 | h.reset(); |
| 11 | }, 5000).unref(); |
When do I use this topic?
Reach for process.env when its responsibility matches the problem. Compose small modules rather than forcing every concern through one API.
Cross-platform?
Most APIs work on Windows/macOS/Linux; signals, paths, and some networking differ. Test where you deploy.
Agent verification
Generate a runnable example, run tests, fail closed if timeouts/errors/shutdown are missing.
| 1 | curl -s "https://forgelearn.dev/api/markdown?path=nodejs/config-env" |
| 2 | curl -s https://forgelearn.dev/api/agent?curriculum=nodejs |
| 3 | curl -s https://forgelearn.dev/api/agent?skill=forgelearn-nodejs |
Retry with jitter
Transient failures around process.env often need bounded retries. Cap attempts and use full-jitter backoff.
| 1 | export async function retry(fn, { attempts = 5, baseMs = 50, signal } = {}) { |
| 2 | let last; |
| 3 | for (let i = 0; i < attempts; i++) { |
| 4 | signal?.throwIfAborted?.(); |
| 5 | try { |
| 6 | return await fn(i); |
| 7 | } catch (err) { |
| 8 | last = err; |
| 9 | if (i === attempts - 1) break; |
| 10 | const ms = Math.random() * baseMs * 2 ** i; |
| 11 | await new Promise((r) => setTimeout(r, ms)); |
| 12 | } |
| 13 | } |
| 14 | throw last; |
| 15 | } |
Circuit breaker sketch
Trip open after consecutive failures; half-open after a cool-down to probe recovery.
| 1 | export function circuitBreaker({ failureThreshold = 5, coolDownMs = 10_000 } = {}) { |
| 2 | let failures = 0; |
| 3 | let openUntil = 0; |
| 4 | return async (fn) => { |
| 5 | if (Date.now() < openUntil) { |
| 6 | const e = new Error("circuit open"); |
| 7 | e.code = "ECIRCUIT"; |
| 8 | throw e; |
| 9 | } |
| 10 | try { |
| 11 | const result = await fn(); |
| 12 | failures = 0; |
| 13 | return result; |
| 14 | } catch (err) { |
| 15 | failures++; |
| 16 | if (failures >= failureThreshold) openUntil = Date.now() + coolDownMs; |
| 17 | throw err; |
| 18 | } |
| 19 | }; |
| 20 | } |
Idempotent boot
Initialize clients once; reuse across requests. Lazy-init with a promise singleton.
| 1 | let clientPromise; |
| 2 | export function getClient(factory) { |
| 3 | if (!clientPromise) clientPromise = factory(); |
| 4 | return clientPromise; |
| 5 | } |
These anti-patterns show up repeatedly in Node services touching process.env. Agents should fail closed if generated code exhibits them.
| Anti-pattern | Symptom | Replace with |
|---|---|---|
| Callback hell + ignored err | Silent wrong results | async/await + throw |
| Global mutable singleton race | Flaky tests | DI + ALS context |
| Fire-and-forget promises | Unhandled rejection | void track(promise) |
| Busy spin wait | 100% CPU | events / condition vars |
| String-built shell | RCE | spawn args array |
| Unbounded cache Map | OOM | LRU with TTL |
| 1 | export function track(promise, label = "bg") { |
| 2 | promise.catch((err) => { |
| 3 | console.error(JSON.stringify({ level: "error", msg: "background failure", label, err: String(err) })); |
| 4 | }); |
| 5 | } |
pro tip
APIs around process.env evolve across Node majors. Read changelogs when upgrading. Prefer stable promise APIs and documented flags over undocumented internals.
- Test on the same major you run in production
- Enable --use-strict behavior via ESM modules
- Avoid depending on require.extensions hacks
- Document any experimental flags in README and CI
| 1 | node -p process.versions |
| 2 | npm ls --depth=0 |
| 3 | # CI matrix: 20.x LTS, 22.x LTS |
Before claiming mastery of config-env, generated artifacts must pass this checklist.
- Uses node: import prefixes where applicable
- Errors have codes and are not swallowed
- Outbound I/O has timeouts / AbortSignal
- Resources closed in finally / shutdown
- No shell interpolation of user input
- Tests cover at least one failure path
- Logs are structured; secrets redacted
danger
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.