Configuration Management Patterns for Reliable Deployments
How to manage application configuration without shooting yourself in the foot.
March 1, 2026 · 7 min · 1386 words · Rob Washington
Table of Contents
Configuration is where deployments go to die. A typo in an environment variable, a missing secret, a config file that works in staging but breaks in production. Here’s how to make configuration boring and reliable.
Environment variables are ubiquitous because they work everywhere:
1
2
3
4
5
6
7
8
# Explicit is better than implicitDATABASE_URL=postgres://user:pass@host:5432/db
REDIS_URL=redis://localhost:6379/0
LOG_LEVEL=INFO
# Prefix for namespacingMYAPP_DATABASE_URL=...
MYAPP_CACHE_TTL=300
Best practices:
Use a prefix for your app’s variables. DATABASE_URL might conflict; MYAPP_DATABASE_URL won’t.
Document every variable. A .env.example file is minimum:
1
2
3
4
5
# .env.example - copy to .env and fill in valuesDATABASE_URL=# Required. PostgreSQL connection stringREDIS_URL=# Optional. Defaults to redis://localhost:6379SECRET_KEY=# Required. 32+ character random stringDEBUG=false# Optional. Enable debug mode
Validate on startup:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defvalidate_config(config:Config)->list[str]:errors=[]ifnotos.getenv('DATABASE_URL'):errors.append("DATABASE_URL is required")ifnotos.getenv('SECRET_KEY'):errors.append("SECRET_KEY is required")eliflen(os.getenv('SECRET_KEY',''))<32:errors.append("SECRET_KEY must be at least 32 characters")returnerrors# Fail fast on startuperrors=validate_config(config)iferrors:forerrorinerrors:print(f"Configuration error: {error}",file=sys.stderr)sys.exit(1)
# BAD: Secrets in config files (gets committed to git)config={"database_password":"hunter2"# NO}# BAD: Secrets in environment variables (visible in process list)$psaux|greppython# Shows DATABASE_URL=postgres://user:password@...# BETTER: Secrets from filesDATABASE_PASSWORD_FILE=/run/secrets/db_passworddefget_secret(name:str)->str:file_path=os.getenv(f'{name}_FILE')iffile_pathandos.path.exists(file_path):returnopen(file_path).read().strip()# Fallback to direct env var for developmentreturnos.getenv(name,'')
For production: Use a secrets manager (Vault, AWS Secrets Manager, etc.) with short-lived credentials.
frompydanticimportBaseModel,validatorfromtypingimportOptionalclassServerConfig(BaseModel):host:str="0.0.0.0"port:int=8080workers:int=4@validator('port')defport_must_be_valid(cls,v):ifnot(1<=v<=65535):raiseValueError('port must be 1-65535')returnv@validator('workers')defworkers_must_be_positive(cls,v):ifv<1:raiseValueError('workers must be at least 1')returnvclassDatabaseConfig(BaseModel):pool_size:int=10timeout_seconds:int=30classAppConfig(BaseModel):server:ServerConfig=ServerConfig()database:DatabaseConfig=DatabaseConfig()# Pydantic validates on loadconfig=AppConfig(**yaml.safe_load(open('config.yaml')))
classFeatureFlags:def__init__(self,redis_client):self.redis=redis_clientself.cache={}self.cache_ttl=60# Refresh every minuteself.last_refresh=0defis_enabled(self,flag:str,default:bool=False)->bool:self._maybe_refresh()returnself.cache.get(flag,default)def_maybe_refresh(self):iftime.time()-self.last_refresh>self.cache_ttl:flags=self.redis.hgetall('feature_flags')self.cache={k:v=='true'fork,vinflags.items()}self.last_refresh=time.time()# Usageflags=FeatureFlags(redis)ifflags.is_enabled('new_checkout'):returnnew_checkout_flow(cart)else:returnlegacy_checkout(cart)
importhashlibimportjsondefconfig_fingerprint(config:dict)->str:"""Generate a hash of the current config."""serialized=json.dumps(config,sort_keys=True)returnhashlib.sha256(serialized.encode()).hexdigest()[:12]# Log on startuplogger.info(f"Starting with config fingerprint: {config_fingerprint(config)}")# Store expected fingerprints per environmentEXPECTED_FINGERPRINTS={'production':'a1b2c3d4e5f6','staging':'f6e5d4c3b2a1',}fingerprint=config_fingerprint(config)expected=EXPECTED_FINGERPRINTS.get(environment)ifexpectedandfingerprint!=expected:logger.warning(f"Config drift detected! Expected {expected}, got {fingerprint}")