28.1. Configuration Management
Table of Contents
Why Configuration Management Matters
When you deploy a backend application, you must control how it behaves in different environments: development, testing, staging, and production. Things like database URLs, API keys, feature flags, and log levels must be configurable without changing the code.
Configuration management is the practice of defining, storing, loading, and evolving application settings in a safe, predictable way. Good configuration management:
- Keeps secrets out of code.
- Makes deployments repeatable and automated.
- Allows different environments to use different settings.
- Reduces “it works on my machine” problems.
In production, configuration mistakes are a common cause of outages. A wrong URL or a missing environment variable can break your app even if the code is perfect.
Important rule:
Application code should be the same across environments. Only configuration should change.
Types of Configuration
Your backend usually needs several kinds of configuration. Organizing them clearly helps avoid chaos.
Environment-specific configuration
These settings change between environments:
- Database connection strings
- Dev:
postgres://dev_user:dev_pass@localhost:5432/app_dev - Prod:
postgres://prod_user:prod_pass@db-prod.internal:5432/app_prod - External service URLs
- Dev:
https://sandbox.stripe.com - Prod:
https://api.stripe.com - Log level
- Dev:
DEBUG - Prod:
INFOorWARN - CORS allowed origins
- Dev:
http://localhost:3000 - Prod:
https://myapp.com
Typical pattern:
ENV=development | staging | production
Your code then loads the correct settings for each environment based on ENV.
Secrets and credentials
These are sensitive values:
- Database passwords
- API keys (Stripe, PayPal, SendGrid)
- JWT signing keys
- Encryption keys
- OAuth client secrets
These must never be:
- Committed to Git
- Hard-coded in code
- Printed in logs or error messages
We will leave secure storage details to the “Secrets Management” chapter, but configuration management must be designed to consume secrets safely (for example from environment variables or a secret manager).
Feature flags and behavior switches
Feature flags let you toggle behavior without deploying new code. Examples:
FEATURE_NEW_CHECKOUT_FLOW=trueENABLE_RATE_LIMITING=falseUSE_FAKE_PAYMENT_GATEWAY=true(for staging)
These are part of configuration, not part of the code logic itself. The code reads these flags and decides what to do.
Example in pseudocode:
if config.FEATURE_NEW_CHECKOUT_FLOW:
use_new_checkout()
else:
use_old_checkout()Operational configuration
These control how the app runs, not what business logic it has:
- HTTP port
- Number of worker processes
- Connection pool sizes
- Cache TTLs
- Request timeouts
- Health check intervals
Example:
PORT=8000
DB_POOL_SIZE=20
REQUEST_TIMEOUT_SECONDS=10
CACHE_DEFAULT_TTL_SECONDS=300These often need tuning in production for performance and scalability.
Where to Store Configuration
There are many places to store configuration. In production you often use multiple levels at the same time.
Environment variables
Environment variables are the standard way in modern backends, especially in containerized deployments.
Example:
export APP_ENV=production
export DATABASE_URL="postgres://user:pass@db:5432/app"
export STRIPE_API_KEY="sk_live_123"In Docker Compose:
services:
api:
image: myapp-api:latest
environment:
- APP_ENV=production
- DATABASE_URL=${DATABASE_URL}
- STRIPE_API_KEY=${STRIPE_API_KEY}Typical app code reads them at startup:
import os
APP_ENV = os.getenv("APP_ENV", "development")
DATABASE_URL = os.environ["DATABASE_URL"] # requiredAdvantages:
- Aligns with 12-factor app principles.
- Works well with Docker, Kubernetes, serverless.
- Easy to override per environment.
Disadvantages:
- Can be hard to manage when there are many variables.
- Secrets in env vars require strict access controls and audit (covered elsewhere).
Important rule:
Use environment variables as the primary interface for configuration in production.
Configuration files
Configuration files are human-readable documents stored with your app, usually without secrets. Common formats:
.env(key-value pairs)- JSON
- YAML
- TOML
- INI
`.env` files
These mimic environment variables and are often used in development and staging.
Example .env:
APP_ENV=development
DATABASE_URL=postgres://dev_user:dev_pass@localhost:5432/app_dev
STRIPE_API_KEY=sk_test_abc123
LOG_LEVEL=DEBUGThen load it in code (Python example):
from dotenv import load_dotenv
import os
load_dotenv() # loads .env into process environment
DATABASE_URL = os.environ["DATABASE_URL"]
Production systems often avoid .env files on servers and instead use real environment variables plus a secret manager.
YAML / JSON config
Useful when you have structured configuration like lists or nested objects.
Example config.production.yaml:
app:
env: production
debug: false
log_level: INFO
database:
url: "postgres://prod_user:prod_pass@db-prod:5432/app_prod"
pool_size: 20
redis:
url: "redis://cache-prod:6379/0"
default_ttl_seconds: 300You can combine this with environment variables:
- YAML defines defaults and non-sensitive values.
- Environment variables override secrets and dynamic values.
Centralized configuration services
In larger systems, you may have a central server that stores configuration:
- HashiCorp Consul
- Spring Cloud Config
- AWS Systems Manager Parameter Store
- etcd
- Kubernetes ConfigMaps
Characteristics:
- Configuration is stored centrally.
- Multiple services can read it.
- Some systems allow dynamic reload without redeploying.
These are more advanced and usually come into play when you have many microservices or many environments.
Kubernetes config maps and secrets
If you deploy with Kubernetes, its primitives double as configuration mechanisms:
- ConfigMaps: non-sensitive configuration
- Secrets: sensitive values
Example ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: "production"
LOG_LEVEL: "INFO"Example Secret (base64-encoded values):
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
DATABASE_URL: cG9zdGdyZXM6Ly91c2VyOnBhc3NAZGI6NTQzMi9hcHA=Your Pod spec then mounts them as environment variables.
Designing a Configuration Strategy
You need a clear, consistent strategy so that everyone on the team knows how to add or change settings.
Principles for good configuration design
- Separation of code and configuration
- Code is fixed and versioned.
- Configuration is flexible and environment-specific.
- Single source of truth
- Each config value should have one canonical definition.
- Avoid duplication like setting the same URL in 3 different files.
- Explicit is better than implicit
- Require important config values.
- Fail fast if a required value is missing or invalid.
- Validation at startup
- Validate all configuration when the app starts.
- Do not wait to fail in the middle of a user request.
- Minimal required configuration
- Do not create dozens of rarely used options.
- Start simple and grow when needed.
Layered configuration
A common approach is to combine multiple sources in a priority order.
Example priority (from lowest to highest):
- Hard-coded defaults in code.
- Default config file (for example,
config.default.yaml). - Environment-specific config file (for example,
config.production.yaml). - Environment variables (override any file).
- Command line arguments (optional, highest priority).
You can describe it like this:
| Level | Source | Typical use |
|---|---|---|
| 1 | Code defaults | Safe fallback values |
| 2 | Shared config file | Common settings for all envs |
| 3 | Env-specific config file | Different per environment |
| 4 | Environment variables | Secrets, last-minute overrides |
| 5 | CLI arguments (optional) | Local tests, special runs |
Example load order in pseudocode:
config = load_default_config()
config.update(load_env_specific_file(APP_ENV))
config.update(load_from_env())
config.update(load_from_cli_args())
validate_config(config)
Important rule:
Always define a clear precedence order for configuration sources. Never allow ambiguous overrides.
Configuration schema and validation
Treat configuration as data with a schema. This prevents subtle bugs.
Example in Python using Pydantic:
from pydantic import BaseSettings, AnyUrl, validator
class Settings(BaseSettings):
app_env: str = "development"
debug: bool = False
log_level: str = "INFO"
port: int = 8000
database_url: AnyUrl
redis_url: AnyUrl
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@validator("app_env")
def validate_env(cls, v):
allowed = {"development", "staging", "production"}
if v not in allowed:
raise ValueError(f"app_env must be one of {allowed}")
return v
settings = Settings()This approach:
- Loads config from environment variables.
- Applies defaults for some fields.
- Requires
database_urlandredis_url. - Validates
app_env.
If you start the app without DATABASE_URL, it fails immediately.
Configuration for multiple services
In a microservices environment you will have:
- Config that is shared for many services, such as logging format or tracing settings.
- Config that is service-specific, such as the port or internal dependencies.
Design patterns:
- Use a shared library for common settings (for example,
company_common_config). - Name environment variables by service:
PAYMENT_SERVICE_PORTUSER_SERVICE_PORT- Keep service configuration small and focused.
Configuration in Different Environments
Although configuration values change by environment, the shape of configuration should be consistent.
Development
Characteristics:
- Convenience is more important than safety.
- You can use
.envfiles. - Fake services or sandboxes are common.
Examples:
APP_ENV=development
DEBUG=true
DATABASE_URL=postgres://dev:dev@localhost:5432/app_dev
REDIS_URL=redis://localhost:6379/0
STRIPE_API_KEY=sk_test_abc
EMAIL_PROVIDER=consoleYour code might send emails to the console or a local mail catcher instead of a real SMTP server.
Staging
Staging should be as close to production as possible:
- Same database engine version.
- Same external services, but possibly with sandbox accounts.
- Same configuration keys and structure.
- Different values: less capacity, different domains, different credentials.
Examples:
APP_ENV=staging
DEBUG=false
DATABASE_URL=postgres://staging_user:staging_pass@db-staging:5432/app_staging
REDIS_URL=redis://cache-staging:6379/0
STRIPE_API_KEY=sk_test_staging_123
ALLOWED_ORIGINS=https://staging.myapp.comIf it works in staging, you want high confidence it works in production.
Production
Production configuration focuses on:
- Security
- Stability
- Performance
Examples:
APP_ENV=production
DEBUG=false
DATABASE_URL=postgres://prod_user:prod_pass@db-prod:5432/app_prod
REDIS_URL=redis://cache-prod:6379/0
STRIPE_API_KEY=sk_live_abc123
ALLOWED_ORIGINS=https://myapp.com
LOG_LEVEL=INFOProduction needs more careful review processes:
- Configuration changes require approvals.
- Configuration is applied through CI/CD pipelines.
- Secrets are injected from a secret manager.
Important rule:
Never run production with development configuration. For example, do not enable debug mode in production.
Managing Configuration Changes Safely
In production, configuration changes can be as dangerous as code changes. Treat them with the same discipline.
Versioning and history
Even if you use environment variables, you should have:
- A version-controlled template of configuration (for example,
.env.example,config.production.template.yaml). - A clear record of who changed which variable, when, and why.
Typical practice:
- In Git, store sample files without secrets:
# .env.example
APP_ENV=development
DATABASE_URL=postgres://USER:PASS@HOST:PORT/DBNAME
REDIS_URL=redis://HOST:PORT/DB
STRIPE_API_KEY=your_key_here- In your infrastructure-as-code (Terraform, Ansible, etc.), declare config values and commit those files.
Rollout strategies
Avoid modifying many configuration values at once. Strategies:
- Change one or two settings at a time.
- Combine a feature flag with a configuration change.
- For critical changes (for example, database URLs), use:
- Staging verification
- Canary deployments or blue/green setups
- Health checks and monitoring
Example: introducing rate limiting
- Add
FEATURE_RATE_LIMITINGflag, defaultfalse. - Deploy code with flag off.
- Enable flag in staging, test.
- Enable flag in production for 10 percent of instances.
- Monitor metrics.
- Enable flag for all instances.
Validating new configuration
Before you apply a new configuration:
- Run automated tests with the new config.
- Run a “dry run” or validation command if your app supports it.
You can write a small CLI to validate:
python manage.py validate-configThat command can:
- Load all config sources.
- Check required variables.
- Verify formats (URLs, emails, numbers in range).
Your CI pipeline can run this before deploying.
Configuration and Infrastructure as Code
In a production backend you often combine application configuration with infrastructure as code tools like:
- Terraform
- Ansible
- CloudFormation
- Pulumi
These tools can:
- Define servers, load balancers, and databases.
- Attach environment variables to services.
- Attach ConfigMaps and Secrets to Kubernetes deployments.
Example Terraform snippet for an environment variable in a container task:
environment = [
{
name = "APP_ENV"
value = "production"
},
{
name = "DATABASE_URL"
value = aws_ssm_parameter.database_url.value
}
]Here:
- Terraform is the source of truth for
APP_ENV. DATABASE_URLcomes from AWS Systems Manager Parameter Store.
Benefits:
- Configuration is documented and reviewed through Git.
- You can reproduce the entire environment.
Common Pitfalls and Anti-patterns
Avoid these patterns in production configuration management.
Hard-coded configuration
Example of what not to do:
DATABASE_URL = "postgres://prod_user:prod_pass@db-prod:5432/app_prod"Problems:
- Requires code change to update.
- Sensitive information is in the repo.
- Testing with other configs is hard.
Mixed concerns
Avoid mixing completely unrelated settings in the same place.
Bad pattern:
all_settings:
# 300 unrelated keys from 10 different servicesBetter:
- Use separate namespaces or files per service or domain:
database.yamlemail.yamlpayments.yaml
Inconsistent naming
Avoid random or inconsistent variable names:
db_url,DATABASE_URL,POSTGRES_URL,PG_CONN_STRINGfor the same thing.
Pick a convention and stick to it:
DATABASE_URL,REDIS_URL,SMTP_URL.
Missing defaults and validation
If your app silently uses bad default values, a missing config can cause hidden bugs.
Bad:
log_level = os.getenv("LOG_LEVEL") # None if missing
# later
logger.setLevel(log_level) # will fail at runtime or use a strange valueBetter:
log_level = os.getenv("LOG_LEVEL", "INFO")
validate_log_level(log_level)
logger.setLevel(log_level)Or use a schema/validation library as shown earlier.
Leaking secrets in logs
Avoid logging full configuration values. At most, log:
- Which keys are present.
- Masks for sensitive fields.
Bad:
logger.info(f"Loaded config: {config}")Better:
logger.info("Loaded configuration keys: %s", list(config.keys()))Or mask values:
def mask(value, visible=4):
if value is None:
return None
if len(value) <= visible:
return "*" * len(value)
return value[:visible] + "*" * (len(value) - visible)
logger.info("Using STRIPE_API_KEY=%s", mask(settings.stripe_api_key))Putting It All Together: Example Setup
Here is a small but realistic configuration setup for a production-ready backend.
Files in the repository
config/
base.yaml
development.yaml
staging.yaml
production.yaml
.env.example`config/base.yaml`
app:
log_level: INFO
request_timeout_seconds: 10
database:
pool_size: 10
redis:
default_ttl_seconds: 300`config/production.yaml`
app:
env: production
log_level: INFO
debug: false
database:
pool_size: 20
redis:
default_ttl_seconds: 600
Secrets like DATABASE_URL and REDIS_URL are not in these files. They come from environment variables.
Configuration loading code (Python example)
import os
import yaml
from pydantic import BaseSettings, AnyUrl, PositiveInt
class Settings(BaseSettings):
app_env: str
log_level: str
debug: bool = False
request_timeout_seconds: PositiveInt
database_url: AnyUrl
database_pool_size: PositiveInt
redis_url: AnyUrl
redis_default_ttl_seconds: PositiveInt
class Config:
env_prefix = ""
env_file = None # in production, rely on real env vars
def load_yaml(path):
with open(path) as f:
return yaml.safe_load(f)
def build_settings() -> Settings:
env = os.getenv("APP_ENV", "development")
base = load_yaml("config/base.yaml")
env_specific = load_yaml(f"config/{env}.yaml")
# simple deep merge
merged = {**base, **env_specific}
merged["database"]["url"] = os.environ["DATABASE_URL"]
merged["redis"]["url"] = os.environ["REDIS_URL"]
# flatten for Settings
flat = {
"app_env": env_specific["app"]["env"],
"log_level": merged["app"]["log_level"],
"debug": merged["app"].get("debug", False),
"request_timeout_seconds": merged["app"]["request_timeout_seconds"],
"database_url": merged["database"]["url"],
"database_pool_size": merged["database"]["pool_size"],
"redis_url": merged["redis"]["url"],
"redis_default_ttl_seconds": merged["redis"]["default_ttl_seconds"],
}
return Settings(**flat)
settings = build_settings()In this pattern:
- Application behavior is controlled by files under
config/and env vars. - If an environment variable is missing or misformatted,
Settingswill raise an error at startup. - Production uses
APP_ENV=productionand environment variables for secrets.
Important statement:
A production-grade backend must have a documented, consistent, and validated way to load configuration across all environments.
This is the core of configuration management in production. It lets you move the same codebase through development, staging, and production while changing only environment-specific and secret values in a controlled way.
Views: 7
KAHIBARO