Handbooks / CI/CD & DevOps / Chapter 5
Configuration and Environment Parity
7 pages · ~5 min✓ Reviewed
Builds on Docker Images as Build Artifacts. Next up: Secrets and Pipeline Security.
Twelve-Factor Config: The Mental Model
1. Config vs Code
- Config = everything that varies per deploy
- DB URLs, credentials, hostnames, third-party keys
- Code = identical across every deploy
- Factor III: config in env vars, never in the repo
- One build artifact, many deploys
- Litmus: could you open-source the repo now without leaking a credential?
2. Env Vars vs Files
| Env vars | Files (config/prod.yml) | |
|---|---|---|
| Granularity | per variable | whole file |
| Language | neutral | format-specific |
| Risk | kept out of repo | multiply, get committed |
| Grouping | one var each | avoid a 'production' group |
3. Fail Fast
- Silent default like
DB_URLor localhost - Prod boots against the wrong database
import os os.environ.pop("DB_URL", None) try: db = os.environ["DB_URL"] except KeyError: print("DB_URL missing, refusing to boot")
no default: a missing required var stops startup
DB_URL missing, refusing to boot
Required var missing? Crash at startup, never guess.
Dev/Staging/Prod Parity
1. The Three Gaps
| Gap | Cause | Fix |
|---|---|---|
| Time | long deploy cycles | deploy continuously: hours not weeks |
| People | devs write, ops deploys | devs deploy their own code |
| Tools | different backing services | same service type and version everywhere |
2. Parity of Behaviour, Not Scale
- Staging: same images, same OS, same service versions
- Size and data volume can be smaller
- SQLite in dev, Postgres in prod hides SQL dialect and locking bugs
- Run real Postgres locally via docker compose
services:
db:
image: postgres:16
ports: ["5432:5432"]pin the same version prod runs
3. Promote One Image
- Build oncetag app:1.4.2
- Dev
- Staging
- Prod
Configuration per Environment
1. Layering Order
- 1Defaultsbuilt in
- 2Config fileper app
- 3Env varsper deploy
- 4CLI flagswins
2. Secrets and Injection
| Lives in | Reaches the app | |
|---|---|---|
| Plain config | Git or a ConfigMap | configMapRef |
| Secrets | Vault, AWS Secrets Manager | injected at runtime |
envFrom:
- configMapRef: {name: app-config}
- secretRef: {name: app-secrets}or: docker run --env-file .env.staging
.envfiles stay gitignored; commit.env.example- Scope CI secrets per environment: GitHub Actions environments, GitLab protected variables
- Prod secrets reach only prod deploy jobs, masked in logs
3. Mistake: Keying on Environment Name
NODE_ENV=stagingplusif env == 'prod'branches hides untested paths- Use explicit settings:
LOG_LEVEL,FEATURE_X
Feature Flags vs Config
1. Config vs Flag
| Config | Feature flag | |
|---|---|---|
| Set | per deploy | per request or user |
| Change needs | restart or redeploy | no deploy |
2. Flag Types
| Type | Use | Life |
|---|---|---|
| Release | hide unfinished code | short |
| Ops | kill switch | varies |
| Experiment | A/B test | short |
| Permission | entitlement | long-lived |
3. Mechanism and Rollout
- Appsends user context
- Flag serviceLaunchDarkly, Unleash, OpenFeature
- Decisionnew_flow or old_flow
Ephemeral Preview Environments and Migrations in the Pipeline
1. Preview Environments
- Preview env = full short-lived copy of the app, one per pull request
- Own URL:
pr-123.preview.example.com - Destroyed on PR close or merge; a TTL job is the fallback
- Data is seeded or anonymised, never a production copy
- Own database or schema per preview; small instance sizes cap cost
- PR openbuild image
- Deploynamespace pr-123
- Seed DBfake data
- Post URLcomment on PR
import os os.environ["PR_NUMBER"] = "123" pr = os.environ["PR_NUMBER"] print(f"pr-{pr}.preview.example.com") print(f"namespace: pr-{pr}")
URL and namespace both derive from the PR number
pr-123.preview.example.com namespace: pr-123
2. Migrations in the Pipeline
- Run as a separate step before the app rollout
- Use a Kubernetes
Jobor a release-phase command - Never run from every instance at boot: races
- 1Expandadd nullable column
- 2Deployold and new code both work
- 3Backfillfill old rows
- 4Contractdrop old column later
Cheat Sheet: Config and Environments
1. Config and Precedence
- Env vars hold anything that differs per deploy
- One artifact is promoted unchanged through every environment
- Missing required var: crash at startup, no silent default
- Defaultslowest
- File
- Env var
- CLI flaghighest
import os try: db = os.environ["APP_DB_URL"] except KeyError as err: print("crash at startup: missing", err)
required var: no default
crash at startup: missing 'APP_DB_URL'2. Parity and Secrets
- Same backing service type and version
- Same image in every environment
- Deploy continuously; devs own their deploys
- Secrets: manager plus runtime injection, scoped per environment
.envgitignored,.env.examplecommitted
3. Flags, Previews, Migrations
| Topic | Rule |
|---|---|
| Config vs flag | Config needs a redeploy; a flag is a runtime toggle |
Check yourself
Code reads os.environ.get('DB_URL', 'postgres://localhost/app'). Prod forgets to set DB_URL. What happens, and what's the fix?
- It boots with the default and points at localhost, not the real database.
- Use
os.environ['DB_URL']so a missing var crashes at startup.
A release renames column name to full_name in a migration, then rolls out new pods gradually. What breaks?
- Old pods still run and query
name, so they error mid-rollout. - Expand/contract: add
full_name, deploy, backfill, dropnamein a later release.
The file sets LOG_LEVEL=info, the environment sets LOG_LEVEL=debug, and the command runs with --log-level=warn. Which wins?
warn: precedence is defaults, file, env var, then CLI flag, with the flag highest.