Config & TestsGENERALCLOUD-SPECIFICFRAMEWORK-SPECIFIC

Configuration: Separating Code From Environment

The same artifact must run in dev, staging and production — so everything that differs between them is input, not source.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What belongs in the code, what belongs in runtime configuration, and how does a value actually reach the process?

The requirement

One build has to run on a laptop, in CI, in staging and in production, hitting different databases and different third-party accounts, without anyone editing a file to promote it.

The obvious build

Keep a config/ folder with development.ts, staging.ts and production.ts, select one by NODE_ENV, and check them all in. Every value is visible, typed, and version-controlled.

Why it breaks

The artifact is no longer environment-independent. Changing a timeout in production means a code change, a build, a review and a deploy — for a value that was always meant to be operational.

How it breaks in production
  • The artifact is no longer environment-independent. Changing a timeout in production means a code change, a build, a review and a deploy — for a value that was always meant to be operational.
  • Secrets end up in the file, because it is the natural place to put a value. Once committed, they are in the history of every clone forever (Secrets Are Not Configuration).
  • The set of environments becomes a closed enum. A per-customer deployment, an ephemeral preview environment or a load-test environment needs a code change to exist.
  • NODE_ENV-style branching leaks into application code — if (env === 'production') scattered through modules — so the production path is the one least exercised in tests (Validate at Startup, Fail Loudly).
  • Config drifts from reality: the file says the timeout is 5 s, an operator changed it in the platform six months ago, and nobody can tell which is in effect.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The dividing question is not "is this a constant" but does this value differ between two places the same artifact runs? If yes, it is configuration. If no, it is code, and putting it in config adds a failure mode for nothing.
  • Configuration reaches a process through a small number of channels: environment variables, mounted files, command-line arguments, a configuration service, and — for values that change without a deploy — a database or feature-flag service (Feature Flags: Rollout, Kill Switches and Debt).
  • Environment variables are the common default because every platform can set them, they are process-scoped, and they require no filesystem. They are also strings, flat, size-limited, and visible in process listings and crash dumps.
  • Mounted files handle what env vars handle badly: multi-line values, certificates, large structured documents, and — importantly — values that can be rotated without restarting the process (ConfigMap vs Secret — and the Honest Limit of a Secret).
  • Precedence matters and must be explicit: typically defaults, then file, then environment, then explicit override. Undefined precedence is why "the value in the file is not the value in effect".
  • Configuration should be read once, at startup, into one typed object. Reading process.env deep inside a module means the value can differ between call sites, cannot be validated as a whole, and is unmockable in tests.
  • The parsed config object is the boundary between untyped strings and the rest of the program — the same parse-don't-validate discipline applied to environment input (Parse, Do Not Validate).

Deciding what is configuration

The useful test is a thought experiment: if you deployed this exact artifact to a second environment, would this value have to change? That question separates configuration from constants far more reliably than intuition, which tends to classify anything numeric as configurable.

Over-configuration has a real cost. Every knob is a value that can be wrong, an entry in a schema, a line of documentation and a possible divergence between instances. A constant that has never differed between environments should stay in the code where it can be read, typed and tested.

Where does this value live?

Does it differ between environments, and how often does it change?

Code constant

when Identical everywhere: a protocol version, a business rule, an internal buffer size that has never differed.

cost Changing it requires a deploy — which is correct for a value that is part of behaviour.

Environment variable

when Differs per environment, changes at deploy time: endpoints, pool sizes, timeouts, region.

cost Strings only, flat, visible in process listings, and a change means a restart.

Mounted file

when Multi-line or structured: certificates, allowlists, a large policy document, or anything rotated without restart.

cost A filesystem dependency and a reload path you have to write (ConfigMap vs Secret — and the Honest Limit of a Secret).

Secret manager

when Any credential. Always, regardless of how convenient an env var would be.

cost A runtime dependency, a fetch at startup, and a rotation story (Secrets Are Not Configuration).

Feature flag / dynamic config

when Must change without a deploy: rollout percentages, kill switches, operational limits during an incident.

cost A distributed-state problem, mid-request consistency and stale-flag debt (Feature Flags: Rollout, Kill Switches and Debt).

Database row

when Per-tenant or per-user settings — this is application data, not configuration.

cost A read on the request path; cache it and own the invalidation (Cache Invalidation).

Parse once, at the edge of the process

LANGUAGE-SPECIFICZod is a TypeScript library; the pattern is the same with pydantic-settings in Python, envconfig or koanf in Go, and @ConfigurationProperties with validation annotations in Spring. What differs is whether the language gives you a compile-time type from the schema — TypeScript and Rust do, so downstream code cannot forget a field.

