Who this is for
Backend Engineers and DevOps-minded developers who deploy services across dev, test, staging, and production and want reliable, secure, repeatable configuration.
Prerequisites
- Comfort with a terminal and Git
- Basic understanding of environment variables
- Optional: Docker basics; basic YAML reading
Why this matters
Real backend work involves deployments that must behave predictably in different environments. You will:
- Rotate secrets without redeploying code
- Switch endpoints (e.g., sandbox vs. production) safely
- Debug issues caused by configuration drift
- Automate releases where CI/CD injects the right values at the right time
Concept explained simply
Configuration is everything your app needs to run but shouldnât be hard-coded: URLs, ports, feature flags, timeouts, API keys, database credentials. Treat it as data, not code.
Mental model: knobs and a vault
- Knobs: non-sensitive settings you can turn per environment (e.g., LOG_LEVEL, FEATURE_X_ENABLED)
- Vault: sensitive values locked away (DB_PASSWORD, API_KEY). Your app reads them at runtime; theyâre never committed to Git.
Key rules youâll follow
- 12-Factor principle: store config in the environment
- Prefer runtime or deploy-time injection over build-time baking
- Separate config for each environment; avoid dangerous fallbacks
- Use secrets managers or platform secrets, not plain text in repos
- Keep env parity: dev â staging â prod (just different values)
Core concepts and decisions
- Environment types: local, CI, test, staging, production
- Runtime vs build-time: inject at runtime to avoid rebuilding for each environment
- Sources of truth:
- Environment variables (non-sensitive and sensitive)
- Config files (JSON/YAML) for non-sensitive defaults
- Secrets stores (Vault, cloud KMS/parameter stores, platform secrets)
- Precedence example: CLI args > environment variables > config file defaults
- Configuration as code: version the structure and defaults, never real secrets
Worked examples
Example 1: .env for local + Docker env-file
Goal: run a container that reads values from a .env file without committing secrets.
Files
.gitignore
.env
.env.example
# .env.example (no secrets)
APP_ENV=local
DB_HOST=localhost
DB_USER=appuser
DB_NAME=app
# DB_PASSWORD=<set locally>
# .env (local only; DO NOT COMMIT)
APP_ENV=local
DB_HOST=localhost
DB_USER=appuser
DB_NAME=app
DB_PASSWORD=supersecret
Run
docker run --rm --env-file .env alpine:3.19 sh -c 'echo $APP_ENV $DB_HOST $DB_USER $DB_NAME'
Output should show your values but not expose them in Git.
Example 2: CI runtime injection by branch
Goal: choose environment at deploy time, not build time.
CI pseudo-workflow
name: Deploy
on:
push:
branches: [ main, staging ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Select environment
run: |
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "ENV=production" >> $GITHUB_ENV
echo "API_URL=https://api.example.com" >> $GITHUB_ENV
else
echo "ENV=staging" >> $GITHUB_ENV
echo "API_URL=https://staging-api.example.com" >> $GITHUB_ENV
fi
- name: Deploy
run: |
echo "Deploying to $ENV with $API_URL"
# Call your deploy scripts here
Example 3: Kubernetes ConfigMap + Secret
Goal: inject non-sensitive and sensitive settings separately.
Manifests
# configmap.yaml (non-sensitive)
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: info
FEATURE_X_ENABLED: "true"
---
# secret.yaml (sensitive)
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DB_PASSWORD: supersecret
---
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: web
image: your-image:latest
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
env:
- name: APP_ENV
value: production
Apply and verify with printenv in the pod. Secrets are mounted as env vars but managed separately.
Step-by-step: set up environment configuration safely
- List config: enumerate all settings your service needs. Mark which are sensitive.
- Create templates: add .env.example with only placeholders and safe defaults.
- Protect secrets: add .env to .gitignore; store real secrets in a secure manager or platform secrets.
- Inject at runtime: plan how CI/CD will set variables per environment (branch, tag, or manual promotion).
- Fail fast: on startup, validate that required variables exist; abort with clear errors.
- Parity checks: ensure the same keys exist across environments; values differ, structure doesnât.
Startup validation snippet (pseudo-code)
required = ["DB_HOST","DB_USER","DB_PASSWORD","DB_NAME"]
missing = [k for k in required if not env(k)]
if missing:
exit_with_error("Missing required config: " + ", ".join(missing))
Exercises (do these now)
These mirror the exercises below. Complete them locally and self-check with the checklist.
Exercise 1: Local .env and Docker env-file
Create .env.example and .env. Run a container with --env-file and print APP_ENV, DB_HOST, DB_USER. See the exercise card below for steps.
Exercise 2: Branch-based CI config selection
Write a simple CI workflow that echoes which environment is selected for staging vs main. See the exercise card below.
Self-check checklist
- Your repository contains .env.example but not .env
- Running the container prints expected values from .env
- CI workflow prints âDeploying to stagingâ on staging branch and âDeploying to productionâ on main branch
- There are no dangerous default fallbacks for secrets
Common mistakes and how to self-check
- Committing secrets: even in private repos. Self-check: search for DB_PASSWORD, API_KEY in Git history; add pre-commit checks.
- Baking config at build-time: forces rebuild for each environment. Self-check: does the same image work in staging and production? If not, move config to runtime.
- Missing validation: app starts with empty vars. Self-check: add startup validation and fail fast.
- Drift between environments: keys exist in prod but not staging. Self-check: compare key sets automatically in CI.
- Overusing secrets for non-sensitive values: increases friction. Self-check: classify values; keep only sensitive in secret stores.
Quick parity script idea
# Compare key sets between two files
comm -3 <(sed 's/=.*//' .env.staging | sort) <(sed 's/=.*//' .env.production | sort)
Practical projects
- Convert an existing service to runtime config: add .env.example, startup validation, and CI injection.
- Dockerize a small API and run it against staging and production just by changing env files; same image, different values.
- Kubernetes version: split non-sensitive ConfigMap and Secret, and verify printenv shows correct values.
Learning path
- Now: Environment configuration foundations
- Next: Secrets management and rotation
- Then: CI/CD deployment strategies (staged rollouts, blue/green)
- Later: Observability of config changes (auditing, change logs)
Next steps
- Finish Exercises 1â2 below
- Run the Quick Test to validate understanding
- Apply these patterns to one of your services this week
Mini challenge
You need dev, staging, and prod for an API. Design where each of these lives and how values are injected:
- LOG_LEVEL (non-sensitive)
- PAYMENTS_API_KEY (sensitive)
- FEATURE_CHECKOUT_V2 (boolean)
- DB_CONNECTION_STRING (sensitive)
Think it through
- Non-sensitive: ConfigMap or env var; version defaults
- Sensitive: secret store or platform secrets; injected at runtime
- Feature flag: env var or feature flag service; enable per environment
- Fail fast on missing sensitive values
Ready to test yourself?
Quick test is available to everyone; only logged-in users get saved progress.