Environment Configuration Patterns: From Dev to Production
Practical patterns for managing configuration across environments without losing your mind or leaking secrets.
February 21, 2026 · 9 min · 1809 words · Rob Washington
Table of Contents
Configuration management sounds simple until you’re debugging why production is reading from the staging database at 3am. Here’s how to structure configuration so environments stay isolated and secrets stay secret.
The Twelve-Factor App got it right: store config in environment variables. But that’s just the starting point. Real systems need layers.
Each layer overrides the one below it. Defaults live in code, environment-specific values in config files, secrets in a secrets manager, and runtime overrides in environment variables.
frompydantic_settingsimportBaseSettingsfromtypingimportOptionalfromfunctoolsimportlru_cacheclassDatabaseConfig(BaseSettings):host:str="localhost"port:int=5432name:str="app_dev"user:str="postgres"password:str# Required, no defaultclassConfig:env_prefix="DB_"classAppConfig(BaseSettings):environment:str="development"debug:bool=Falselog_level:str="INFO"database:DatabaseConfig=DatabaseConfig()classConfig:env_prefix="APP_"@lru_cachedefget_config()->AppConfig:returnAppConfig()
Benefits:
Validation at startup (fail fast if config is wrong)
importboto3fromfunctoolsimportlru_cacheimportjsonclassSecretsManager:def__init__(self):self._client=boto3.client('secretsmanager')self._cache={}defget_secret(self,secret_name:str)->dict:ifsecret_namenotinself._cache:response=self._client.get_secret_value(SecretId=secret_name)self._cache[secret_name]=json.loads(response['SecretString'])returnself._cache[secret_name]# Integration with configclassDatabaseConfig(BaseSettings):host:str="localhost"port:int=5432name:str="app"@propertydefcredentials(self)->dict:# Fetch from secrets manager on first accessreturnsecrets_manager.get_secret(f"db/{get_environment().value}")@propertydefpassword(self)->str:returnself.credentials["password"]
# config.py - InfrastructureclassConfig:database_url:strredis_url:strlog_level:str# features.py - BehaviorclassFeatures:new_checkout_flow:bool=Falsedark_mode:bool=Trueai_recommendations:bool=False@classmethoddeffor_environment(cls,env:Environment)->"Features":ifenv==Environment.PRODUCTION:returncls(new_checkout_flow=False,# Not ready yetai_recommendations=True,)else:returncls(new_checkout_flow=True,# Test in stagingai_recommendations=True,)
Feature flags can come from a service like LaunchDarkly, a database, or simple environment-based defaults. The key is separating “where does the database live” from “should we show the new UI.”
defvalidate_config(config:AppConfig)->None:errors=[]# Check required secrets are presentifnotconfig.database.password:errors.append("DB_PASSWORD is required")# Check values are sensibleifconfig.environment=="production"andconfig.debug:errors.append("DEBUG must be False in production")# Check connectivity (optional but useful)try:test_database_connection(config.database)exceptExceptionase:errors.append(f"Database connection failed: {e}")iferrors:raiseConfigurationError("Configuration validation failed:\n"+"\n".join(f" - {e}"foreinerrors))# In your app startupconfig=get_config()validate_config(config)# Crashes immediately if misconfigured
defdiff_configs(base:AppConfig,compare:AppConfig)->dict:"""Show differences between two configurations."""diffs={}forfieldinbase.__fields__:base_val=getattr(base,field)compare_val=getattr(compare,field)ifbase_val!=compare_val:# Mask secretsif"password"infield.lower()or"secret"infield.lower():diffs[field]={"base":"***","compare":"***","changed":True}else:diffs[field]={"base":base_val,"compare":compare_val}returndiffs# Usage: Compare staging to productionstaging_config=AppConfig(_env_file=".env.staging")prod_config=AppConfig(_env_file=".env.production")print(diff_configs(staging_config,prod_config))
classSecurityConfig(BaseSettings):# Safe defaults - require explicit opt-in for dangerous settingsallow_http:bool=False# HTTPS required by defaultcors_origins:list[str]=[]# No CORS by defaultrate_limit:int=100# Conservative defaultsession_timeout_minutes:int=30# Development overrides must be explicitdisable_auth:bool=False# Never True by defaultdef__init__(self,**kwargs):super().__init__(**kwargs)# Extra safety checkifself.disable_authandos.getenv("APP_ENVIRONMENT")=="production":raiseValueError("Cannot disable auth in production")
fromdotenvimportload_dotenv# Load in order - later files override earlierload_dotenv(".env")load_dotenv(f".env.{environment}")load_dotenv(f".env.{environment}.local",override=True)
# CONFUSINGifenv=="prod":config={"db":{"primary":"...","replica":"..."}}else:config={"database_url":"..."}# Different structure!
4. Reading config at import time:
1
2
3
4
5
6
7
# BAD - runs before environment is set upDATABASE_URL=os.getenv("DATABASE_URL")# Module-level# GOOD - lazy evaluation@lru_cachedefget_database_url():returnos.getenv("DATABASE_URL")