The single most valuable change most services can make is to stop reading environment variables anywhere except one module. That module turns a bag of untyped strings into a validated, frozen object, and everything downstream works with real types.

This is the same discipline applied to HTTP input, one layer out: the untrusted, untyped thing is parsed into a typed thing at a boundary, and no code past the boundary handles the raw form (Parse, Do Not Validate).

Two ways to read configuration
Read env where needed
// billing/client.ts
const timeout = Number(process.env.PAYMENT_TIMEOUT_MS) || 5000

// orders/service.ts
if (process.env.NODE_ENV === 'production') { /* different code path */ }

// db/pool.ts
const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// Failure shape: PAYMENT_TIMEOUT_MS='5s' -> NaN -> falls back to 5000 silently.
// DATABASE_URL missing -> undefined -> the driver connects to a local default
// and the process starts, healthy, pointed at nothing.
One parse at boot
// config.ts — the only file that touches process.env
const Schema = z.object({
  DATABASE_URL: z.string().url(),
  PAYMENT_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000),
  PAYMENT_MODE: z.enum(['sandbox', 'live']),
  REQUIRE_AUTH: z.coerce.boolean().default(true),   // fails closed
  MAX_POOL: z.coerce.number().int().min(1).max(100).default(10),
})

const parsed = Schema.safeParse(process.env)
if (!parsed.success) {
  console.error('invalid configuration', parsed.error.flatten().fieldErrors)
  process.exit(1)                                   // [[startup-validation]]
}
export const config = Object.freeze(parsed.data)

The right-hand version cannot start with a missing or malformed value, gives every consumer a real type, has one place to document and redact, and makes the config trivially substitutable in tests. The left-hand version turns configuration mistakes into runtime behaviour changes that look like bugs in unrelated code.

One artifact, many environments

The reason configuration is externalised at all is promotion: the container image that passed staging should be bit-identical to the one running in production, because otherwise staging tested a different program. Everything that differs must therefore arrive as input at run time.

This is also why environment-name branching is corrosive. if (env === 'production') means the production code path is the one that never ran in staging, which defeats the entire point of having a staging environment.

A value's journey from decision to running process
  1. 1
    Decide the category

    Constant, env var, file, secret, or dynamic flag — using the change cadence, not the data type.

    fails by Classifying a credential as an env var because it is a short string.

  2. 2
    Declare it

    Add it to the schema and to .env.example with type, default and whether it is required.

    fails by Adding it in code only, so the deployment platform never learns it exists.

  3. 3
    Supply it

    The platform injects env vars, mounts files, or the app fetches from a secret manager at boot.

    fails by Set locally in a shell, missing in the deployed unit — works everywhere except production.

  4. 4
    Parse and validate at boot

    One schema parse; exit non-zero on failure before accepting traffic.

    fails by Lazy reads, so an invalid value surfaces on the first request that needs it (Validate at Startup, Fail Loudly).

  5. 5
    Freeze and expose typed

    One immutable object imported by the rest of the program.

    fails by Mutable config mutated at runtime, so two requests see different values (Backend Races).

  6. 6
    Log it, redacted

    One startup line with the effective values and the build sha.

    fails by Logging the whole object, publishing every credential to the log index (Secrets in Logs).

How to build it

Most important first.

  • Build one artifact and promote it unchanged through environments. If the container in production is not the byte-identical one that passed staging, you tested something else (Build Once, Promote the Same Bytes).
  • Parse the whole environment into a typed, frozen config object at boot, with a schema. Fail loudly and immediately if anything is missing or malformed (Validate at Startup, Fail Loudly).
  • Never read raw environment variables outside that one module. Pass the typed config in, or import it — either way there is exactly one parse.
  • Give every value an owner and a category: infrastructure endpoints, tuning knobs, feature toggles, secrets. Each has a different change cadence and a different control (Secrets Are Not Configuration).
  • Prefer explicit values over environment-name branching. PAYMENT_MODE=sandbox says what it does; if (env !== 'production') says where it runs and hides what it changes.
  • Document every variable in one place with type, default, whether it is required, and what it affects. The .env.example file is a real artifact, not a courtesy.
  • Treat defaults carefully: a safe default for a timeout is good; a default database URL pointing at localhost means a misconfigured production process starts happily and connects to nothing.
  • Log the effective configuration at startup with secret values redacted. "Which config was this process running" is a question that comes up in most config-related incidents.

What can go wrong

