Skip to main content
โšก Calmops

Configuration Management: 12-Factor Apps and Beyond

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

Comments