October 4, 2026 · Yunus Emre Vurgun
How Should I Manage App Config with Environment Variables?
Store every value that varies between deploys — database URLs, API keys, feature flags, timeouts — in environment variables, never in code or committed config files. That strict separation is the core of twelve-factor config: the same artifact runs unchanged in development, staging, and production, and only the environment differs. Below is how to name variables, handle secrets, set defaults, and validate everything at startup.
Config is everything that varies between deploys
The twelve-factor methodology draws one bright line: code is everything identical across deploys, config is everything that differs. Database hosts, credentials, third-party endpoints, per-environment limits, and feature flags are config. Application logic, default algorithms, and internal constants are code. If you must edit a file and rebuild to move from staging to production, something that should be config got baked into code.
Environment variables win over config files for this job on three counts. They are language- and OS-agnostic, so the same mechanism works for Python, Go, Node, and the shell. They are easy to change between deploys without touching code — a container orchestrator, a systemd unit, or a hosting dashboard can inject a different set per environment. And they are naturally granular: each variable is an independent value, which makes rotation and per-deploy overrides trivial. Files still have their place for large structured data, but anything small and variable belongs in the environment. For the reliability thinking behind this separation, see the site reliability practices dataset.
Naming and organizing env vars
Good names make misconfiguration obvious. Follow four conventions and enforce them in code review:
- UPPER_SNAKE_CASE, always.
DATABASE_URL, notdatabaseUrlordatabase-url. Shells and most loaders treat lowercase and hyphens inconsistently. - Prefix with the app or subsystem.
BILLING_STRIPE_KEYbeatsSTRIPE_KEYonce two services share a host or a CI environment. - Name the unit for numbers.
CACHE_TTL_SECONDS=300prevents the eternal seconds-or-milliseconds debate. Never store a bareTIMEOUT=30. - Use positive boolean names.
ENABLE_CACHE=truereads clearly;DISABLE_CACHE=falseis a double negative waiting to cause an outage.
Keep a canonical list of every variable your app reads — a .env.example file in the repo is the standard home — with a comment on each line explaining what it does and what a valid value looks like. That file is documentation, not configuration: it ships with placeholder values and is never loaded in production. Treat an undocumented variable as a bug, because the next person to deploy will have to read source code to discover it.
Secrets do not belong in code
Credentials deserve stricter handling than ordinary config. Never commit a real secret to version control — not in code, not in .env, not in a comment, not even briefly, because history remembers. The moment a secret touches a repo, rotate it. Instead, inject secrets at deploy time from a proper store: your orchestrator's secret objects, a cloud secret manager, or a vault service. The application reads them as ordinary environment variables and never knows where they came from.
Three more rules close the common holes. First, never log secret values — redact them in startup dumps and error reports, or you will find production keys in your log aggregator. Second, give each environment its own credentials so a leaked development key cannot touch production data. Third, make secrets rotatable without a code change: read them at startup (or reload them on signal), and document the rotation procedure. This discipline is part of why some services deliberately avoid credential handling altogether, as described in why we serve data without authentication.
Defaults, required vars, and fail-fast validation
Every variable needs an explicit policy: required with no default, or optional with a safe default. Require anything without which the app cannot work correctly — database URLs, API keys — and give safe defaults only to genuinely optional tuning knobs like log levels and timeouts. The dangerous middle ground is a default that silently points at the wrong thing, such as defaulting to a localhost database in production; when the safe default is unclear, make the variable required.
Validate everything at startup and refuse to boot on any problem. Fail-fast beats fail-confused every time:
import os, sys
def env(name, default=None, required=False):
value = os.environ.get(name, default)
if required and not value:
sys.exit(f"missing required env var: {name}")
return value
DATABASE_URL = env("DATABASE_URL", required=True)
LOG_LEVEL = env("LOG_LEVEL", default="info")
CACHE_TTL_SECONDS = int(env("CACHE_TTL_SECONDS", default="300"))
Parse and range-check values here too: convert ports to integers, reject unknown log levels, and verify that URLs parse. A crash with missing required env var: DATABASE_URL in the first second is infinitely cheaper than a half-working process that discovers the gap during the first real request.
Should I use a .env file?
For local development, yes — a .env file loaded by your framework or a tool like dotenv is the convenient way to set the dozen variables a developer needs. Keep .env itself gitignored with real local values, and commit only the .env.example template so newcomers know what to fill in. That split gives convenience without leaking secrets into history.
In staging and production, do not rely on .env files. Real environments should inject variables through the platform — container env blocks, systemd EnvironmentFile with locked-down permissions, or your host's dashboard — so secrets live in the platform's store with audit trails and access control, not in a file on disk that anyone with filesystem access can read. The same app reads both paths identically through os.environ, which is exactly the point: the code never changes, only the environment does. If you run on modest infrastructure, serving an API on shared hosting shows how far environment-driven config can take a small setup.