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.