What belongs in environment variables (and what doesn't)
The Twelve-Factor App defines config as everything likely to vary between deploys of the same codebase. That covers three kinds of values: handles to backing services such as the database or cache, credentials for external services, and per-deploy values like the canonical hostname. Internal wiring does not change between deploys, so it stays in code. Examples are route tables and how modules are composed.
A quick test tells you whether config is separated properly. Could you open-source the repository right now without leaking a single credential? If the answer is no, some config is still living in code.
| Belongs in env vars | Stays in code |
|---|---|
DATABASE_URL, cache host |
Route definitions |
| API keys, service tokens | Dependency injection wiring |
| Canonical hostname per deploy | Validation rules, business constants |
Each variable should be an independent control, set directly for each deploy. Avoid bundling variables into named environments like staging or production. Bundles force you to name every combination you need, and the number of combinations grows quickly.
Illustrative estimate: suppose you have four settings with two plausible values each. That gives 2⁴ = 16 possible combinations. Teams start with three bundles, then add qa, perf, and alice-staging whenever someone needs a different mix. Each new bundle is one more file that drifts out of sync. With orthogonal variables, a new deploy just sets the values it needs.
This model suits any backend or platform team that deploys one codebase to more than one place.
Why scattered env lookups cause runtime bugs
The process environment is input from outside your code, so it deserves the same suspicion as a request body. The operating system hands your process a map of strings. A variable is either a string or absent, and those are two different states. PORT unset means nobody configured it. PORT="" means someone configured it to nothing, often through a templating mistake or an empty secret. A lookup with a fallback usually treats both the same way. The bug is hidden instead of reported.
Every other type needs explicit parsing. In both JavaScript and Python, "false", "0" and "no" are non-empty strings and therefore truthy, so if (env.FEATURE_X) turns the feature on when an operator meant to turn it off. Numbers have similar traps. In JavaScript, Number("") returns 0, and parseInt("30s") silently returns 30, dropping a unit the author may have meant as seconds. Durations need a unit convention. URLs need to be structurally valid. Lists need an agreed separator and whitespace handling. Enums like LOG_LEVEL need a closed set of allowed values, so a typo like "wanr" gets rejected instead of falling through to some default.
When these lookups are scattered across many files, each call site makes its own choices about defaults and parsing, and they rarely agree. A malformed value doesn't fail at startup. It fails the first time that code path runs, maybe hours later, as a timeout of zero or a connection to the wrong host. Centralized parsing moves all of these decisions into one place and runs them once, before the app accepts traffic.
// Scattered: each file reads and converts values its own way.
// FEATURE_X="false" is a non-empty string, so Boolean() returns true.
const featureOn = Boolean(process.env.FEATURE_X);
// TIMEOUT_MS="" is set, so ?? keeps ""; Number("") is 0, a zero timeout.
const timeoutMs = Number(process.env.TIMEOUT_MS ?? 5000);
// RETRY_DELAY_MS="30s" -> parseInt drops the unit and returns 30.
const retryMs = parseInt(process.env.RETRY_DELAY_MS ?? "1000", 10);
// Centralized: parse everything once at startup and throw on bad input.
function raw(name: string): string {
const v = process.env[name];
if (v === undefined) throw new Error(`${name} is not set`);
if (v.trim() === "") throw new Error(`${name} is set but empty`);
return v.trim();
}
function bool(name: string): boolean {
const v = raw(name).toLowerCase();
if (v === "true") return true;
if (v === "false") return false;
throw new Error(`${name} must be "true" or "false", got "${v}"`);
}
// The unit goes in the variable name (_MS), and the value must be plain digits.
function millis(name: string): number {
const v = raw(name);
if (!/^\d+$/.test(v)) throw new Error(`${name} must be whole milliseconds, got "${v}"`);
return Number(v);
}
function oneOf<T extends string>(name: string, allowed: readonly T[]): T {
const v = raw(name);
if (!(allowed as readonly string[]).includes(v)) {
throw new Error(`${name} must be one of ${allowed.join(", ")}, got "${v}"`);
}
return v as T;
}
export const config = Object.freeze({
featureX: bool("FEATURE_X"),
timeoutMs: millis("TIMEOUT_MS"),
logLevel: oneOf("LOG_LEVEL", ["debug", "info", "warn", "error"] as const),
databaseUrl: new URL(raw("DATABASE_URL")), // throws TypeError if malformed
});
Scattered lookups quietly turn "false", an empty string and "30s" into the wrong values, while one config module that parses everything at startup rejects the same inputs with clear errors before the app takes traffic.
How it works: one config module, read once at startup
The startup sequence runs in a fixed order:
- Read once. The config module takes a single snapshot of the environment.
- Parse. Each value becomes its real type. Ports become integers and timeouts become durations. Flags become booleans. URLs are parsed and checked for a scheme and a host.
- Validate. All the checks run before anything reports. The module collects every error instead of stopping at the first, so one failed deploy lists every missing or malformed variable.
- Build and inject. The result is one immutable, typed config object. Each component receives only the part it needs. The HTTP server gets its port and timeouts, the database pool gets its URL and pool size, and the queue consumer gets its concurrency.
The app binds a port, connects to the database or starts workers only after that object exists. If validation fails, the process logs the errors and exits with a non-zero status before any of those steps. Nothing is left half-initialized, and no request is served with a bad value.
The config module is the only code allowed to read the environment. Everything else gets its values as constructor arguments or parameters. This rule puts the app's full list of external inputs in one file. It also makes tests simpler, because a test can build a config object directly instead of changing process-wide state.
Exiting early works well with rolling deploys. The orchestrator starts the new version and waits for its readiness check to pass before it moves traffic or stops old instances. A process that exits during startup never becomes ready, so the rollout stops and the crash loop shows up in the deploy status. Meanwhile the old version keeps serving traffic.
+-------------+-------------+
| process environment |
+-------------+-------------+
| read once; no other code reads env
v
+--------------------------------------------+
| config module |
| parse: port->int, timeout->duration |
| flag->bool, URL->scheme+host |
| validate: collect ALL errors, not first |
+-------------+-------------------------+----+
| all valid | any error
v v
+----------------------------+ +----------------------------+
| immutable typed config | | log every error |
| (one object, read-only) | | exit non-zero |
+-------------+--------------+ +-------------+--------------+
| inject slices |
v v
+----------------------------+ +----------------------------+
| HTTP server: port, timeouts| | readiness check never |
| DB pool: URL, pool size | | passes; rollout halts |
| queue: concurrency | | old version keeps serving |
+-------------+--------------+ +----------------------------+
| only now: bind port, connect DB, start workers
v
+----------------------------+
| readiness passes; traffic |
| shifts, old instances stop |
+----------------------------+
The config module reads and validates the environment once before any port, connection or worker starts; a failure exits non-zero so the new version never becomes ready and the old version keeps serving.
Implementing config validation and fail-fast startup
Start with a schema that describes every variable the service reads. Each entry declares a type (integer, boolean, URL, enum), whether it is required, and a constraint: allowed values for LOG_FORMAT, a port range for PORT, a parseable URL with an expected scheme for DATABASE_URL. Cross-field rules sit alongside the per-field ones. TLS_CERT_PATH and TLS_KEY_PATH must be set together or not at all, because having only one of them is a misconfiguration, not a partial feature.
Conditional requirements work the same way. If PAYMENTS_ENABLED is true, then PAYMENTS_API_KEY and PAYMENTS_WEBHOOK_SECRET become required. When the flag is off, both may be absent. Encoding this in the schema stops a disabled feature from blocking startup, and it stops an enabled feature from failing on its first request.
The loader should check every rule, collect all failures, and exit once with the full list. Reporting only the first error turns three typos into three restarts. Each message names the variable, the expected form, and what was received: PORT: expected integer 1–65535, got "80a". For fields marked sensitive, report only that the value was present or missing and never echo it.
Defaults are allowed only where a value is safe in every deploy, such as log format or the container's listen port. Credentials and backing-service locations get no default. A fallback to localhost can quietly point staging at the wrong database.
The sensitive flag matters beyond error output. Use it to redact values when the config is logged or printed. Environment variables can also leak through crash dumps, debug or diagnostics endpoints, and child processes, which inherit the parent's environment unless you pass them a filtered copy.
import os
import sys
from urllib.parse import urlparse
SENSITIVE = {"DATABASE_URL", "PAYMENTS_API_KEY", "PAYMENTS_WEBHOOK_SECRET"}
def load_config(env=os.environ):
errors, cfg = [], {}
# Safe defaults: the same value works in every deploy.
port = env.get("PORT", "8080")
if port.isdigit() and 1 <= int(port) <= 65535:
cfg["PORT"] = int(port)
else:
errors.append(f'PORT: expected integer 1-65535, got "{port}"')
fmt = env.get("LOG_FORMAT", "json")
if fmt in ("json", "text"):
cfg["LOG_FORMAT"] = fmt
else:
errors.append(f'LOG_FORMAT: expected one of json|text, got "{fmt}"')
# No default for backing services. Sensitive, so never echo the value.
db = env.get("DATABASE_URL")
if not db:
errors.append("DATABASE_URL: required, missing")
elif urlparse(db).scheme not in ("postgres", "postgresql"):
errors.append("DATABASE_URL: expected postgres:// URL, got <redacted>")
else:
cfg["DATABASE_URL"] = db
# Cross-field rule: set both or neither. One alone is a misconfiguration.
if bool(env.get("TLS_CERT_PATH")) != bool(env.get("TLS_KEY_PATH")):
errors.append("TLS_CERT_PATH/TLS_KEY_PATH: must be set together or not at all")
# Conditional requirement: secrets are needed only when the feature is on.
if env.get("PAYMENTS_ENABLED", "false").lower() == "true":
for name in ("PAYMENTS_API_KEY", "PAYMENTS_WEBHOOK_SECRET"):
if not env.get(name):
errors.append(f"{name}: required when PAYMENTS_ENABLED=true, missing")
else:
cfg[name] = env[name]
# Report every failure in one pass, then exit once.
if errors:
sys.exit("Invalid configuration:\n " + "\n ".join(errors))
return cfg
def redacted(cfg):
# Use this view whenever the config is logged or printed.
return {k: ("<redacted>" if k in SENSITIVE else v) for k, v in cfg.items()}
A dependency-free Python loader that checks per-field, cross-field and conditional rules. It collects every error before exiting once, defaults only safe values, and redacts sensitive fields in both error messages and logged config.
Where dotenv fits in local development and testing
A dotenv library reads a .env file and copies its key-value pairs into the process environment before your config module runs. That is its whole job. It saves developers from exporting a dozen variables by hand, but it does not replace the way your deploy platform injects configuration. In production the variables should already be in the environment, and the file should not exist.
Keep .env out of version control and out of build contexts. Commit a .env.example that lists every variable the schema expects, using placeholder values and a short comment for each one. New developers copy it to get started, and reviewers can see when a change adds a setting. Set precedence so that real environment values win over file values. Many dotenv libraries skip keys that are already set by default, but check yours. If a shell export or CI variable can be silently overwritten by a stale file, debugging gets much harder.
Failure modes
- A
.envfile gets committed by accident or copied into a container image, which ships its secrets with every layer. - Old values in the file shadow the real ones, so the app connects to a database nobody meant to use.
- The dev file and prod settings drift apart until a variable exists in only one of them.
Testing
Unit-test the loader by passing it explicit maps instead of mutating the global environment. Mutating it leaks state between tests and breaks parallel runs. Add a CI step or container-start check that runs validation on its own and exits non-zero on failure. Add a check that fails when .env.example and the schema list different keys.
// config.ts: pure loader, a .env.example key check, and a CLI entry for CI or container start
import dotenv from "dotenv";
import { readFileSync } from "node:fs";
// Every key the app requires. .env.example must list exactly these.
export const REQUIRED_KEYS = ["DATABASE_URL", "PORT", "LOG_LEVEL"] as const;
// Pure function. Tests pass a plain object instead of mutating process.env.
export function loadConfig(env: Record<string, string | undefined>) {
const missing = REQUIRED_KEYS.filter((k) => !env[k]);
if (missing.length) throw new Error(`Missing env vars: ${missing.join(", ")}`);
const port = Number(env.PORT);
if (!Number.isInteger(port)) throw new Error("PORT must be an integer");
return { databaseUrl: env.DATABASE_URL!, port, logLevel: env.LOG_LEVEL! };
}
// Parse .env.example without loading it, then compare its keys to the schema.
export function exampleKeysMatch(path = ".env.example"): boolean {
const keys = Object.keys(dotenv.parse(readFileSync(path))).sort();
return keys.join(",") === [...REQUIRED_KEYS].sort().join(",");
}
// Run directly (`node dist/config.js`) as a standalone validation step.
if (require.main === module) {
// Load .env outside production only. By default dotenv does not overwrite
// variables that are already set, so shell exports and CI values take precedence.
if (process.env.NODE_ENV !== "production") dotenv.config();
try {
loadConfig(process.env);
if (!exampleKeysMatch()) throw new Error(".env.example is out of sync with REQUIRED_KEYS");
console.log("config ok");
} catch (e) {
console.error((e as Error).message);
process.exit(1); // non-zero exit fails the CI job or container start
}
}
// config.test.ts (node:test + node:assert), with no global state touched:
// assert.throws(() => loadConfig({ PORT: "8080", LOG_LEVEL: "info" }), /DATABASE_URL/);
A Node.js config module validates an explicit environment map. It loads .env only outside production without overriding real variables, and its standalone entry point exits non-zero when validation fails or .env.example drifts from the schema.
When to avoid it and the alternatives
Environment variables are flat strings. A list of upstream hosts or a per-tenant rate-limit map turns into comma-separated values or numbered keys, and each one needs a custom parser. When config has real structure, a mounted config file parsed and validated by the same startup module is usually cleaner.
Env vars are also not a secret manager. Child processes inherit them, crash reporters and debug endpoints can dump them, and a rotated credential takes effect only after a restart. A dedicated secrets mechanism that supports access control and rotation fits better. Values read once at startup cannot change at runtime, so feature flags and tunables belong in a separate flag system.
| Mechanism | Shape | Changes without restart | Good for |
|---|---|---|---|
| Env vars | Flat strings | No | Per-deploy scalars, endpoints |
| Mounted config file | Nested, typed | Only if the app watches it | Structured or list-shaped config |
| Secrets manager | Opaque values | Yes, with rotation | Credentials, keys |
| Runtime flag system | Flags, rules | Yes | Rollouts, kill switches, tunables |
Strict validation has costs. If a variable is required everywhere, a missing key for a rarely used export job blocks every deploy. Making it conditionally required (required only when EXPORT_ENABLED=true) avoids that, but adds rules you have to test. A validation library handles types, defaults and error aggregation for you. A hand-rolled loader avoids a dependency, but it tends to grow into a worse version of the library.
Validation catches a missing value or a malformed one. It cannot tell you that a well-formed URL points at the wrong database. Optional connectivity checks at boot can find that kind of mistake. The risk is that when a dependency goes down, every replica fails the check and the service turns a partial outage into crash loops.
Impact on development cost and engineering time
The cost is real but small. Someone has to write the schema: decide which variables are required, their types, defaults and allowed ranges. You either take on a validation library as a dependency or maintain a hand-rolled loader, including its own tests. The schema and .env.example must also stay in sync. When they drift, new developers copy a template that no longer starts the app. A CI check that compares the two removes most of that maintenance.
The time saved comes from moving failures earlier:
- Misconfiguration surfaces at deploy time. A missing or malformed value no longer waits for the first request that reads it.
- One restart reports every error. You see all bad variables together, not one per crash loop.
- Failed rollouts leave the old version serving. In a rolling deployment that waits for new instances to become healthy, a process that exits at boot never receives traffic.
- Onboarding is faster. The schema documents every variable, its type and whether it is required.
Illustrative estimate (assumed numbers, not a benchmark)
- Net hours saved per year = incidents × (hours to resolve in production − hours to resolve at deploy) − (setup hours + maintenance hours)
- Assume 6 config incidents per year, 3 hours each in production, 0.5 hours each when caught at deploy, 8 hours of setup and 6 hours of maintenance per year.
- 6 × (3 − 0.5) − (8 + 6) = 15 − 14 = 1 hour in year one. Without the setup cost in later years, the saving is about 9 hours per year.
The return grows with the number of deploy targets and variables, because every new environment is another chance for a value to be wrong. For a single service with five variables and one deploy, a short manual check at startup may be enough.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to environment variables backend, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.