Two lines that have caused an enormous amount of real damage:
const DATABASE_URL = 'postgres://admin:hunter2@prod-db.company.com/main';
const STRIPE_KEY = 'sk_live_51H8xY2KZ...';Three separate problems, each serious.
You cannot run this anywhere else. Testing against a local database means editing source code, and someone will eventually commit that edit and point production at their laptop.
The secret is in git forever. Deleting it in a later commit does not remove it — the history still holds it. Anyone who ever cloned the repository has a copy. Rotating that key is now the only real fix.
Everyone with code access has production access. A new contractor reading the codebase to fix a typo now holds your live payment key.
The rule
Config is anything that differs between environments. Code is everything that does not.
Database URLs, API keys, feature flags, log levels, external service endpoints and timeouts vary between your laptop, staging and production. They are config.
Business rules, algorithms and the structure of your program are identical everywhere. They are code.
Keep them apart, and one build artefact can run in every environment — which is the whole point. The exact container you tested in staging is the one that reaches production, differing only in what it is told at startup.
Environment variables
The standard mechanism, because every operating system, container runtime and cloud platform supports it.
// config.js — the ONLY file that reads process.env
export const config = {
databaseUrl: process.env.DATABASE_URL,
stripeKey: process.env.STRIPE_KEY,
port: Number(process.env.PORT ?? 3000),
logLevel: process.env.LOG_LEVEL ?? 'info',
isProd: process.env.NODE_ENV === 'production',
};Two disciplines make this work.
Read the environment in exactly one place. When process.env.X is scattered across forty files, nobody can list what the app needs to run, and a typo becomes undefined at 2am rather than a startup failure.
Commit an example, never the real thing. .env is git-ignored; .env.example is committed with every key present and no real values:
# .env.example
DATABASE_URL=postgres://user:pass@localhost:5432/dev
STRIPE_KEY=sk_test_replace_me
LOG_LEVEL=debugThis doubles as documentation. A new developer clones, copies the example, fills in values, and runs.
Fail fast: validate at startup
Here is the failure mode that catches teams repeatedly.
You deploy. The app starts fine. Health checks pass. Traffic flows. Forty minutes later, the first user tries to pay and the request crashes, because STRIPE_KEY was never set on the new server and undefined was passed to the payment client.
A missing config value should stop the process immediately, not surface as a runtime error later. Validate everything at boot:
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_KEY: z.string().startsWith('sk_'),
PORT: z.coerce.number().int().positive().default(3000),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid configuration:', parsed.error.flatten().fieldErrors);
process.exit(1); // refuse to start
}
export const config = parsed.data;A deploy that fails in ten seconds with STRIPE_KEY: Required is vastly better than one that succeeds and breaks checkout an hour later. Your deployment system sees the crash, halts the rollout, and nobody is paged.
Secrets deserve more than environment variables
Environment variables are fine for non-sensitive settings. Secrets need extra care, because they can leak through crash dumps, logs, and child processes that inherit the environment.
Use a dedicated secret manager — AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, or your platform's equivalent. They give you three things plain variables cannot:
- Access control and audit — who read which secret, and when.
- Rotation — change a credential without redeploying.
- Encryption at rest, with keys you control.
Diagram, as text
- App starts leads to Secret Manager
- Secret Manager leads to App starts (short-lived credential)
- App starts leads to Database
Feature flags: config that changes while running
Some settings need to change without a deploy. Feature flags let you turn behaviour on and off at runtime:
if (await flags.isEnabled('new-checkout', { userId })) {
return newCheckout(order);
}
return legacyCheckout(order);This enables gradual rollout — 1% of users, then 10%, then everyone — and instant rollback if error rates rise, without waiting for a deploy pipeline.
The cost is that every flag doubles the code paths you must reason about and test. Flags are temporary by intent; delete them once the rollout finishes. A codebase with two hundred live flags has 2²⁰⁰ theoretical configurations and nobody knows which one production is in.
Check your understanding
Common mistakes
Different config shapes per environment. If production reads settings from a file and development from the environment, you are testing a different program from the one you ship.
Defaulting to production values. process.env.DATABASE_URL ?? 'postgres://prod...' means one missing variable silently points a test suite at live data. Default to safe values, or to nothing at all.
Logging the whole config at startup. Convenient, and it prints your secrets into a log aggregator that half the company can read. Log the keys, never the values.
Config that is really code. Some teams push business rules into config to "avoid deploys". You end up with untested, unversioned logic in a JSON file. If it needs review and testing, it belongs in code.
Rebuilding per environment. Building a separate artefact for staging and production means the thing you tested is not the thing you shipped. Build once, configure at startup.
Practice
Design startup validation for a service with about 20 configuration values, of which 6 are required, 3 are secrets and 2 must be valid URLs. Say what happens on failure and what the logs contain.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
The goal is that a misconfigured service never serves a request.
Show a strong answer
Validate everything once, before the server accepts traffic, in a single dedicated step rather than scattered through the code that uses each value.
What the step does:
- Load and parse all 20 values into a typed structure. Not strings passed around — a parsed object with the port as a number, the timeout as a duration, the flags as booleans. Parsing is validation:
PORT=abcdfails here rather than at first use. - Assert the 6 required values are present and non-empty. An empty string is a common and nasty case, because it is technically set.
- Validate the 2 URLs parse, have an expected scheme, and — worth doing — are not pointing at localhost in a production environment, which catches a whole class of copied-config mistakes.
- Check the 3 secrets are present and plausibly shaped (length, prefix), without ever logging their values.
On failure: exit non-zero immediately, before binding the port. Not a warning, not a default. A container that fails to start is rolled back automatically by every orchestrator, so the previous version keeps serving and nobody is paged.
What the logs contain:
- Every validation failure, not just the first — a misconfigured deploy usually has more than one, and reporting them one per attempt wastes a rollout cycle each time.
- The name of each offending variable and why it failed: "PORT must be an integer, got 'abcd'".
- For secrets, the name and the fact it is missing or malformed — never the value, and never a partial value.
- A one-line summary at the end of the non-secret configuration actually in effect, which is what makes "why is it behaving like staging?" answerable in seconds.
A feature flag added for a two-week rollout is still in the code two years later, and nobody knows if it is safe to remove. Explain how this happens and what would have prevented it.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
A flag has a lifecycle. Most teams only design the beginning of it.
Show a strong answer
How it happens: the flag is added to make a risky change safe, the rollout succeeds, and the flag has done its job — but removing it is a separate task with no deadline, no owner and no visible cost. Meanwhile code accumulates on both branches, other flags interact with it, and eventually nobody can say what the false branch does or whether anyone is on it. Removing it becomes a risk in itself, so it stays.
The cost is not cosmetic. Every flag doubles the paths through the code, and n flags mean up to 2ⁿ combinations that nobody tests. Most are never exercised, so a rare combination becomes an untested code path in production.
What would have prevented it:
- An expiry date recorded when the flag is created, with the flag system reporting anything past it. A flag without a removal date is a permanent branch, whatever the intention was.
- An owner recorded alongside it, so the report has someone to go to.
- Distinguishing the two kinds of flag explicitly. A release flag exists to be deleted and should be short-lived; an operational toggle (a kill switch for a dependency) is permanent by design. Conflating them means the temporary ones inherit the permanent ones' indefinite life.
- Instrumentation of evaluations per branch. If nothing has taken the
falsepath in six months, that is the evidence needed to delete it confidently — the reason removal feels risky is the absence of exactly this data. - Treating removal as part of the change, not as follow-up work: the rollout is not finished until the flag is gone.
Key takeaways
- Config is what differs between environments; code is what does not. Separating them lets one tested artefact run everywhere.
- Never commit secrets. Git history is permanent, so a leaked credential must be rotated, not merely deleted.
- Read the environment in exactly one module, and commit a
.env.examplethat documents every required key. - Validate configuration at startup and refuse to start if it is invalid. Failing in ten seconds beats failing for a customer an hour later.
- Parsing at boot also fixes types — environment variables are always strings, and
"false"is truthy. - Use a secret manager for credentials, for audit and rotation that plain variables cannot provide.
- Feature flags are temporary. Delete them after rollout, or your production configuration becomes unknowable.
Further reading
- Adam Wiggins, The Twelve-Factor App (2011) — configuration in the environment, and its limits.
- Martin Fowler, "Feature Toggles (aka Feature Flags)" (2017) — the taxonomy of release, operational and permission toggles.
- Google SRE, Site Reliability Engineering (O'Reilly, 2016), chapter 8 — release engineering and configuration as code.
- OWASP, Secrets Management Cheat Sheet — why the environment is not a secret store.
- Pete Hodgson, "Feature Toggles" (ThoughtWorks, 2016) — managing toggle debt.