Skip to content

Environments

Manage configuration across development, staging, and production.

Environment Strategy

Typical Environment Hierarchy

┌─────────────┐
│ Development │  Local developer machines
└──────┬──────┘
┌──────┴──────┐
│   Staging   │  Pre-production testing
└──────┬──────┘
┌──────┴──────┐
│ Production  │  Live customer traffic
└─────────────┘

Environment Purposes

Environment Purpose Data Access
Development Local testing Fake/seeded Developers
Staging Integration testing Anonymized prod Team
Production Live users Real Restricted

GitHub Environments

Configure in Settings

# Repository Settings → Environments

# Staging environment
staging:
  deployment_branch_policy:
    protected_branches: false
    custom_branch_policies: true
    - main
    - develop
  environment_variables:
    API_URL: https://staging-api.example.com
  secrets:
    DATABASE_URL: ***

# Production environment
production:
  deployment_branch_policy:
    protected_branches: true  # Only main
  required_reviewers:
    - @team-leads
  wait_timer: 5  # minutes

Use in Workflows

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com

    steps:
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_URL: ${{ vars.API_URL }}
        run: ./deploy.sh

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com

    steps:
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}  # Different secret
        run: ./deploy.sh

Environment Variables

Configuration Hierarchy

┌─────────────────────────────────────┐
│     Environment Variables           │  Highest priority
├─────────────────────────────────────┤
│     .env.local                      │  Local overrides (gitignored)
├─────────────────────────────────────┤
│     .env.{environment}              │  Environment-specific
├─────────────────────────────────────┤
│     .env                            │  Default values
└─────────────────────────────────────┘

Python Settings

# settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal

class Settings(BaseSettings):
    environment: Literal["development", "staging", "production"] = "development"

    # Database
    database_url: str

    # API
    api_url: str = "http://localhost:8000"
    debug: bool = False

    # Feature flags
    enable_analytics: bool = False

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    @property
    def is_production(self) -> bool:
        return self.environment == "production"

settings = Settings()

Environment Files

# .env (defaults, committed)
ENVIRONMENT=development
DEBUG=true
ENABLE_ANALYTICS=false

# .env.staging (committed)
ENVIRONMENT=staging
DEBUG=false
API_URL=https://staging-api.example.com

# .env.production (committed, no secrets)
ENVIRONMENT=production
DEBUG=false
API_URL=https://api.example.com
ENABLE_ANALYTICS=true

# .env.local (gitignored, local overrides)
DATABASE_URL=postgresql://localhost/myapp
SECRET_KEY=dev-secret-key

Next.js Environment

# .env.local (development)
NEXT_PUBLIC_API_URL=http://localhost:8000
DATABASE_URL=postgresql://localhost/dev

# .env.production
NEXT_PUBLIC_API_URL=https://api.example.com
// next.config.js
module.exports = {
  env: {
    NEXT_PUBLIC_ENVIRONMENT: process.env.VERCEL_ENV || 'development',
  },
};

Secrets Management

Per-Environment Secrets

# GitHub Actions
jobs:
  deploy:
    environment: ${{ inputs.environment }}
    steps:
      - name: Deploy
        env:
          # Automatically uses environment-specific secret
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: ./deploy.sh

External Secret Stores

# Using AWS Secrets Manager
- name: Get secrets from AWS
  uses: aws-actions/aws-secretsmanager-get-secrets@v1
  with:
    secret-ids: |
      ${{ inputs.environment }}/database
      ${{ inputs.environment }}/api-keys
    parse-json-secrets: true

- name: Deploy
  env:
    DATABASE_URL: ${{ env.DATABASE_URL }}
  run: ./deploy.sh

Feature Flags by Environment

Configuration-Based

# feature_flags.py
from settings import settings

FEATURE_FLAGS = {
    "new_checkout": {
        "development": True,
        "staging": True,
        "production": False,
    },
    "dark_mode": {
        "development": True,
        "staging": True,
        "production": True,
    },
    "ai_recommendations": {
        "development": True,
        "staging": True,
        "production": False,  # Rolling out gradually
    },
}

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

# Usage
if is_feature_enabled("new_checkout"):
    return new_checkout_flow()
else:
    return legacy_checkout_flow()

External Feature Flag Service

import launchdarkly_client as ld

client = ld.LDClient(settings.launchdarkly_sdk_key)

def is_feature_enabled(feature: str, user: User = None) -> bool:
    context = ld.Context.builder(user.id if user else "anonymous").build()
    return client.variation(feature, context, False)

Database Per Environment

Separate Databases

# CI/CD secrets
staging:
  DATABASE_URL: postgresql://user:pass@staging-db.example.com/app

production:
  DATABASE_URL: postgresql://user:pass@prod-db.example.com/app

Database Migrations

migrate:
  runs-on: ubuntu-latest
  environment: ${{ inputs.environment }}

  steps:
    - uses: actions/checkout@v4

    - name: Run migrations
      env:
        DATABASE_URL: ${{ secrets.DATABASE_URL }}
      run: alembic upgrade head

    - name: Verify migrations
      run: alembic current

URL Configuration

Dynamic URLs

# settings.py
class Settings(BaseSettings):
    environment: str = "development"

    @property
    def api_url(self) -> str:
        return {
            "development": "http://localhost:8000",
            "staging": "https://staging-api.example.com",
            "production": "https://api.example.com",
        }[self.environment]

    @property
    def frontend_url(self) -> str:
        return {
            "development": "http://localhost:3000",
            "staging": "https://staging.example.com",
            "production": "https://example.com",
        }[self.environment]

CORS by Environment

from fastapi.middleware.cors import CORSMiddleware

CORS_ORIGINS = {
    "development": ["http://localhost:3000"],
    "staging": ["https://staging.example.com"],
    "production": ["https://example.com"],
}

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS[settings.environment],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Environment Promotion

Workflow for Promotion

name: Promote to Production

on:
  workflow_dispatch:
    inputs:
      staging_version:
        description: 'Staging version to promote'
        required: true

jobs:
  verify-staging:
    runs-on: ubuntu-latest
    steps:
      - name: Verify staging health
        run: |
          curl -f https://staging.example.com/health || exit 1

  promote:
    needs: verify-staging
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Tag production image
        run: |
          docker pull myapp:staging-${{ inputs.staging_version }}
          docker tag myapp:staging-${{ inputs.staging_version }} myapp:production
          docker push myapp:production

      - name: Deploy to production
        run: ./deploy.sh production

Environment Validation

Startup Checks

# startup.py
import sys
from settings import settings

def validate_environment():
    """Validate required configuration on startup."""
    errors = []

    # Check required settings
    if not settings.database_url:
        errors.append("DATABASE_URL is required")

    if settings.is_production:
        if settings.debug:
            errors.append("DEBUG must be False in production")
        if "localhost" in settings.database_url:
            errors.append("Cannot use localhost database in production")

    if errors:
        for error in errors:
            print(f"ERROR: {error}", file=sys.stderr)
        sys.exit(1)

# Run on import
validate_environment()

CI Environment Check

- name: Validate environment config
  run: |
    python -c "
    from settings import settings
    assert settings.environment == '${{ inputs.environment }}'
    assert settings.database_url.startswith('postgresql://')
    print('Environment configuration valid')
    "

Best Practices Summary

Practice Benefit
Separate environments Isolation, safety
Environment-specific secrets Security
Feature flags Controlled rollout
Validate on startup Catch misconfig early
No prod data in staging Privacy, compliance
Require approval for prod Change control
Document differences Team awareness
Automate promotion Consistency