The third factor of the 12-Factor App methodology states: “Store config in the environment.” Simple advice that’s surprisingly easy to get wrong.
The Core Principle#
Configuration that varies between environments (dev, staging, production) should come from environment variables, not code. This includes:
- Database connection strings
- API keys and secrets
- Feature flags
- Service URLs
- Port numbers
- Log levels
What stays in code: application logic, default behaviors, anything that doesn’t change between deploys.
Loading Environment Variables#
Shell Basics#
1
2
3
4
5
6
7
8
| # Set for current session
export DATABASE_URL="postgres://user:pass@localhost/db"
# Set for single command
DATABASE_URL="postgres://..." ./myapp
# From a file
export $(grep -v '^#' .env | xargs)
|
The .env File Pattern#
1
2
3
4
5
6
7
8
9
| # .env.example (committed to repo)
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
LOG_LEVEL=debug
# .env (gitignored, local overrides)
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp_dev
API_KEY=actual-dev-key
|
Always commit .env.example, never commit .env.
Language-Specific Loading#
Python:
1
2
3
4
5
6
7
8
| import os
from dotenv import load_dotenv
load_dotenv() # Load .env file
DATABASE_URL = os.getenv("DATABASE_URL")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
PORT = int(os.getenv("PORT", "8080"))
|
Node.js:
1
2
3
4
5
6
7
| require('dotenv').config();
const config = {
databaseUrl: process.env.DATABASE_URL,
port: parseInt(process.env.PORT, 10) || 3000,
debug: process.env.DEBUG === 'true',
};
|
Go:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| import (
"os"
"strconv"
)
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
var (
DatabaseURL = os.Getenv("DATABASE_URL")
Port = getEnv("PORT", "8080")
Debug = getEnv("DEBUG", "false") == "true"
)
|
Validation at Startup#
Fail fast if required config is missing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # config.py
import os
import sys
REQUIRED_VARS = [
"DATABASE_URL",
"SECRET_KEY",
"API_KEY",
]
def validate_config():
missing = [var for var in REQUIRED_VARS if not os.getenv(var)]
if missing:
print(f"Missing required environment variables: {', '.join(missing)}")
sys.exit(1)
# Call at startup
validate_config()
|
1
2
3
4
5
6
7
8
| // config.js
const required = ['DATABASE_URL', 'SECRET_KEY', 'API_KEY'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required environment variables: ${missing.join(', ')}`);
process.exit(1);
}
|
Type Coercion#
Environment variables are always strings. Handle conversions explicitly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import os
class Config:
# Strings (direct)
DATABASE_URL = os.getenv("DATABASE_URL")
# Integers
PORT = int(os.getenv("PORT", "8080"))
WORKERS = int(os.getenv("WORKERS", "4"))
# Booleans
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
# Lists
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost").split(",")
# Optional with None default
SENTRY_DSN = os.getenv("SENTRY_DSN") # None if not set
|
Docker and Containers#
Dockerfile#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| FROM python:3.11-slim
# Build-time args (not available at runtime)
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
# Runtime defaults (can be overridden)
ENV PORT=8080 \
LOG_LEVEL=info \
WORKERS=4
COPY . /app
WORKDIR /app
CMD ["python", "main.py"]
|
Docker Compose#
1
2
3
4
5
6
7
8
9
10
11
12
| version: '3.8'
services:
app:
build: .
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=debug
env_file:
- .env.local
ports:
- "${HOST_PORT:-8080}:8080"
|
Kubernetes#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "info"
WORKERS: "4"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: "postgres://user:secret@db:5432/myapp"
API_KEY: "super-secret-key"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
containers:
- name: app
image: myapp:latest
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
env:
# Override or add individual vars
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
|
Secrets vs Config#
Not all environment variables are equal:
Config (non-sensitive):
- Log levels
- Feature flags
- Service URLs (internal)
- Worker counts
- Timeouts
Secrets (sensitive):
- Database passwords
- API keys
- Encryption keys
- OAuth tokens
Secret Management Options#
Development: .env files (gitignored)
CI/CD: Pipeline secrets (GitHub Actions, GitLab CI, etc.)
1
2
3
4
5
6
7
8
9
| # GitHub Actions
jobs:
deploy:
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
|
Production: Dedicated secrets managers
1
2
3
4
5
6
7
| # AWS Secrets Manager
aws secretsmanager get-secret-value --secret-id myapp/prod | \
jq -r '.SecretString' | jq -r 'to_entries[] | "export \(.key)=\(.value)"'
# HashiCorp Vault
vault kv get -format=json secret/myapp/prod | \
jq -r '.data.data | to_entries[] | "export \(.key)=\(.value)"'
|
Environment-Specific Patterns#
Layered Configuration#
1
2
3
4
5
6
7
8
| # Base config (all environments)
source .env.base
# Environment-specific overrides
source .env.${ENVIRONMENT:-development}
# Local overrides (optional, gitignored)
[ -f .env.local ] && source .env.local
|
Prefix Namespacing#
1
2
3
4
| # Avoid collisions in shared environments
MYAPP_DATABASE_URL=postgres://...
MYAPP_REDIS_URL=redis://...
MYAPP_LOG_LEVEL=debug
|
1
2
3
4
5
6
7
8
| import os
PREFIX = "MYAPP_"
def get_config(key, default=None):
return os.getenv(f"{PREFIX}{key}", default)
DATABASE_URL = get_config("DATABASE_URL")
|
Common Mistakes#
Mistake 1: Secrets in Docker Images#
1
2
3
4
5
| # WRONG - secret baked into image
ENV API_KEY=super-secret-key
# RIGHT - set at runtime
ENV API_KEY=""
|
Mistake 2: Logging Secrets#
1
2
3
4
5
| # WRONG
logger.info(f"Connecting with {DATABASE_URL}")
# RIGHT
logger.info(f"Connecting to database at {urlparse(DATABASE_URL).hostname}")
|
Mistake 3: No Defaults for Optional Config#
1
2
3
4
5
| # WRONG - crashes if not set
TIMEOUT = int(os.environ["TIMEOUT"])
# RIGHT - sensible default
TIMEOUT = int(os.getenv("TIMEOUT", "30"))
|
Mistake 4: Trusting .env in Production#
1
2
3
4
5
6
| # WRONG - loads .env unconditionally
load_dotenv()
# RIGHT - only in development
if os.getenv("ENVIRONMENT") != "production":
load_dotenv()
|
Testing with Environment Variables#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| import os
import pytest
@pytest.fixture
def mock_env(monkeypatch):
"""Set up test environment variables."""
monkeypatch.setenv("DATABASE_URL", "postgres://test:test@localhost/test")
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setenv("DEBUG", "true")
def test_config_loading(mock_env):
# Import after env is set
from myapp import config
assert config.DEBUG is True
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Jest
describe('config', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it('loads database URL', () => {
process.env.DATABASE_URL = 'postgres://test@localhost/test';
const config = require('./config');
expect(config.databaseUrl).toBe('postgres://test@localhost/test');
});
});
|
Quick Reference#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| # Check if variable is set
[ -z "${VAR}" ] && echo "VAR is not set"
# Default value
${VAR:-default}
# Required (exit if not set)
${VAR:?VAR must be set}
# Export all from file
set -a; source .env; set +a
# List all env vars with prefix
env | grep "^MYAPP_"
# Unset variable
unset VAR
|
Environment variables are the bridge between your code and its deployment context. Get them right, and the same code runs everywhere. Get them wrong, and you’re debugging config issues at 3 AM.
Keep secrets out of code, validate at startup, and fail fast when something’s missing. Your future self will appreciate it.