Configuration Management¶
Hardcoded values are technical debt. This chapter covers how to externalize configuration, manage environment-specific settings, and avoid the traps that turn configuration into chaos.
Where Good Intentions Die
Configuration is where good intentions go to die. It starts with one hardcoded value. Then another. Then someone adds an environment variable, and someone else adds a config file, and before you know it you have three incompatible systems and nobody knows which one actually controls the thing.
Why Configuration Matters¶
Configuration controls behavior without changing code:
- Database connections — Dev, staging, production
- API endpoints — Local services, external APIs
- Feature flags — Enable/disable functionality
- Logging levels — Debug in dev, error in prod
- Resource limits — Timeouts, memory caps, concurrency
When configuration is done well, the same code artifact runs everywhere. When it's done poorly, you're editing code to deploy to different environments.
The Configuration Hierarchy¶
Configuration comes from multiple sources. Most frameworks use a hierarchy:
Priority (highest to lowest):
1. Command-line arguments
2. Environment variables
3. Environment-specific config files (.env.production)
4. Local config files (.env.local, .env)
5. Default config files (config/default.json)
6. Hardcoded defaults in code
Higher priority sources override lower ones. This lets you:
- Set sensible defaults in code
- Override defaults in config files
- Override config files for environments
- Override everything with environment variables for deployment
Environment Variables¶
The most universal configuration mechanism.
Reading Environment Variables¶
import os
# Basic access
database_url = os.environ.get('DATABASE_URL')
# With default
debug_mode = os.environ.get('DEBUG', 'false').lower() == 'true'
# Required (fail fast)
api_key = os.environ['API_KEY'] # Raises KeyError if missing
# Better: explicit validation
def get_required_env(name):
value = os.environ.get(name)
if value is None:
raise ValueError(f"Required environment variable {name} is not set")
return value
// Node.js
const databaseUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT || '3000', 10);
// With validation
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Required environment variable ${name} is not set`);
}
return value;
}
Setting Environment Variables¶
Shell (temporary):
Inline (single command):
Docker:
Docker Compose:
.env Files¶
Environment files provide a convenient way to manage environment variables during development.
Structure¶
# .env
# Comments start with #
# Database
DATABASE_URL=postgres://localhost:5432/mydb
DATABASE_POOL_SIZE=10
# External APIs
API_KEY=sk-dev-abc123
API_TIMEOUT=30
# Feature flags
ENABLE_BETA_FEATURES=true
Loading .env Files¶
Python (python-dotenv):
from dotenv import load_dotenv
import os
# Load .env file
load_dotenv()
# Now os.environ has the values
database_url = os.environ.get('DATABASE_URL')
Node.js (dotenv):
require('dotenv').config();
// process.env now has the values
const databaseUrl = process.env.DATABASE_URL;
Environment-Specific Files¶
Common pattern:
.env # Shared defaults, committed (no secrets!)
.env.local # Local overrides, not committed
.env.development # Development-specific
.env.production # Production-specific (CI/CD only)
Load order (python-dotenv):
from dotenv import load_dotenv
# Load in order, later files override earlier
load_dotenv('.env') # Defaults
load_dotenv('.env.local', override=True) # Local overrides
What Goes in .gitignore¶
# Always ignore files with secrets
.env.local
.env.*.local
.env.production
# Safe to commit (if no secrets)
# .env
# .env.example
Critical: Never commit files with real credentials. Create .env.example with placeholder values for documentation.
# .env.example (committed)
DATABASE_URL=postgres://user:password@localhost:5432/dbname
API_KEY=your-api-key-here
Configuration Files¶
For complex configuration, files work better than environment variables.
Common Formats¶
JSON:
{
"database": {
"host": "localhost",
"port": 5432,
"name": "mydb"
},
"logging": {
"level": "info",
"format": "json"
}
}
Pros: Ubiquitous, easy to parse Cons: No comments, verbose
YAML:
Pros: Readable, comments allowed Cons: Whitespace-sensitive, security concerns with untrusted input
TOML:
Pros: Readable, explicit types, comments Cons: Less common, nested structures awkward
Loading Configuration¶
Python:
import json
import yaml
import tomllib # Python 3.11+
# JSON
with open('config.json') as f:
config = json.load(f)
# YAML
with open('config.yaml') as f:
config = yaml.safe_load(f) # safe_load, not load!
# TOML
with open('config.toml', 'rb') as f:
config = tomllib.load(f)
Node.js:
const fs = require('fs');
const yaml = require('js-yaml');
// JSON (native)
const config = require('./config.json');
// YAML
const config = yaml.load(fs.readFileSync('./config.yaml', 'utf8'));
Structured Configuration¶
Validation¶
Don't trust configuration. Validate it.
Python with Pydantic:
from pydantic import BaseSettings, PostgresDsn
class Settings(BaseSettings):
database_url: PostgresDsn
api_key: str
debug: bool = False
port: int = 8000
class Config:
env_file = '.env'
# Validation happens on instantiation
settings = Settings() # Raises ValidationError if invalid
Node.js with Zod:
const { z } = require('zod');
const configSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
DEBUG: z.coerce.boolean().default(false),
PORT: z.coerce.number().int().default(8000),
});
const config = configSchema.parse(process.env);
Fail Fast¶
Applications should fail immediately if configuration is invalid:
def load_config():
"""Load and validate configuration. Fail fast if invalid."""
try:
settings = Settings()
except ValidationError as e:
print(f"Configuration error: {e}")
sys.exit(1)
return settings
# At startup
config = load_config() # App won't start with bad config
Failing at startup is better than failing at runtime when the missing config is first accessed.
Configuration Anti-Patterns¶
Magic Values in Code¶
# Bad: Magic values scattered in code
def connect_db():
return psycopg2.connect(
host="localhost",
port=5432,
database="mydb"
)
# Good: Configuration injected
def connect_db(config):
return psycopg2.connect(
host=config.database.host,
port=config.database.port,
database=config.database.name
)
Configuration in the Wrong Layer¶
# Bad: Business logic knows about environment
def calculate_tax(amount):
if os.environ.get('ENV') == 'production':
rate = 0.08
else:
rate = 0.0
return amount * rate
# Good: Rate is configuration, passed in
def calculate_tax(amount, tax_rate):
return amount * tax_rate
Mixing Secrets and Configuration¶
# Bad: Secrets mixed with config
database:
host: localhost
password: supersecret123
# Good: Secrets referenced, not embedded
database:
host: localhost
password: ${DATABASE_PASSWORD} # Resolved at runtime
Environment Sprawl¶
# Bad: Checking environment everywhere
if os.environ.get('ENV') == 'production':
# production code
elif os.environ.get('ENV') == 'staging':
# staging code
else:
# development code
# Good: Behavior controlled by specific config, not environment name
if config.enable_email_sending:
send_email(...)
The environment name should select which configuration to load. The configuration itself should control behavior.
AI-Generated Configuration¶
AI-generated configuration files are particularly prone to anti-patterns:
# AI-generated configs often have:
# - Hardcoded paths from the AI's training examples
# - Default credentials left in place
# - Assumptions about environment that don't match yours
# - Copy-pasted patterns without understanding context
When AI generates configuration—Dockerfiles, .env files, CI/CD pipelines—review every value. The AI doesn't know your environment, your security requirements, or your deployment targets. It's pattern-matching from training data, which may include examples with exposed secrets, development defaults in production configs, or outdated patterns. See Vibe Coding for more.
Feature Flags¶
Feature flags let you enable/disable functionality without deploying new code.
Simple Implementation¶
class FeatureFlags:
def __init__(self):
self.flags = {
'new_checkout': os.environ.get('FF_NEW_CHECKOUT', 'false') == 'true',
'beta_search': os.environ.get('FF_BETA_SEARCH', 'false') == 'true',
}
def is_enabled(self, flag_name):
return self.flags.get(flag_name, False)
flags = FeatureFlags()
if flags.is_enabled('new_checkout'):
use_new_checkout()
else:
use_old_checkout()
When to Use Feature Flags¶
- Gradual rollouts — Enable for 10% of users, then 50%, then 100%
- Kill switches — Disable problematic features without deploying
- A/B testing — Different experiences for different users
- Trunk-based development — Merge incomplete features behind flags
Feature Flag Hygiene¶
Flags are technical debt. Manage them:
# Document flags with expiration
FEATURE_FLAGS = {
'new_checkout': {
'description': 'New checkout flow',
'owner': 'payments-team',
'expires': '2024-06-01', # Remove by this date
}
}
Remove flags when features are fully rolled out. Stale flags accumulate and obscure code.
Environment Parity¶
Development, staging, and production should be as similar as possible.
The Parity Principle¶
| Aspect | Development | Production |
|---|---|---|
| Database | Same version | Same version |
| Services | Same services (containerized) | Same services |
| Configuration | Different values | Different values |
| Structure | Same structure | Same structure |
The values differ (different URLs, different credentials). The structure should be identical.
Docker for Parity¶
# docker-compose.yml
services:
app:
build: .
environment:
- DATABASE_URL=postgres://db:5432/mydb
db:
image: postgres:15 # Same version as production
The same Dockerfile, same database version, same service architecture. Only configuration differs.
Boring Is Good
I've seen configuration done every possible way. The worst is when configuration is scattered—some in code, some in files, some in environment variables, some in a database, with no clear hierarchy.
The best configuration systems are boring. Environment variables for simple cases. Validated, typed configuration objects for complex cases. Clear separation between what's code and what's configuration. Fail-fast on invalid configuration.
Configuration is one of those things that seems simple until it isn't. A few hardcoded values become dozens, then hundreds. By the time you realize you need a system, you've already built three incompatible half-systems. Start with a clear pattern. Load configuration in one place. Validate it. Pass it explicitly to the code that needs it.
Quick Reference¶
Configuration Sources¶
| Source | Use For | Secrets OK? |
|---|---|---|
| Code defaults | Sensible fallbacks | Never |
| Config files | Complex structure | No (committed) |
| .env files | Developer convenience | Local only |
| Environment vars | Deployment config | Yes (not logged) |
| Secrets managers | Sensitive data | Yes |
Validation Checklist¶
- All required configuration is validated at startup
- Invalid configuration fails fast with clear errors
- Types are validated (not just presence)
- Secrets are not logged or exposed in errors
- Default values are documented
Environment Variable Naming¶
# Use prefixes for grouping
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
API_KEY=abc123
API_TIMEOUT=30
FF_NEW_FEATURE=true # Feature flags
Convention: UPPER_SNAKE_CASE, prefixed by component.