Engineering Notes
Home/Backend/Frameworks/Configuration Management
Backend · Frameworks

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.

⚙️ Backend● Beginner⏱ 5 min read▶ 1 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What counts as configuration
  • Sources and precedence
  • Fail-fast validation at startup; injecting config
  • Secrets and feature flags

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:

text
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
SourceGood forAvoid for
code defaultssafe non-secret defaultsanything environment-specific
config filesstructured, non-secret settings checked into gitsecrets
.envlocal development convenienceproduction (never commit it)
environment variableseverything per-deployment, incl. secrets injected by the platformhuge structured config
secret manager (Vault, AWS Secrets Manager, k8s Secrets)credentials, rotation, auditvalues 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:

ts
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

ts
// ❌ 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 .env to .gitignore, commit a .env.example with 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:

ts
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 = 8080 stores "8080"; assigning undefined stores the string "undefined" (use delete).
  • 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>/environ on Linux for the same user, crash dumps, docker inspect showing 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.

Defaults, config files, a .env file and real environment variables are layered with later sources overriding earlier ones, then validated once at startup into a frozen typed config object injected into services
Defaults, config files, a .env file and real environment variables are layered with later sources overriding earlier ones, then validated once at startup into a frozen typed config object injected into services

⚠️ Common Pitfalls

  1. Number(process.env.X) without validation — a typo yields NaN silently.
  2. process.env.FLAG === true — always false; env values are strings.
  3. Committing .env or secrets in config files.
  4. Reading config lazily deep in the code — misconfiguration discovered at the worst possible time.
  5. Logging the whole config on startup — leaks secrets into the log system.
  6. Different config shape per environment — environments should differ in values, not in which keys exist.

📋 Quick Reference

NeedHow
per-deploy valuesenv vars
local dev.env + .env.example (committed)
typed + validatedschema parse at boot, freeze, inject
secrets in prodsecret manager / orchestrator secrets
booleans from envv === "true" (explicit)
NestJSConfigModule.forRoot({ validate }) + ConfigService
runtime togglesfeature-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.env throughout the code; feature flags belong in a flag service for runtime changes.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. A required env var is missing. Best behaviour?
Fail fast.
2. Which usually wins: .env file or a real environment variable?
Platform config overrides files.
3. Why not call process.env.X all over the codebase?
Centralize.
4. Where should production secrets live?
Never in code.