NodeJSConfiguration Management

Configuration Management

Every non-trivial app has settings that change between environments: the database URL, API keys, the port, feature flags, log level. Configuration management is the discipline of getting those values into your app without baking them into the code — so the same build runs unchanged in development, staging, and production, and so secrets never end up in your repository. The guiding principle is the Twelve-Factor App's rule: store config in the environment. This page covers what counts as config, why it must live outside the code, the environment-variable approach, and the layered model most real apps use.

Config vs code — what belongs where

JS
// ❌ Config baked into code — wrong: changes need a code edit + redeploy, and
//    the secret is now in git history forever.
const db = connect('postgres://admin:s3cr3t@prod-db.example.com:5432/app')
const PORT = 8080

// ✅ Config read from the environment — same code everywhere; values injected per env.
const db = connect(process.env.DATABASE_URL)
const PORT = Number(process.env.PORT) || 3000
Config is everything that varies per environment or is secret — it belongs outside the code, read from the environment
The litmus test for **configuration** is: *does this value change between deployments (dev / staging / prod), or is it a secret?* Database connection strings, API keys and tokens, the listening port, external service URLs, log levels, and feature flags all qualify — they differ per environment or must stay private. Things that are the *same* everywhere (route definitions, business rules, which library you use) are **code**, not config. The Twelve-Factor principle is to keep a **strict separation**: code is identical across all environments and lives in the repo; config varies and is injected from the outside. A quick gut-check — "could I open-source this repo right now without leaking anything?" — tells you whether a value has wrongly crept into the code.
Why config must live outside the code
Hard-coded config means secrets in git forever and a redeploy for every change — and git history keeps deleted secrets
Putting configuration in source code causes two serious problems. First, **secrets leak permanently**: a hard-coded API key or password committed to git stays in the repository's *history* even after you delete the line, so anyone with repo access (now or later) can recover it — the only real remediation is rotating the credential. Second, **changes require a code change**: bumping a timeout, pointing at a new database, or flipping a flag means editing, reviewing, and redeploying code, instead of just changing an environment value and restarting. Externalizing config fixes both: the same artifact deploys everywhere, values are injected per environment, and secrets are managed by a secrets store rather than your version control. This is why "store config in the environment" is the baseline rule for any deployable service.
The environment-variable approach

Bash
# Values are set in the ENVIRONMENT, not the code:
export DATABASE_URL="postgres://localhost:5432/app_dev"
export PORT=3000
export LOG_LEVEL=debug
node app.js

# In production they come from the platform's env/secrets (systemd, Docker,
# Kubernetes Secrets, your PaaS dashboard) — never from a file in the repo.

JS
// Node exposes them on process.env (values are ALWAYS strings):
const port = Number(process.env.PORT) || 3000   // coerce — env vars are strings
const debug = process.env.LOG_LEVEL === 'debug'  // compare, don't assume boolean
`process.env` is Node's window into environment variables — universal, language-agnostic, and always string-valued
Node reads environment variables through **`process.env`**, an object of the variables present when the process started. This is the standard mechanism because it's **universal** — every operating system, container runtime, CI system, and hosting platform knows how to set environment variables, so your app stays decoupled from any one of them. Two practical gotchas: every value is a **string** (`process.env.PORT` is `"3000"`, not `3000` — coerce with `Number()`, and there's no real boolean, so compare strings), and a variable that wasn't set is `undefined` (so you provide defaults or [validate](/nodejs/config-best-practices) that required ones exist). Setting them by hand for local development is tedious, which is exactly what [`dotenv`](/nodejs/dotenv) and `.env` files solve.
The layered config model

Text
Most apps merge several sources, later ones OVERRIDING earlier:

   1. Built-in defaults (in code)        ← sensible fallbacks, lowest priority
   2. Config file for the environment    ← e.g. config/production.json
   3. .env file (local dev convenience)  ← loaded by dotenv
   4. Real environment variables         ← highest priority; what prod actually uses
        ──────────────────────────────►  override direction
Real apps layer defaults → env-specific files → `.env` → real env vars, with later sources overriding earlier ones
In practice configuration isn't a single source but a **merge of layers**, ordered by priority so a more specific source overrides a more general one. A typical precedence: **code defaults** (lowest — sensible fallbacks so the app boots), then **environment-specific files** (`config/default.json`, `config/production.json`), then a **`.env` file** for local development convenience, and finally the **actual environment variables** (highest — what production injects). This lets you keep harmless defaults in the repo while overriding only what differs per environment, and lets an operator override anything at deploy time without code changes. Libraries like `node-config` formalize this layering; many apps build a small `config.ts` module that reads `process.env`, applies defaults, validates, and exports a typed config object — the [best-practices page](/nodejs/config-best-practices) covers that pattern.
Next
The everyday tool for loading config locally: [Environment Variables with dotenv](/nodejs/dotenv).