name: managing-configuration description: Structures backend service configuration the 12-factor way — environment variables into one typed config object validated at startup, secrets kept out of code and VCS, per-environment overrides without per-environment code. Use when the user asks how to manage config, environment variables, .env files, secrets in a service, add a config setting, or fix per-environment behavior. Do not use for infrastructure provisioning (Terraform/CloudFormation) or CI/CD pipeline configuration.
Managing Configuration
When to use / when NOT to use
- Use for: application-level configuration: env vars, .env files, secrets handling, typed config objects, feature flags vs config.
- Do NOT use for: provisioning infrastructure (Terraform etc.) or CI/CD pipeline YAML — different lifecycles and tools.
Core rules
- All config comes from the environment (12-factor). Code reads env vars; nothing environment-specific is baked into the artifact — the same image runs in dev, staging, and prod.
- One typed config object, validated at startup. Parse env once into a frozen, typed structure; every missing/invalid key crashes the boot with the key named.
- ✅
Settings()raisesDATABASE_URL: field requiredbefore serving - ❌
os.environ["DATABASE_URL"]scattered across 30 files, failing at first use in prod.
- ✅
- Secrets are not config. Passwords, API keys, signing keys come from a secret manager or injected env at deploy time — never committed, never in Docker images, never logged.
.envis for local dev only and gitignored. - No per-environment code paths. Behavior differences are driven by config values, not environment names.
- ✅
if settings.payments_sandbox:(set true in staging's env) - ❌
if ENV == "staging": use_sandbox()sprinkled through the codebase.
- ✅
- Defaults are safe for dev, explicit for prod. Local dev works out of the box (localhost DB, DEBUG level); production values must be provided — a prod boot with dev defaults should fail validation (e.g.,
SECRET_KEYhas no default). .env.exampleis the single, current catalog of every variable: name, purpose, example value, required/optional. A new variable isn't merged without its line.- Feature flags are not settings. Flags are runtime-togglable and short-lived (removed after rollout); config is boot-time and long-lived. Don't grow a flag system inside your config object.
Workflow
- Inventory every
os.environ/process.envaccess; move each into the central typed config object. - Classify each key: plain config vs secret; route secrets to the secret manager / injected env.
- Set dev defaults where safe; mark prod-critical keys as required (no default).
- Update
.env.examplewith every key; gitignore.env; purge any committed secrets (and rotate them — history remembers). - Validate: boot with one required var unset → assert the process exits naming that key; run
git log -p | grep -iE "api_key|secret|password"and a repo scan (gitleaks detect) → must be clean.
Edge cases & failure modes
- A secret was committed → rotating the secret is mandatory; rewriting git history is optional. Treat it as leaked.
- Config needed before the config system loads (log level for the config parser itself) → read that one var directly, document the exception.
- Multi-tenant / per-customer settings → that's data, not config: store in the database, not env vars.
- Large config values (PEM certs) → mount as files and put the path in env; multiline env vars break tooling.
- Different values per process in one deploy (worker vs web concurrency) → separate variables (
WEB_CONCURRENCY,WORKER_CONCURRENCY), not conditionals on process type.
References
Deeper recipes and gotchas: see references/patterns.md