Multi-Environment Configuration¶
This example demonstrates how to manage configurations across multiple environments (development, staging, production) using the DataKnobs Config package.
Environment Strategy¶
Directory Structure¶
config/
├── base.yaml # Base configuration (shared)
├── development.yaml # Development overrides
├── staging.yaml # Staging overrides
├── production.yaml # Production overrides
├── local.yaml # Local developer overrides (gitignored)
└── secrets/ # Sensitive configurations
├── development.yaml
├── staging.yaml
└── production.yaml
Base Configuration¶
Shared Settings¶
# config/base.yaml
application:
name: MyApp
version: ${APP_VERSION:1.0.0}
timezone: UTC
databases:
- name: primary
type: postgresql
database: myapp
options:
connect_timeout: 10
statement_timeout: 30000
caches:
- name: redis
type: redis
db: 0
decode_responses: true
services:
- name: api
type: fastapi
workers: 1
- name: worker
type: celery
tasks:
- tasks.email
- tasks.reports
monitoring:
metrics:
enabled: true
interval: 60
logging:
format: json
level: INFO
features:
authentication:
enabled: true
session_timeout: 3600
rate_limiting:
enabled: true
default_limit: 100
caching:
enabled: true
ttl: 300
Environment-Specific Configurations¶
Development Environment¶
# config/development.yaml
databases:
- name: primary
host: localhost
port: 5432
username: dev_user
password: dev_password
pool:
min_size: 2
max_size: 10
options:
log_statement: all
caches:
- name: redis
host: localhost
port: 6379
services:
- name: api
host: localhost
port: 8000
settings:
debug: true
reload: true
cors:
enabled: true
origins: ["*"]
monitoring:
logging:
level: DEBUG
console: true
features:
rate_limiting:
enabled: false
caching:
enabled: false
Staging Environment¶
# config/staging.yaml
databases:
- name: primary
host: staging-db.internal
port: 5432
username: ${DB_USER}
password: ${DB_PASSWORD}
pool:
min_size: 5
max_size: 20
options:
sslmode: require
caches:
- name: redis
host: staging-redis.internal
port: 6379
password: ${REDIS_PASSWORD}
services:
- name: api
host: 0.0.0.0
port: 8000
workers: 2
settings:
debug: false
cors:
enabled: true
origins:
- https://staging.example.com
monitoring:
logging:
level: INFO
outputs:
- console
- file: /var/log/myapp/staging.log
alerts:
enabled: true
webhook: ${ALERT_WEBHOOK_URL}
Production Environment¶
# config/production.yaml
databases:
- name: primary
host: ${DB_HOST}
port: ${DB_PORT:5432}
username: ${DB_USER}
password: ${DB_PASSWORD}
database: ${DB_NAME}
pool:
min_size: 20
max_size: 100
timeout: 30
options:
sslmode: require
sslcert: /etc/ssl/certs/client.crt
sslkey: /etc/ssl/private/client.key
- name: replica
host: ${DB_REPLICA_HOST}
port: ${DB_PORT:5432}
username: ${DB_USER}
password: ${DB_PASSWORD}
database: ${DB_NAME}
readonly: true
caches:
- name: redis
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
ssl: true
services:
- name: api
host: 0.0.0.0
port: ${PORT:8000}
workers: ${WEB_CONCURRENCY:4}
settings:
debug: false
cors:
enabled: true
origins: ${CORS_ORIGINS}
- name: worker
concurrency: ${WORKER_CONCURRENCY:20}
monitoring:
logging:
level: WARNING
outputs:
- type: cloudwatch
log_group: /aws/myapp/production
metrics:
backend: prometheus
port: 9090
alerts:
enabled: true
services:
- pagerduty: ${PAGERDUTY_KEY}
- slack: ${SLACK_WEBHOOK}
features:
rate_limiting:
default_limit: 1000
redis_backend: true
caching:
ttl: 3600
redis_backend: true
Environment Manager Implementation¶
Configuration Loader¶
# config/loader.py
import os
from pathlib import Path
from dataknobs_config import Config, Settings
from typing import Optional, List
import logging
logger = logging.getLogger(__name__)
class EnvironmentConfig:
"""Manages multi-environment configuration loading."""
ENVIRONMENTS = ["development", "staging", "production", "test"]
def __init__(self, config_dir: str = "config"):
self.config_dir = Path(config_dir)
self.environment = self._detect_environment()
self.config = None
def _detect_environment(self) -> str:
"""Detect current environment from various sources."""
# Check environment variables
env = os.getenv("APP_ENV") or \
os.getenv("ENVIRONMENT") or \
os.getenv("ENV")
if env and env.lower() in self.ENVIRONMENTS:
return env.lower()
# Check for CI/CD indicators
if os.getenv("CI"):
return "test"
# Default to development
return "development"
def load(self,
environment: Optional[str] = None,
include_secrets: bool = True,
include_local: bool = True) -> Config:
"""Load configuration for specified environment."""
env = environment or self.environment
logger.info(f"Loading configuration for environment: {env}")
# Start with base configuration
config = Config.from_file(self.config_dir / "base.yaml")
# Merge environment-specific configuration
env_file = self.config_dir / f"{env}.yaml"
if env_file.exists():
config.merge_file(str(env_file))
logger.info(f"Merged environment config: {env_file}")
# Merge secrets if they exist
if include_secrets:
secrets_file = self.config_dir / "secrets" / f"{env}.yaml"
if secrets_file.exists():
config.merge_file(str(secrets_file))
logger.info(f"Merged secrets: {secrets_file}")
# Merge local overrides (for development)
if include_local and env == "development":
local_file = self.config_dir / "local.yaml"
if local_file.exists():
config.merge_file(str(local_file))
logger.info(f"Merged local overrides: {local_file}")
# Apply environment variable overrides
config.apply_env_overrides()
# Validate configuration
self._validate_config(config, env)
self.config = config
return config
def _validate_config(self, config: Config, environment: str):
"""Validate configuration for environment."""
# Check required fields based on environment
if environment == "production":
self._validate_production_config(config)
elif environment == "staging":
self._validate_staging_config(config)
def _validate_production_config(self, config: Config):
"""Validate production configuration."""
required = [
("databases", "primary", "password"),
("monitoring", "alerts", "enabled"),
("services", "api", "workers"),
]
for path in required:
value = config.get(*path[:-1])
if not value or path[-1] not in value:
raise ValueError(f"Missing required production config: {'.'.join(path)}")
def _validate_staging_config(self, config: Config):
"""Validate staging configuration."""
# Staging-specific validation
pass
def get_environment(self) -> str:
"""Get current environment name."""
return self.environment
def is_production(self) -> bool:
"""Check if running in production."""
return self.environment == "production"
def is_development(self) -> bool:
"""Check if running in development."""
return self.environment == "development"
Environment-Aware Application¶
# app.py
from config.loader import EnvironmentConfig
from dataknobs_config import Config
import logging
from typing import Optional
class Application:
"""Environment-aware application."""
def __init__(self, environment: Optional[str] = None):
self.env_config = EnvironmentConfig()
self.config = self.env_config.load(environment)
self.environment = self.env_config.get_environment()
self._setup_logging()
self._setup_features()
def _setup_logging(self):
"""Configure logging based on environment."""
log_config = self.config.get("monitoring", "logging")
level = getattr(logging, log_config.get("level", "INFO"))
format_type = log_config.get("format", "simple")
if format_type == "json":
import json_logging
json_logging.init_fastapi(enable_json=True)
else:
logging.basicConfig(
level=level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Add environment-specific handlers
if self.env_config.is_production():
self._add_production_logging()
elif self.env_config.is_development():
self._add_development_logging()
def _add_production_logging(self):
"""Add production logging handlers."""
outputs = self.config.get("monitoring", "logging", "outputs", default=[])
for output in outputs:
if isinstance(output, dict) and "cloudwatch" in output:
# Setup CloudWatch logging
import watchtower
handler = watchtower.CloudWatchLogHandler(
log_group=output["cloudwatch"]["log_group"]
)
logging.getLogger().addHandler(handler)
def _add_development_logging(self):
"""Add development logging handlers."""
# Development uses console logging by default
pass
def _setup_features(self):
"""Enable/disable features based on configuration."""
features = self.config.get("features", default={})
# Setup authentication
if features.get("authentication", {}).get("enabled", False):
self._setup_authentication()
# Setup rate limiting
if features.get("rate_limiting", {}).get("enabled", False):
self._setup_rate_limiting()
# Setup caching
if features.get("caching", {}).get("enabled", False):
self._setup_caching()
def _setup_authentication(self):
"""Configure authentication."""
auth_config = self.config.get("features", "authentication")
# Authentication setup logic
pass
def _setup_rate_limiting(self):
"""Configure rate limiting."""
rate_config = self.config.get("features", "rate_limiting")
if self.env_config.is_production() and rate_config.get("redis_backend"):
# Use Redis for distributed rate limiting
pass
else:
# Use in-memory rate limiting
pass
def _setup_caching(self):
"""Configure caching."""
cache_config = self.config.get("features", "caching")
if cache_config.get("redis_backend"):
# Use Redis cache
cache = self.config.construct("caches", "redis")
else:
# Use in-memory cache
from cachetools import TTLCache
cache = TTLCache(maxsize=1000, ttl=cache_config.get("ttl", 300))
def run(self):
"""Run the application."""
logger = logging.getLogger(__name__)
logger.info(f"Starting application in {self.environment} environment")
# Start services based on environment
if self.env_config.is_production():
self._run_production()
elif self.env_config.is_development():
self._run_development()
else:
self._run_default()
def _run_production(self):
"""Run in production mode."""
# Production-specific startup
pass
def _run_development(self):
"""Run in development mode."""
# Development-specific startup (hot reload, debug, etc.)
pass
def _run_default(self):
"""Run in default mode."""
pass
Docker Integration¶
Dockerfile with Multi-Stage Builds¶
# Dockerfile
# Base stage
FROM python:3.11-slim as base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Development stage
FROM base as development
ENV APP_ENV=development
RUN pip install --no-cache-dir -r requirements-dev.txt
CMD ["python", "-m", "uvicorn", "main:app", "--reload", "--host", "0.0.0.0"]
# Production stage
FROM base as production
ENV APP_ENV=production
RUN pip install --no-cache-dir gunicorn
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker"]
Docker Compose for Multiple Environments¶
# docker-compose.yaml
version: '3.8'
services:
app:
build:
context: .
target: ${BUILD_TARGET:-development}
environment:
- APP_ENV=${APP_ENV:-development}
env_file:
- .env
- .env.${APP_ENV:-development}
volumes:
- ./config:/app/config:ro
depends_on:
- postgres
- redis
postgres:
image: postgres:14
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER:-dev_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-dev_password}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7
command: redis-server ${REDIS_CONFIG:-}
volumes:
postgres_data:
Environment-Specific Compose Files¶
# docker-compose.development.yaml
version: '3.8'
services:
app:
build:
target: development
ports:
- "8000:8000"
volumes:
- .:/app
- ./config:/app/config:ro
environment:
- DEBUG=true
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
# docker-compose.production.yaml
version: '3.8'
services:
app:
build:
target: production
deploy:
replicas: 3
resources:
limits:
cpus: '0.5'
memory: 512M
environment:
- WEB_CONCURRENCY=4
postgres:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
redis:
command: redis-server --requirepass ${REDIS_PASSWORD}
Kubernetes Configuration¶
ConfigMaps for Each Environment¶
# k8s/configmap-development.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-development
data:
APP_ENV: "development"
LOG_LEVEL: "DEBUG"
DATABASE_HOST: "postgres-service"
REDIS_HOST: "redis-service"
# k8s/configmap-production.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-production
data:
APP_ENV: "production"
LOG_LEVEL: "WARNING"
WEB_CONCURRENCY: "4"
WORKER_CONCURRENCY: "20"
Kustomization for Environment Management¶
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
configMapGenerator:
- name: app-config
files:
- config/base.yaml
# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
bases:
- ../../base
patchesStrategicMerge:
- deployment-patch.yaml
configMapGenerator:
- name: app-config
behavior: merge
files:
- config/production.yaml
secretGenerator:
- name: app-secrets
envs:
- secrets.env
Environment Variables Management¶
.env Files¶
# .env.development
APP_ENV=development
DEBUG=true
DB_HOST=localhost
DB_PORT=5432
DB_USER=dev_user
DB_PASSWORD=dev_password
REDIS_HOST=localhost
REDIS_PORT=6379
# .env.production
APP_ENV=production
DEBUG=false
# Sensitive values should be injected by CI/CD
# DB_HOST=
# DB_PASSWORD=
# REDIS_PASSWORD=
# JWT_SECRET=
Environment Variable Validation¶
# config/validators.py
import os
from typing import List, Dict, Any
class EnvironmentValidator:
"""Validate required environment variables."""
REQUIRED_VARS = {
"development": [],
"staging": [
"DB_USER",
"DB_PASSWORD",
"REDIS_PASSWORD",
],
"production": [
"DB_HOST",
"DB_USER",
"DB_PASSWORD",
"REDIS_HOST",
"REDIS_PASSWORD",
"JWT_SECRET",
"CORS_ORIGINS",
]
}
@classmethod
def validate(cls, environment: str):
"""Validate environment variables for given environment."""
required = cls.REQUIRED_VARS.get(environment, [])
missing = []
for var in required:
if not os.getenv(var):
missing.append(var)
if missing:
raise EnvironmentError(
f"Missing required environment variables for {environment}: "
f"{', '.join(missing)}"
)
@classmethod
def get_summary(cls) -> Dict[str, Any]:
"""Get summary of environment variables."""
return {
"environment": os.getenv("APP_ENV", "unknown"),
"configured": {
var: "***" if "PASSWORD" in var or "SECRET" in var
else os.getenv(var, "not set")
for var in cls.REQUIRED_VARS.get(
os.getenv("APP_ENV", "development"), []
)
}
}
Testing Different Environments¶
Environment-Specific Tests¶
# tests/test_environments.py
import pytest
from config.loader import EnvironmentConfig
import os
class TestEnvironmentConfig:
"""Test environment-specific configurations."""
@pytest.fixture
def reset_env(self):
"""Reset environment after test."""
original = os.environ.copy()
yield
os.environ.clear()
os.environ.update(original)
def test_development_config(self, reset_env):
"""Test development configuration."""
os.environ["APP_ENV"] = "development"
env_config = EnvironmentConfig()
config = env_config.load()
assert env_config.is_development()
assert config.get("services", "api", "settings", "debug") is True
assert config.get("features", "rate_limiting", "enabled") is False
def test_production_config(self, reset_env):
"""Test production configuration."""
os.environ["APP_ENV"] = "production"
os.environ["DB_HOST"] = "prod-db.example.com"
os.environ["DB_PASSWORD"] = "secret"
env_config = EnvironmentConfig()
config = env_config.load()
assert env_config.is_production()
assert config.get("services", "api", "settings", "debug") is False
assert config.get("databases", "primary", "host") == "prod-db.example.com"
def test_environment_override(self, reset_env):
"""Test environment variable overrides."""
os.environ["APP_ENV"] = "staging"
os.environ["DATAKNOBS_DATABASE__PRIMARY__PORT"] = "5433"
env_config = EnvironmentConfig()
config = env_config.load()
assert config.get("databases", "primary", "port") == 5433
Best Practices¶
- Keep secrets separate from configuration files
- Use base configuration for shared settings
- Validate environment variables on startup
- Use different database names per environment
- Enable debug/development features only in development
- Use environment detection to auto-configure
- Implement configuration validation for each environment
- Use Docker multi-stage builds for different environments
- Keep local overrides in gitignored files
- Document environment-specific requirements clearly