The twelve-factor app methodology tells us: store config in the environment. It’s good advice. Environment variables separate config from code, making the same artifact deployable across environments.
But “store config in environment variables” doesn’t mean “scatter random ENV vars everywhere and hope for the best.” Done poorly, environment configuration becomes untraceable, untestable, and unmaintainable.
The Baseline Pattern#
Centralize environment variable access:
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
| import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
database_url: str
redis_url: str
api_key: str
debug: bool
max_workers: int
log_level: str
@classmethod
def from_env(cls) -> 'Config':
return cls(
database_url=os.environ['DATABASE_URL'],
redis_url=os.environ['REDIS_URL'],
api_key=os.environ['API_KEY'],
debug=os.environ.get('DEBUG', 'false').lower() == 'true',
max_workers=int(os.environ.get('MAX_WORKERS', '4')),
log_level=os.environ.get('LOG_LEVEL', 'INFO'),
)
# Single source of truth
config = Config.from_env()
|
Benefits:
- All config in one place
- Type conversion happens once
- Missing required vars fail fast at startup
- IDE autocompletion works
Fail Fast on Missing Config#
Don’t let missing config create mysterious runtime errors:
1
2
3
4
5
6
7
8
9
10
11
| def require_env(name: str) -> str:
value = os.environ.get(name)
if value is None:
raise EnvironmentError(f"Required environment variable {name} is not set")
return value
# Application won't start with missing config
config = Config(
database_url=require_env('DATABASE_URL'),
# ...
)
|
A clear error at startup is infinitely better than a NoneType has no attribute error buried in production logs hours later.
Validation Beyond Existence#
Existence isn’t enough. Validate format and constraints:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| from urllib.parse import urlparse
def validate_database_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in ('postgres', 'postgresql', 'mysql'):
raise ValueError(f"DATABASE_URL must be postgres or mysql, got {parsed.scheme}")
if not parsed.hostname:
raise ValueError("DATABASE_URL must include hostname")
return url
def validate_port(value: str, min_port=1, max_port=65535) -> int:
port = int(value)
if not min_port <= port <= max_port:
raise ValueError(f"Port must be between {min_port} and {max_port}")
return port
config = Config(
database_url=validate_database_url(require_env('DATABASE_URL')),
port=validate_port(os.environ.get('PORT', '8080')),
)
|
Environment-Specific Defaults#
Some defaults only make sense in certain contexts:
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
| import os
ENV = os.environ.get('ENVIRONMENT', 'development')
DEFAULTS = {
'development': {
'DEBUG': 'true',
'LOG_LEVEL': 'DEBUG',
'DATABASE_URL': 'postgres://localhost/myapp_dev',
},
'staging': {
'DEBUG': 'false',
'LOG_LEVEL': 'INFO',
},
'production': {
'DEBUG': 'false',
'LOG_LEVEL': 'WARNING',
},
}
def get_config(name: str, required: bool = True) -> Optional[str]:
# Explicit env var takes precedence
value = os.environ.get(name)
if value:
return value
# Fall back to environment-specific default
env_defaults = DEFAULTS.get(ENV, {})
value = env_defaults.get(name)
if value:
return value
if required:
raise EnvironmentError(f"{name} not set and no default for {ENV}")
return None
|
The .env File (For Development Only)#
.env files are convenient for local development. Never commit them, never use them in production:
1
2
3
4
5
| # .env (gitignored!)
DATABASE_URL=postgres://localhost/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev-key-not-for-production
DEBUG=true
|
Load with python-dotenv or similar:
1
2
3
4
5
6
7
| from dotenv import load_dotenv
# Only load .env in development
if os.environ.get('ENVIRONMENT') != 'production':
load_dotenv()
config = Config.from_env()
|
Why not in production? Because:
- Files can be read by other processes
- Files aren’t managed by your orchestrator
- Files drift from what’s actually deployed
- Secret management systems can’t inject into files easily
Documentation as Code#
Document your environment variables where they’re defined:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| @dataclass
class Config:
"""Application configuration loaded from environment variables.
Required:
DATABASE_URL: PostgreSQL connection string
Format: postgres://user:pass@host:port/dbname
API_KEY: External service API key
Optional:
DEBUG: Enable debug mode (default: false)
LOG_LEVEL: Logging level (default: INFO)
Values: DEBUG, INFO, WARNING, ERROR
MAX_WORKERS: Worker thread count (default: 4)
PORT: HTTP server port (default: 8080)
"""
|
Generate documentation from this if needed. The source of truth is always the code.
Testing Configuration#
Your config code needs tests too:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| import pytest
import os
def test_config_loads_from_env(monkeypatch):
monkeypatch.setenv('DATABASE_URL', 'postgres://localhost/test')
monkeypatch.setenv('REDIS_URL', 'redis://localhost')
monkeypatch.setenv('API_KEY', 'test-key')
config = Config.from_env()
assert config.database_url == 'postgres://localhost/test'
assert config.debug is False # default
def test_config_fails_on_missing_required(monkeypatch):
monkeypatch.delenv('DATABASE_URL', raising=False)
with pytest.raises(EnvironmentError):
Config.from_env()
def test_config_validates_database_url(monkeypatch):
monkeypatch.setenv('DATABASE_URL', 'not-a-valid-url')
with pytest.raises(ValueError):
Config.from_env()
|
Kubernetes and Container Orchestration#
In Kubernetes, environment variables come from multiple sources:
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
| apiVersion: v1
kind: Pod
spec:
containers:
- name: app
env:
# Literal value
- name: LOG_LEVEL
value: "INFO"
# From ConfigMap
- name: MAX_WORKERS
valueFrom:
configMapKeyRef:
name: app-config
key: max_workers
# From Secret
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database_url
# From Pod metadata
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
|
ConfigMaps for non-sensitive config, Secrets for credentials. Never commit Secrets to git.
Common Anti-Patterns#
Scattered os.environ calls:
1
2
3
4
5
6
7
8
| # Bad: env vars accessed all over the codebase
def connect_db():
url = os.environ.get('DATABASE_URL') # What if it's missing?
# ...
def send_email():
api_key = os.environ['SENDGRID_KEY'] # Different pattern!
# ...
|
Centralize all config access.
Boolean parsing inconsistency:
1
2
3
4
| # Bad: different truthy interpretations
DEBUG = os.environ.get('DEBUG') # String "false" is truthy!
VERBOSE = os.environ.get('VERBOSE') == 'true' # This one's right
ENABLED = bool(os.environ.get('ENABLED')) # Empty string is falsy
|
Pick one parsing convention and use it everywhere.
Secrets in environment variable names:
1
2
3
4
5
| # Bad: the secret is in the variable name
export API_KEY_sk_live_abc123=true
# Good: secret is the value
export API_KEY=sk_live_abc123
|
Environment variable names often appear in logs and error messages.
Environment variables are the right place for configuration. But treating them as a dumping ground leads to config sprawl, mysterious failures, and security risks.
Centralize access. Validate early. Fail fast. Document everything. Test your config loading. The result is configuration that’s traceable, testable, and maintainable — which is the whole point of separating config from code in the first place.