Environment variables are the 12-factor way to configure applications. But “just use env vars” glosses over real complexity. Here’s how to do it well.
The Basics Done Right#
Type Coercion#
Environment variables are always strings. Handle conversion explicitly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import os
def get_env_bool(key: str, default: bool = False) -> bool:
value = os.getenv(key, "").lower()
if value in ("true", "1", "yes", "on"):
return True
if value in ("false", "0", "no", "off"):
return False
return default
def get_env_int(key: str, default: int = 0) -> int:
try:
return int(os.getenv(key, default))
except ValueError:
return default
# Usage
DEBUG = get_env_bool("DEBUG", False)
PORT = get_env_int("PORT", 8080)
WORKERS = get_env_int("WORKERS", 4)
|
Required vs Optional#
Fail fast on missing required config:
1
2
3
4
5
6
7
8
9
10
11
12
| def get_env_required(key: str) -> str:
value = os.getenv(key)
if value is None:
raise RuntimeError(f"Required environment variable {key} is not set")
return value
# Application startup
DATABASE_URL = get_env_required("DATABASE_URL")
SECRET_KEY = get_env_required("SECRET_KEY")
# Optional with sensible defaults
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
Structured Configuration#
Config Classes#
Group related settings:
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
| from dataclasses import dataclass
from typing import Optional
@dataclass
class DatabaseConfig:
url: str
pool_size: int = 5
pool_timeout: int = 30
@classmethod
def from_env(cls) -> "DatabaseConfig":
return cls(
url=get_env_required("DATABASE_URL"),
pool_size=get_env_int("DB_POOL_SIZE", 5),
pool_timeout=get_env_int("DB_POOL_TIMEOUT", 30),
)
@dataclass
class AppConfig:
debug: bool
port: int
database: DatabaseConfig
redis_url: Optional[str]
@classmethod
def from_env(cls) -> "AppConfig":
return cls(
debug=get_env_bool("DEBUG", False),
port=get_env_int("PORT", 8080),
database=DatabaseConfig.from_env(),
redis_url=os.getenv("REDIS_URL"),
)
# Single point of configuration loading
config = AppConfig.from_env()
|
Validation at Startup#
Catch misconfigurations before serving traffic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| def validate_config(config: AppConfig) -> None:
errors = []
if config.port < 1 or config.port > 65535:
errors.append(f"Invalid PORT: {config.port}")
if config.database.pool_size < 1:
errors.append(f"Invalid DB_POOL_SIZE: {config.database.pool_size}")
if not config.database.url.startswith(("postgres://", "postgresql://")):
errors.append("DATABASE_URL must be a PostgreSQL connection string")
if errors:
raise RuntimeError("Configuration errors:\n" + "\n".join(errors))
# On startup
config = AppConfig.from_env()
validate_config(config)
|
Secret Management#
Never Log Secrets#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import logging
class SecretString(str):
"""String that doesn't reveal itself in logs."""
def __repr__(self) -> str:
return "SecretString(***)"
def __str__(self) -> str:
return "***"
@property
def value(self) -> str:
return super().__str__()
# Usage
API_KEY = SecretString(get_env_required("API_KEY"))
logging.info(f"Config loaded: API_KEY={API_KEY}") # Logs: API_KEY=***
requests.get(url, headers={"Authorization": API_KEY.value}) # Uses real value
|
Separate Secret Sources#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| import os
from pathlib import Path
def get_secret(key: str) -> str:
"""Load secret from file (Docker secrets) or env var."""
# Try Docker secrets first
secret_file = Path(f"/run/secrets/{key.lower()}")
if secret_file.exists():
return secret_file.read_text().strip()
# Fall back to env var
value = os.getenv(key)
if value is None:
raise RuntimeError(f"Secret {key} not found in /run/secrets or environment")
return value
DATABASE_PASSWORD = get_secret("DATABASE_PASSWORD")
|
Docker Patterns#
Compose with .env#
1
2
3
4
5
6
7
8
9
10
| # docker-compose.yml
services:
api:
image: myapp:latest
env_file:
- .env
- .env.local # Overrides, git-ignored
environment:
# Explicit overrides
- LOG_LEVEL=DEBUG
|
1
2
3
4
5
6
7
8
| # .env (committed, defaults)
PORT=8080
LOG_LEVEL=INFO
WORKERS=4
# .env.local (git-ignored, secrets)
DATABASE_URL=postgres://user:pass@localhost/db
SECRET_KEY=dev-secret-key
|
Multi-Stage Variable Handling#
1
2
3
4
5
6
7
8
9
10
| # Build args (available during build)
ARG NODE_ENV=production
# Environment vars (available at runtime)
ENV NODE_ENV=${NODE_ENV}
ENV PORT=8080
# Don't bake secrets into images!
# BAD: ENV API_KEY=secret123
# GOOD: Pass at runtime via -e or env_file
|
Kubernetes Patterns#
ConfigMaps for Non-Secrets#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "INFO"
WORKERS: "4"
CACHE_TTL: "3600"
---
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
envFrom:
- configMapRef:
name: app-config
|
Secrets for Sensitive Data#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: "postgres://user:pass@host/db"
API_KEY: "secret-key-here"
---
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
|
Combining Both#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| spec:
containers:
- name: app
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
env:
# Individual overrides
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
|
Anti-Patterns to Avoid#
❌ Parsing Complex Structures#
1
2
3
4
5
6
| # BAD: JSON in env vars
FEATURES='{"dark_mode": true, "beta": false}'
# GOOD: Flat keys
FEATURE_DARK_MODE=true
FEATURE_BETA=false
|
❌ Mutable Defaults#
1
2
3
4
5
6
7
8
| # BAD: Mutable default
def get_list(key: str, default=[]) -> list:
...
# GOOD: Immutable default
def get_list(key: str, default: tuple = ()) -> list:
value = os.getenv(key)
return value.split(",") if value else list(default)
|
❌ Global State Pollution#
1
2
3
4
5
6
7
8
| # BAD: Import-time side effects
DATABASE = connect(os.getenv("DATABASE_URL")) # Runs at import!
# GOOD: Explicit initialization
def create_app():
config = AppConfig.from_env()
database = connect(config.database.url)
return App(config, database)
|
Testing Configuration#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import pytest
from unittest.mock import patch
def test_config_from_env():
env = {
"DATABASE_URL": "postgres://test/db",
"PORT": "9000",
"DEBUG": "true",
}
with patch.dict(os.environ, env, clear=True):
config = AppConfig.from_env()
assert config.port == 9000
assert config.debug is True
assert "test/db" in config.database.url
def test_missing_required_var():
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(RuntimeError, match="DATABASE_URL"):
AppConfig.from_env()
|
The Principles#
- Fail fast: Validate all config at startup
- Type safely: Convert strings explicitly
- Group logically: Related settings in config classes
- Separate secrets: Different handling for sensitive data
- Test thoroughly: Config bugs are runtime bugs
Configuration is code. Treat it that way.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.