Configuration Management
Configuration is everything that changes between environments. Merge sources by precedence, validate once at startup and fail fast, inject typed config instead of reading process.env everywhere, and keep secrets out of code.
🎯 What you'll learn
- What counts as configuration
- Sources and precedence
- Fail-fast validation at startup; injecting config
- Secrets and feature flags
📚 Before you start
1What Counts as Configuration
Configuration is everything that differs between deployments of the same build: database URLs, API keys, feature flags, log levels, ports, timeouts, third-party endpoints. Code is the same in dev, staging and production; config is not.
The twelve-factor rule: store config in the environment, not in the code. A useful test — could you open-source the repository right now without leaking any credentials? If not, config is living in code.
2Sources and Precedence
Most apps merge several sources, later ones overriding earlier ones:
defaults in code PORT=3000, LOG_LEVEL=info
← config file config/production.json
← .env file (local dev) DATABASE_URL=postgres://localhost/dev
← environment variables set by the platform (Docker, k8s, systemd)
← CLI flags --port 8080| Source | Good for | Avoid for |
|---|---|---|
| code defaults | safe non-secret defaults | anything environment-specific |
| config files | structured, non-secret settings checked into git | secrets |
.env | local development convenience | production (never commit it) |
| environment variables | everything per-deployment, incl. secrets injected by the platform | huge structured config |
| secret manager (Vault, AWS Secrets Manager, k8s Secrets) | credentials, rotation, audit | values that change per request |
3Validate Config at Startup — Fail Fast
Environment variables are untyped strings. Parse and validate them once, at boot, into a typed object:
import { z } from "zod";
const ConfigSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
FEATURE_NEW_CHECKOUT: z.enum(["true", "false"]).default("false").transform((v) => v === "true"),
});
export type Config = z.infer<typeof ConfigSchema>;
export function loadConfig(env = process.env): Config {
const result = ConfigSchema.safeParse(env);
if (!result.success) {
// print WHICH keys are wrong — never their values (they may be secrets)
console.error("Invalid configuration:", Object.keys(result.error.flatten().fieldErrors).join(", "));
process.exit(1);
}
return Object.freeze(result.data);
}A missing DATABASE_URL should crash the deploy in second one — not surface as a confusing ECONNREFUSED on the first request an hour later.
4Inject Config, Don't Read process.env Everywhere
// ❌ scattered: hard to test, typos become silent undefined
const timeout = Number(process.env.PAYMENT_TIMEOUT_MS);
// ✅ one typed object, passed in
class PaymentClient {
constructor(private readonly cfg: Pick<Config, "PAYMENT_URL" | "PAYMENT_TIMEOUT_MS">) {}
}NestJS's ConfigModule does exactly this: ConfigModule.forRoot({ validate }) validates at boot, and ConfigService is injected wherever a value is needed (09-dependency_injection/). Tests construct the config object directly instead of mutating process.env.
5Secrets
- Never commit secrets — add
.envto.gitignore, commit a.env.examplewith placeholder values. - Inject at run time from a secret manager or orchestrator (k8s Secret → env var or mounted file).
- Rotate without redeploying code: read secrets from a mounted file that the platform updates, or re-fetch from the secret manager periodically.
- Don't log config objects wholesale; redact (
13-logging/section 5). - Prefer short-lived credentials (IAM roles, workload identity) over long-lived static keys where the platform supports them.
6Feature Flags
Feature flags are runtime config that turns code paths on/off without a deploy:
if (config.FEATURE_NEW_CHECKOUT) return newCheckout(cart);
return legacyCheckout(cart);Env-var flags need a restart to change. Dedicated flag services (LaunchDarkly, Unleash, OpenFeature providers) evaluate per request with targeting rules (percentage rollouts, specific users), which enables canary releases (devops-cloud/cicd/02-deployment_strategies). Remove flags once a rollout completes — stale flags are tech debt.
Under the HoodWhat process.env Really Is▾
When the OS starts a process (execve), it copies the parent's environment block — a flat array of KEY=value C strings — into the new process's memory. Node exposes it as process.env, an object whose property access is intercepted so reads and writes go to the real environment:
- Every value is a string. Assigning
process.env.PORT = 8080stores"8080"; assigningundefinedstores the string"undefined"(usedelete). - On Windows keys are case-insensitive; on Linux/macOS they're case-sensitive.
- The environment is copied at process start. Changing a variable in your shell after the app started does nothing; a child process inherits whatever the parent has at spawn time.
- Anyone who can inspect the process can usually read it (
/proc/<pid>/environon Linux for the same user, crash dumps,docker inspectshowing container env) — env vars are convenient for secrets, but not a vault. Mounted secret files with tight permissions reduce exposure.
dotenv simply reads .env, parses KEY=value lines and assigns them into process.env only if not already set — which is why real environment variables override .env values.
⚠️ Common Pitfalls
Number(process.env.X)without validation — a typo yieldsNaNsilently.process.env.FLAG === true— always false; env values are strings.- Committing
.envor secrets in config files. - Reading config lazily deep in the code — misconfiguration discovered at the worst possible time.
- Logging the whole config on startup — leaks secrets into the log system.
- Different config shape per environment — environments should differ in values, not in which keys exist.
📋 Quick Reference
| Need | How |
|---|---|
| per-deploy values | env vars |
| local dev | .env + .env.example (committed) |
| typed + validated | schema parse at boot, freeze, inject |
| secrets in prod | secret manager / orchestrator secrets |
| booleans from env | v === "true" (explicit) |
| NestJS | ConfigModule.forRoot({ validate }) + ConfigService |
| runtime toggles | feature-flag service |
🧠 Key takeaways
- Config = what varies per environment (URLs, credentials, limits, flags), never code paths.
- Precedence: defaults < config files < env vars < (CLI flags); secrets from a secret manager.
- Validate at startup (Zod/Joi) and fail fast; expose a typed, frozen config via DI.
- Don't read
process.envthroughout the code; feature flags belong in a flag service for runtime changes.
🎤 Interview Q&A
Q1How should configuration be managed?core▾
Following the 12-factor app: config in the environment, separate from code; same build artifact across environments; layered sources (defaults → files → env vars → secret store), validated at startup.
- Why validate at startup? Fail fast instead of failing on the first request using a missing value.
Q2How do you handle secrets?core▾
Never commit them; store in a secrets manager (AWS Secrets Manager, Vault, Azure Key Vault), inject at runtime, rotate regularly, restrict access per service, and scan repos for leaked secrets.
- Are env vars safe for secrets? Acceptable, but they can leak via process dumps or logs; mounted files or SDK access are better.
Q3How do you make configuration type-safe?senior▾
Bind config to typed objects: the Options pattern in .NET (IOptions<T> with validation), Zod/env schemas or @nestjs/config in Node, Pydantic Settings in Python.
- IOptions vs IOptionsSnapshot vs IOptionsMonitor? Singleton, per-request, and change-notifying views of config.
Q4What are feature flags and how do they relate to config?core▾
Runtime toggles to enable features per user/percentage/environment without deploys, enabling trunk-based development, canaries and quick kill switches. Managed via services like LaunchDarkly, Unleash or OpenFeature-compatible tools.
- Risk? Flag debt: remove flags after rollout.
Q5How do you change config without redeploying?core▾
Dynamic config from a store (AppConfig, Consul, a feature flag service) with change notifications, or rolling restarts after updating config maps.
- Danger of dynamic config? Untested changes in production; treat config changes like code changes with review.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.