Failure modes
  • Silent type coercion: MAX_RETRIES='3' used as a number and concatenated instead of added; DEBUG=false being a truthy non-empty string. Both are classic and both are caught by parsing with a schema.
  • A missing variable defaulting to something plausible, so the process starts and behaves subtly wrong instead of refusing to start.
  • Environment variables set in the shell that started a process locally but absent in the deployed unit — the failure appears only in the environment where nobody is watching.
  • Config read lazily at first use, so an invalid value causes a failure hours after deploy, on the first request that happens to need it.
  • The mitigation failing: a schema that validates presence but not semantics, so DATABASE_URL pointing at staging passes validation in production.
  • Drift between the checked-in template and the platform's actual variable set, so a new variable added by one engineer is missing in production until it breaks.
What can race
  • A dynamic config reload races with in-flight requests: one request can read the old timeout and the new endpoint if config is mutated field by field. Swap an immutable snapshot atomically instead of mutating in place (Atomic Operations).
  • Two instances reloading at different moments run different configurations for a window. Any behaviour that must be fleet-consistent cannot be a per-instance config read (Eventual Consistency in Practice).
Security
  • Environment variables are not a secure channel: they appear in process listings, are inherited by child processes, appear in crash dumps and error reporters, and are often printed by debug endpoints (Secrets in Logs).
  • Never log the config object without redaction, and redact by allowlist — choose what is printable rather than trying to name every secret.
  • A config endpoint that returns effective settings is a reconnaissance surface. If one exists, authenticate it and exclude secret values entirely (Not Leaking Your Internals).
  • Config with a security consequence — TLS verification, CORS origins, auth bypass switches — must fail closed if unset. A missing REQUIRE_AUTH must never mean false (Fail Open vs Fail Closed).
  • Child processes inherit the environment by default. A subprocess spawned for image processing receives your database password unless you pass an explicit environment (Command Injection).
Misreads
  • "Twelve-factor says use environment variables, so everything goes in env." Env vars are one channel and a poor one for certificates, large documents and anything rotated in place.
  • "Config in the repo is fine as long as secrets are not." Partly true and it still couples operational values to the release cycle, which is the more common day-to-day pain.
  • "NODE_ENV=production is configuration." It is a mode switch that changes framework behaviour in ways you did not choose. Name the behaviours you want individually.
  • "Defaults make things robust." Defaults make things *start*. Starting with the wrong database is worse than not starting.
  • "If it validates at boot, it is correct." Validation proves shape and presence, not that the value points at the right system.

Operating it

How you see it in production
  • Log the effective, redacted configuration once at startup, with the build sha. This single line resolves most "was this deployed with the right settings" questions.
  • Emit a gauge or a build-info metric labelled with config-derived values that are low-cardinality and operationally interesting — region, mode, major feature toggles.
  • Alert when instances in the same deployment report different effective config. That means a partial rollout or a stale instance (Rolling Deployments).
  • Record config-change events alongside deploy markers on dashboards. A latency change with no deploy is often a config change ("What Changed?" — Deploy Markers and the Invisible Deploys).
What changes at 10x and 100x
  • At 10x instances, config distribution becomes an operational system in its own right: a value changed in one place must reach every instance, and instances that missed it are a correctness problem, not a cosmetic one.
  • Restart-to-apply becomes expensive as fleets grow, which is what drives teams toward dynamic configuration — and dynamic configuration brings mid-request consistency problems (Feature Flags: Rollout, Kill Switches and Debt).
  • With many services, shared config (a database endpoint, a rate limit) needs one owner. Copies diverge, and the divergence is discovered during an incident.
  • Nothing about the parse-once-at-boot discipline changes with scale. It is the same three lines at ten instances and at ten thousand.
What this costs
  • Externalised config means the code no longer tells you what the system does. Reading the source is no longer sufficient, and the effective values have to be looked up.
  • Strict startup validation means a bad value stops the deploy. That is the point, and it does mean a typo can block a release at 5pm on Friday.
  • One typed config object is a small amount of plumbing and one more thing to pass around, which on a tiny service is genuinely more ceremony than reading an env var in place.
  • Dynamic configuration removes the restart and adds a distributed-state problem, plus a new runtime dependency that can fail.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe code-versus-config boundary and parse-once-at-boot apply to any language and platform.
  • CLOUD-SPECIFICHow config arrives differs sharply: Kubernetes mounts ConfigMaps as files or injects them as env vars, and a mounted file updates in place while an injected env var requires a pod restart. Serverless platforms set environment variables at function configuration time, so changing one is a deployment. A plain VM inherits whatever the process manager exports. The parse-at-boot rule survives all three; the reload story does not.
  • FRAMEWORK-SPECIFICFrameworks bring their own config conventions — Spring profiles and property precedence, Django settings modules, Rails environments. Each has its own precedence order, and using it *plus* your own layer is how "the value in the file is not the value in effect" happens.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.