“It works on my machine” is the opening line of every production incident post-mortem.

Here’s how to make sure it works everywhere.

The Environment Ladder

Most projects need at least three:

Deve(ldoepvm)entSt(asgtign)gPro(dpurcotdi)on

Each serves a purpose:

EnvironmentPurposeWho Uses ItData
DevelopmentBuild featuresDevelopersFake/seed
StagingTest before prodQA, stakeholdersProd-like
ProductionReal usersEveryoneReal

Configuration Management

Environment Variables

The simplest approach:

1
2
3
4
5
import os

DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"

Per-environment files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# .env.development
DATABASE_URL=postgresql://localhost/myapp_dev
DEBUG=true
LOG_LEVEL=debug

# .env.staging
DATABASE_URL=postgresql://staging-db.internal/myapp
DEBUG=false
LOG_LEVEL=info

# .env.production
DATABASE_URL=postgresql://prod-db.internal/myapp
DEBUG=false
LOG_LEVEL=warning

Load the right one:

1
2
3
# In your shell or CI
export ENV=staging
source .env.${ENV}

Structured Config

For complex apps, use a config module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# config.py
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    environment: str = "development"
    debug: bool = False
    database_url: str
    redis_url: str = "redis://localhost:6379"
    
    # Feature flags
    enable_new_checkout: bool = False
    
    class Config:
        env_file = ".env"

@lru_cache
def get_settings() -> Settings:
    return Settings()

# Usage
settings = get_settings()
if settings.debug:
    enable_debug_toolbar()

Config by Environment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# config/base.py
class BaseConfig:
    APP_NAME = "MyApp"
    LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"

# config/development.py
from .base import BaseConfig

class DevelopmentConfig(BaseConfig):
    DEBUG = True
    DATABASE_URL = "postgresql://localhost/myapp_dev"
    
# config/production.py
from .base import BaseConfig

class ProductionConfig(BaseConfig):
    DEBUG = False
    DATABASE_URL = os.environ["DATABASE_URL"]

# config/__init__.py
import os

configs = {
    "development": "config.development.DevelopmentConfig",
    "staging": "config.staging.StagingConfig",
    "production": "config.production.ProductionConfig",
}

def get_config():
    env = os.environ.get("ENV", "development")
    return configs[env]

Infrastructure Per Environment

Terraform Workspaces

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# main.tf
variable "environment" {
  type = string
}

locals {
  instance_size = {
    development = "t3.small"
    staging     = "t3.medium"
    production  = "t3.large"
  }
}

resource "aws_instance" "app" {
  instance_type = local.instance_size[var.environment]
  
  tags = {
    Environment = var.environment
    Name        = "app-${var.environment}"
  }
}

Using workspaces:

1
2
3
4
5
6
7
8
# Create environments
terraform workspace new development
terraform workspace new staging
terraform workspace new production

# Deploy to staging
terraform workspace select staging
terraform apply -var="environment=staging"

Separate State Files

Better for production isolation:

infraemsnotvdriuurdspladcoetrepatnvaosptum/gd//aremtimt/mtbenaenaeaea/tirgirirssnr/nrnre/.a.a.a/tftftffofoforrrmmm...tttfffvvvaaarrrsss
1
2
3
4
5
6
7
8
# environments/staging/main.tf
module "app" {
  source = "../../modules/app"
  
  environment    = "staging"
  instance_count = 2
  instance_type  = "t3.medium"
}

Docker Compose Per Environment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# docker-compose.yml (base)
services:
  app:
    build: .
    environment:
      - DATABASE_URL
      - REDIS_URL

# docker-compose.dev.yml
services:
  app:
    volumes:
      - .:/app
    environment:
      - DEBUG=true
  
  db:
    image: postgres:15
    
  redis:
    image: redis:7

# docker-compose.prod.yml
services:
  app:
    image: myapp:${VERSION}
    deploy:
      replicas: 3

Usage:

1
2
3
4
5
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

CI/CD Promotion

GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: Deploy

on:
  push:
    branches: [main, staging]
  release:
    types: [published]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set environment
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            echo "ENV=staging" >> $GITHUB_ENV
          elif [[ "${{ github.event_name }}" == "release" ]]; then
            echo "ENV=production" >> $GITHUB_ENV
          else
            echo "ENV=development" >> $GITHUB_ENV
          fi
      
      - name: Deploy
        run: |
          ./deploy.sh ${{ env.ENV }}
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets[format('AWS_ACCESS_KEY_{0}', env.ENV)] }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets[format('AWS_SECRET_{0}', env.ENV)] }}

Promotion Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/bash
# promote.sh - Promote build from one env to another

FROM_ENV=$1
TO_ENV=$2

if [[ -z "$FROM_ENV" || -z "$TO_ENV" ]]; then
    echo "Usage: ./promote.sh staging production"
    exit 1
fi

# Get current version in source env
VERSION=$(aws ssm get-parameter \
    --name "/${FROM_ENV}/app/version" \
    --query 'Parameter.Value' \
    --output text)

echo "Promoting version $VERSION from $FROM_ENV to $TO_ENV"

# Tag image for new environment
docker pull myapp:${FROM_ENV}-${VERSION}
docker tag myapp:${FROM_ENV}-${VERSION} myapp:${TO_ENV}-${VERSION}
docker push myapp:${TO_ENV}-${VERSION}

# Update version in target env
aws ssm put-parameter \
    --name "/${TO_ENV}/app/version" \
    --value "$VERSION" \
    --overwrite

# Trigger deployment
aws ecs update-service \
    --cluster ${TO_ENV}-cluster \
    --service app \
    --force-new-deployment

Database Migrations

Run migrations carefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# migrations/env.py (Alembic)
from alembic import context
import os

def run_migrations():
    env = os.environ.get("ENV", "development")
    
    if env == "production":
        # Extra safety for prod
        print("Running production migration - are you sure? (yes/no)")
        confirm = input()
        if confirm != "yes":
            print("Aborted")
            return
    
    # Run migrations
    context.run_migrations()

Migration workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 1. Create migration
alembic revision --autogenerate -m "add users table"

# 2. Test in dev
ENV=development alembic upgrade head

# 3. Test in staging
ENV=staging alembic upgrade head

# 4. Run in production (during maintenance window)
ENV=production alembic upgrade head

Feature Flags

Deploy code without enabling features:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from config import settings

FEATURE_FLAGS = {
    "new_checkout": {
        "development": True,
        "staging": True,
        "production": False,  # Enable after testing
    },
    "dark_mode": {
        "development": True,
        "staging": True,
        "production": True,
    }
}

def is_enabled(feature: str) -> bool:
    env = settings.environment
    return FEATURE_FLAGS.get(feature, {}).get(env, False)

# Usage
if is_enabled("new_checkout"):
    return new_checkout_flow()
else:
    return legacy_checkout()

The Checklist

For each environment:

  • Separate database
  • Separate credentials (never share prod keys)
  • Appropriate resource sizing
  • Correct log levels
  • Feature flags configured
  • Monitoring/alerting set up
  • Access controls defined

The Rule

Never promote untested code to production.

The path is always: Dev → Staging → Prod. No shortcuts.


Got an environment horror story? Share it on Twitter.