Configuration is any value that changes between deployments. Good configuration management lets you run the same code in development, staging, and production with different settings.
The 12-Factor App Config
The third factor of 12-factor apps: “Store config in the environment.”
# BAD - Hardcoded configuration
class Database:
def __init__(self):
self.host = "localhost"
self.port = 5432
self.name = "myapp_dev"
# GOOD - From environment
import os
class Database:
def __init__(self):
self.host = os.environ.get("DB_HOST", "localhost")
self.port = int(os.environ.get("DB_PORT", "5432"))
self.name = os.environ.get("DB_NAME", "myapp")
self.user = os.environ.get("DB_USER", "postgres")
self.password = os.environ.get("DB_PASSWORD", "")
Configuration Sources
Applications typically load config from multiple sources, in order of precedence:
1. Command line arguments (highest priority)
2. Environment variables
3. Configuration files
4. Default values (lowest priority)
Implementation
import os
import json
from dataclasses import dataclass
from typing import Optional, List
@dataclass
class Config:
# Database
db_host: str
db_port: int
db_name: str
db_user: str
db_password: str
# Redis
redis_host: str
redis_port: int
redis_password: Optional[str]
# App
app_host: str
app_port: int
debug: bool
log_level: str
# External services
api_keys: List[str]
@classmethod
def from_env(cls) -> "Config":
return cls(
db_host=os.environ.get("DB_HOST", "localhost"),
db_port=int(os.environ.get("DB_PORT", "5432")),
db_name=os.environ.get("DB_NAME", "myapp"),
db_user=os.environ.get("DB_USER", "postgres"),
db_password=os.environ.get("DB_PASSWORD", ""),
redis_host=os.environ.get("REDIS_HOST", "localhost"),
redis_port=int(os.environ.get("REDIS_PORT", "6379")),
redis_password=os.environ.get("REDIS_PASSWORD"),
app_host=os.environ.get("APP_HOST", "0.0.0.0"),
app_port=int(os.environ.get("APP_PORT", "8000")),
debug=os.environ.get("DEBUG", "false").lower() == "true",
log_level=os.environ.get("LOG_LEVEL", "INFO"),
api_keys=os.environ.get("API_KEYS", "").split(","),
)
# Usage
config = Config.from_env()
Environment-Specific Config
Development vs Production
# config/development.py
development_config = {
"debug": True,
"log_level": "DEBUG",
"db_host": "localhost",
"cache_ttl": 60,
"max_connections": 10,
}
# config/production.py
production_config = {
"debug": False,
"log_level": "WARNING",
"db_host": "db.production.internal",
"cache_ttl": 3600,
"max_connections": 100,
}
# config_loader.py
import os
def load_config():
env = os.environ.get("ENVIRONMENT", "development")
if env == "production":
return production_config
elif env == "staging":
return staging_config
else:
return development_config
Configuration Files
# config.yaml
database:
host: ${DB_HOST}
port: ${DB_PORT}
name: ${DB_NAME}
user: ${DB_USER}
password: ${DB_PASSWORD}
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
app:
host: 0.0.0.0
port: 8080
workers: ${WORKERS}
import yaml
def load_config_yaml(path: str) -> dict:
with open(path) as f:
config = yaml.safe_load(f)
# Interpolate environment variables
return interpolate_env_vars(config)
def interpolate_env_vars(config):
if isinstance(config, dict):
return {k: interpolate_env_vars(v) for k, v in config.items()}
elif isinstance(config, list):
return [interpolate_env_vars(item) for item in config]
elif isinstance(config, str) and config.startswith("${") and config.endswith("}"):
var = config[2:-1]
default = None
if ":" in var:
var, default = var.split(":", 1)
return os.environ.get(var, default or config)
return config
Secrets Management
Never store secrets in code or config files:
Using Environment Variables
# .env file (add to .gitignore!)
export DB_PASSWORD="secret123"
export API_KEY="sk-xxx"
export JWT_SECRET="very-long-secret-key"
# Load .env file
from dotenv import load_dotenv
load_dotenv() # Loads .env file into environment
# Or use python-dotenv in production
# But never commit .env files!
Using HashiCorp Vault
import hvac
class VaultClient:
def __init__(self, url: str, token: str, mount_point: str = "secret"):
self.client = hvac.Client(url=url, token=token)
self.mount_point = mount_point
def get_secret(self, path: str) -> dict:
return self.client.secrets.kv.v2.read_secret_version(
path=path,
mount_point=self.mount_point
)["data"]["data"]
def get_db_credentials(self) -> dict:
return self.get_secret("database")
def get_api_keys(self) -> dict:
return self.get_secret("external-apis")
# Usage
vault = VaultClient(
url=os.environ["VAULT_ADDR"],
token=os.environ["VAULT_TOKEN"]
)
db_creds = vault.get_db_credentials()
# db_creds = {"host": "...", "user": "...", "password": "..."}
AWS Secrets Manager
import boto3
class AWSSecretsManager:
def __init__(self, region_name: str = "us-east-1"):
self.client = boto3.client("secretsmanager", region_name=region_name)
def get_secret(self, secret_name: str) -> dict:
response = self.client.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])
# Usage
secrets = AWSSecretsManager()
db_creds = secrets.get_secret("prod/database")
Kubernetes Secrets
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
stringData:
username: postgres
password: secret123
# In Kubernetes, secrets are mounted as files
# /run/secrets/db/username
# /run/secrets/db/password
import os
def get_db_credentials():
return {
"username": open("/run/secrets/db/username").read().strip(),
"password": open("/run/secrets/db/password").read().strip(),
}
Configuration Validation
Validate config at startup:
from pydantic import BaseModel, validator
from typing import Optional
class AppConfig(BaseModel):
db_host: str
db_port: int = 5432
db_name: str
db_user: str
db_password: str
redis_host: str
redis_port: int = 6379
app_port: int = 8080
log_level: str = "INFO"
@validator("db_port", "redis_port")
def port_must_be_positive(cls, v):
if v <= 0:
raise ValueError("Port must be positive")
return v
@validator("log_level")
def log_level_must_be_valid(cls, v):
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
if v.upper() not in valid_levels:
raise ValueError(f"Log level must be one of {valid_levels}")
return v.upper()
# Usage - validates on creation
config = AppConfig(
db_host="localhost",
db_name="myapp",
db_user="postgres",
db_password="secret",
redis_host="localhost"
)
Configuration Updates
Hot Reload
import time
import threading
from pathlib import Path
class ConfigReloader:
def __init__(self, config_path: str, reload_interval: int = 30):
self.config_path = config_path
self.reload_interval = reload_interval
self._config = None
self._lock = threading.Lock()
self._start_reloader()
def _start_reloader(self):
def reload_loop():
while True:
time.sleep(self.reload_interval)
with self._lock:
self._config = self._load_config()
thread = threading.Thread(target=reload_loop, daemon=True)
thread.start()
def _load_config(self):
with open(self.config_path) as f:
return yaml.safe_load(f)
@property
def config(self):
with self._lock:
return self._config
# Usage
config = ConfigReloader("/etc/app/config.yaml")
@app.get("/config")
def get_config():
return config.config
Kubernetes ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "INFO"
DB_HOST: "postgres"
REDIS_HOST: "redis"
WORKERS: "4"
# In Kubernetes, ConfigMaps are either:
# 1. Mounted as files
# 2. Injected as environment variables
# Using volume mount:
# mountPath: /etc/config
# File: /etc/config/settings.json
with open("/etc/config/settings.json") as f:
config = json.load(f)
Configuration Best Practices
Do
- Store config in environment, not code
- Use separate configs per environment
- Validate config at startup
- Use secret management for sensitive data
- Document all configuration options
Don’t
- Don’t commit secrets to version control
- Don’t hardcode environment-specific values
- Don’t use different config formats per service
- Don’t skip config validation
Example: Complete Configuration System
# config.py
import os
import json
from dataclasses import dataclass
from typing import Optional
import yaml
import boto3
@dataclass
class DatabaseConfig:
host: str
port: int
name: str
user: str
password: str
pool_size: int = 10
@dataclass
class RedisConfig:
host: str
port: int
password: Optional[str] = None
@dataclass
class AppConfig:
database: DatabaseConfig
redis: RedisConfig
log_level: str
debug: bool
def load_config() -> AppConfig:
env = os.environ.get("ENVIRONMENT", "development")
# Load from Vault in production
if env == "production":
secrets = get_vault_secrets()
db_password = secrets["db_password"]
else:
db_password = os.environ.get("DB_PASSWORD", "")
return AppConfig(
database=DatabaseConfig(
host=os.environ.get("DB_HOST", "localhost"),
port=int(os.environ.get("DB_PORT", "5432")),
name=os.environ.get("DB_NAME", "myapp"),
user=os.environ.get("DB_USER", "postgres"),
password=db_password,
pool_size=int(os.environ.get("DB_POOL_SIZE", "10"))
),
redis=RedisConfig(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", "6379")),
password=os.environ.get("REDIS_PASSWORD")
),
log_level=os.environ.get("LOG_LEVEL", "INFO"),
debug=os.environ.get("DEBUG", "").lower() == "true"
)
def get_vault_secrets():
# Implementation for Vault
...
# Run validation
config = load_config()
Tools
| Tool | Use Case | Type |
|---|---|---|
| Vault | Secret management | Open source/Commercial |
| AWS Secrets Manager | AWS secrets | Cloud service |
| AWS Parameter Store | Parameters | Cloud service |
| Consul | Service configuration | Open source |
| etcd | Distributed config | Open source |
| Spring Cloud Config | Java/spring | Open source |
Conclusion
Good configuration management enables:
- Same code, multiple environments
- Secure secret handling
- Easy configuration changes
- Configuration validation
- Audit trails
Start with environment variables, add validation, then add secret management.
External Resources
Related Articles
- Clean Code Principles - Configuration in code
- Secrets Management - Full secrets guide
Comments