Skip to content

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

export DATABASE_URL=postgres://localhost:5432/mydb
python app.py

Inline (single command):

DATABASE_URL=postgres://localhost:5432/mydb python app.py

Docker:

docker run -e DATABASE_URL=postgres://db:5432/mydb myimage

Docker Compose:

services:
  app:
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
    env_file:
      - .env

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

database:
  host: localhost
  port: 5432
  name: mydb

logging:
  level: info
  format: json

Pros: Readable, comments allowed Cons: Whitespace-sensitive, security concerns with untrusted input

TOML:

[database]
host = "localhost"
port = 5432
name = "mydb"

[logging]
level = "info"
format = "json"

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.

Configuration That Executes

Everything above this line treats configuration the way it has behaved for thirty years: values that a program reads, where the program holds the agency and the file holds parameters. That is why it's correct to review code carefully and skim config.

A growing class of file breaks the deal. It looks like configuration, sits next to configuration, and is JSON — but reading it starts a process:

File What reading it does
.vscode/tasks.json runOn: folderOpen executes on open
.devcontainer/ Builds and runs a container on attach
.claude/settings.json Defines hooks that fire on session start
.amazonq/mcp.json, .mcp.json Names MCP servers to launch as local processes

None of these require installing a dependency or running the project. The trigger is cloning the repository and opening the folder.

In June 2026, CVE-2026-12957 showed what that costs when a tool skips the trust check: Amazon Q Developer auto-loaded .amazonq/mcp.json from any open workspace with no consent prompt, and the processes it spawned inherited the developer's full environment — AWS credentials and SSH agent socket included. See Amazon Q.

The rule: a file that names something to run is code, whatever its extension. Diff these on every incoming PR and every repository you clone, and treat a new one appearing with the same alarm as a new postinstall script.

Unanchored Patterns in Allow-Lists

If a config value is a regex that decides who or what is trusted, anchor it. Every time.

# WRONG — matches any string CONTAINING an approved ID
ALLOWED_ACTOR = r"1234567|7654321"

# RIGHT — matches only those IDs
ALLOWED_ACTOR = r"^(1234567|7654321)$"

Without ^ and $, a regex engine doesn't look for a string that matches the pattern — it looks for one that contains it. The allow-list silently becomes a substring search, and it keeps behaving correctly for every input except a crafted one.

AWS shipped exactly this in the CodeBuild webhook filters guarding four public repositories. GitHub account IDs are sequential, so an old maintainer's seven-digit ID eventually appears as a substring inside a newly issued nine-digit one — roughly every five days. Wiz registered such an account and obtained a token with full admin over the AWS SDK for JavaScript.

This is the anti-pattern worth internalizing, because it is not really about regex:

  • It reviews clean. The line looks like an allow-list and reads like one.
  • It tests green. Every approved actor passes; every obviously-wrong one fails.
  • It fails open. A misconfigured deny rule blocks someone and you hear about it in an hour. A misconfigured allow rule grants access and nobody hears anything, possibly for years.

Validate trust-deciding config the way you validate input. If your config schema (see Validation above) accepts a regex used for authorization, make the validator reject unanchored patterns outright — that's a five-line check that would have closed this.

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.