KAHIBARO
Discord Login Register

28.1. Configuration Management

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:

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:

Typical pattern:

text
ENV=development | staging | production

Your code then loads the correct settings for each environment based on ENV.

Secrets and credentials

These are sensitive values:

These must never be:

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:

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:

python
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:

Example:

text
PORT=8000
DB_POOL_SIZE=20
REQUEST_TIMEOUT_SECONDS=10
CACHE_DEFAULT_TTL_SECONDS=300

These 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:

bash
export APP_ENV=production
export DATABASE_URL="postgres://user:pass@db:5432/app"
export STRIPE_API_KEY="sk_live_123"

In Docker Compose:

yaml
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:

python
import os
APP_ENV = os.getenv("APP_ENV", "development")
DATABASE_URL = os.environ["DATABASE_URL"]  # required

Advantages:

Disadvantages:

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` files

These mimic environment variables and are often used in development and staging.

Example .env:

env
APP_ENV=development
DATABASE_URL=postgres://dev_user:dev_pass@localhost:5432/app_dev
STRIPE_API_KEY=sk_test_abc123
LOG_LEVEL=DEBUG

Then load it in code (Python example):

python
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:

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: 300

You can combine this with environment variables:

Centralized configuration services

In larger systems, you may have a central server that stores configuration:

Characteristics:

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:

Example ConfigMap:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  APP_ENV: "production"
  LOG_LEVEL: "INFO"

Example Secret (base64-encoded values):

yaml
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

  1. Separation of code and configuration
    • Code is fixed and versioned.
    • Configuration is flexible and environment-specific.
  2. Single source of truth
    • Each config value should have one canonical definition.
    • Avoid duplication like setting the same URL in 3 different files.
  3. Explicit is better than implicit
    • Require important config values.
    • Fail fast if a required value is missing or invalid.
  4. Validation at startup
    • Validate all configuration when the app starts.
    • Do not wait to fail in the middle of a user request.
  5. 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):

  1. Hard-coded defaults in code.
  2. Default config file (for example, config.default.yaml).
  3. Environment-specific config file (for example, config.production.yaml).
  4. Environment variables (override any file).
  5. Command line arguments (optional, highest priority).

You can describe it like this:

LevelSourceTypical use
1Code defaultsSafe fallback values
2Shared config fileCommon settings for all envs
3Env-specific config fileDifferent per environment
4Environment variablesSecrets, last-minute overrides
5CLI arguments (optional)Local tests, special runs

Example load order in pseudocode:

python
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:

python
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:

If you start the app without DATABASE_URL, it fails immediately.

Configuration for multiple services

In a microservices environment you will have:

Design patterns:

Configuration in Different Environments

Although configuration values change by environment, the shape of configuration should be consistent.

Development

Characteristics:

Examples:

env
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=console

Your 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:

Examples:

env
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.com

If it works in staging, you want high confidence it works in production.

Production

Production configuration focuses on:

Examples:

env
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=INFO

Production needs more careful review processes:

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:

Typical practice:

env
  # .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

Rollout strategies

Avoid modifying many configuration values at once. Strategies:

Example: introducing rate limiting

  1. Add FEATURE_RATE_LIMITING flag, default false.
  2. Deploy code with flag off.
  3. Enable flag in staging, test.
  4. Enable flag in production for 10 percent of instances.
  5. Monitor metrics.
  6. Enable flag for all instances.

Validating new configuration

Before you apply a new configuration:

You can write a small CLI to validate:

bash
python manage.py validate-config

That command can:

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:

These tools can:

Example Terraform snippet for an environment variable in a container task:

hcl
environment = [
  {
    name  = "APP_ENV"
    value = "production"
  },
  {
    name  = "DATABASE_URL"
    value = aws_ssm_parameter.database_url.value
  }
]

Here:

Benefits:

Common Pitfalls and Anti-patterns

Avoid these patterns in production configuration management.

Hard-coded configuration

Example of what not to do:

python
DATABASE_URL = "postgres://prod_user:prod_pass@db-prod:5432/app_prod"

Problems:

Mixed concerns

Avoid mixing completely unrelated settings in the same place.

Bad pattern:

yaml
all_settings:
  # 300 unrelated keys from 10 different services

Better:

Inconsistent naming

Avoid random or inconsistent variable names:

Pick a convention and stick to it:

Missing defaults and validation

If your app silently uses bad default values, a missing config can cause hidden bugs.

Bad:

python
log_level = os.getenv("LOG_LEVEL")  # None if missing
# later
logger.setLevel(log_level)  # will fail at runtime or use a strange value

Better:

python
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:

Bad:

python
logger.info(f"Loaded config: {config}")

Better:

python
logger.info("Loaded configuration keys: %s", list(config.keys()))

Or mask values:

python
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

text
config/
  base.yaml
  development.yaml
  staging.yaml
  production.yaml
.env.example

`config/base.yaml`

yaml
app:
  log_level: INFO
  request_timeout_seconds: 10
database:
  pool_size: 10
redis:
  default_ttl_seconds: 300

`config/production.yaml`

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)

python
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:

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

Comments

Please login to add a comment.

Don't have an account? Register now!