Skip to main content

Configuration Management: 12-Factor Apps and Beyond

Created: February 27, 2026 Larry Qu 6 min read

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

Share this article

Scan to read on mobile

👍 Was this article helpful?