Module 2: Local & Container Deployment

3. Environment Configuration

Overview

In this capsule you'll master per-environment configuration for Docker Compose: how to make your same AI app run in development, staging, and production with different configurations. By the end, you'll be able to switch between environments with a single flag, without modifying code or Compose files.

Context: A Docker Compose that only runs in "dev mode" isn't real deployment. In production you need different logs, hot reload disabled, production variables, and debugging tools off. This capsule teaches you to manage those differences cleanly.


Environment Variables: The 3 Levels

Level 1: The .env file

# .env — Default variables (development)
OPENAI_API_KEY=sk-dev-key-here
REDIS_URL=redis://cache:6379
ENVIRONMENT=development
LOG_LEVEL=debug
API_PORT=8000
CACHE_TTL=60

Docker Compose reads .env automatically when you run docker compose up:

# docker-compose.yml
services:
  api:
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - ENVIRONMENT=${ENVIRONMENT}
      - LOG_LEVEL=${LOG_LEVEL}

Level 2: .env files per environment

# .env.development
ENVIRONMENT=development
LOG_LEVEL=debug
CACHE_TTL=60
API_WORKERS=1

# .env.staging
ENVIRONMENT=staging
LOG_LEVEL=info
CACHE_TTL=300
API_WORKERS=2

# .env.production
ENVIRONMENT=production
LOG_LEVEL=warning
CACHE_TTL=3600
API_WORKERS=4
# Use a specific .env
docker compose --env-file .env.staging up -d

Level 3: Compose Overrides

# docker-compose.yml — Base (always applied)
services:
  api:
    build: ./api
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://cache:6379

# docker-compose.override.yml — Dev overrides (applied automatically)
services:
  api:
    volumes:
      - ./api:/app  # Hot reload
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
    environment:
      - LOG_LEVEL=debug

# docker-compose.prod.yml — Production overrides
services:
  api:
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
    environment:
      - LOG_LEVEL=warning
    deploy:
      resources:
        limits:
          memory: 512M
# Development (uses the override automatically)
docker compose up -d

# Production (specify the override file)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Complete Production Override for an AI App

In production, an override goes far beyond changing the log level. You need resource limits, restart policies, no development volumes, and stricter health checks:

# docker-compose.prod.yml — Complete override for production
services:
  api:
    # No development volumes (no hot reload)
    volumes: []
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${API_WORKERS:-4}
    environment:
      - LOG_LEVEL=warning
      - ENVIRONMENT=production
      - CACHE_TTL=3600
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 256M
    restart: unless-stopped
    healthcheck:
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 30s
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  cache:
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    deploy:
      resources:
        limits:
          memory: 512M
    restart: unless-stopped

  vectordb:
    deploy:
      resources:
        limits:
          memory: 2G
        reservations:
          memory: 512M
    restart: unless-stopped
# Bring up production with env-file and override
docker compose --env-file .env.production \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  up -d

# Verify the merged configuration before bringing it up
docker compose --env-file .env.production \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  config

The key trick: the base (docker-compose.yml) defines the structure, the production override tunes the operational parameters. Never put environment-specific configuration in the base.


Complete Pattern: Config per Environment

File structure

project/
├── docker-compose.yml              # Base config
├── docker-compose.override.yml     # Dev overrides (auto)
├── docker-compose.staging.yml      # Staging overrides
├── docker-compose.prod.yml         # Prod overrides
├── .env                            # Dev variables (default)
├── .env.staging                    # Staging variables
├── .env.production                 # Production variables
├── .env.example                    # Template (commit this)
└── .gitignore                      # Ignores real .env files

.gitignore for secrets

# .gitignore
.env
.env.staging
.env.production
!.env.example

.env.example (commit this)

# .env.example — Template of required variables
# Copy to .env and fill with your values
OPENAI_API_KEY=sk-your-key-here
REDIS_URL=redis://cache:6379
ENVIRONMENT=development
LOG_LEVEL=debug
CACHE_TTL=60

Config in the App: Reading Variables

# api/config.py
import os
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    openai_api_key: str
    redis_url: str = "redis://localhost:6379"
    environment: str = "development"
    log_level: str = "debug"
    cache_ttl: int = 60
    api_workers: int = 1

    @property
    def is_development(self) -> bool:
        return self.environment == "development"

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

    class Config:
        env_file = ".env"

settings = Settings()
# api/main.py — Use settings
from config import settings
import logging

logging.basicConfig(level=getattr(logging, settings.log_level.upper()))
logger = logging.getLogger(__name__)

@app.get("/health")
def health():
    return {
        "status": "healthy",
        "environment": settings.environment,
        "log_level": settings.log_level,
    }

Pydantic Settings in Depth

Pydantic Settings is the standard way to read environment variables in modern Python apps. It's not just "reading env vars" — it's validation, typing, and documentation of your configuration in one place.

Installation

pip install pydantic-settings
# In requirements.txt
pydantic-settings>=2.0

Settings with Full Validation

# api/config.py
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        # Case-insensitive variables: OPENAI_API_KEY == openai_api_key
        case_sensitive=False,
    )

    # Required (the app doesn't start without them)
    openai_api_key: str
    redis_url: str

    # With defaults (optional in .env)
    environment: str = "development"
    log_level: str = "debug"
    cache_ttl: int = 60
    api_workers: int = 1
    model_name: str = "gpt-4o-mini"

    # Optional
    sentry_dsn: Optional[str] = None
    cors_origins: str = "http://localhost:3000"

    @field_validator("openai_api_key")
    @classmethod
    def validate_api_key(cls, v: str) -> str:
        if not v or v.startswith("sk-your-"):
            raise ValueError(
                "OPENAI_API_KEY must be set to a real key, not a placeholder"
            )
        return v

    @field_validator("cache_ttl")
    @classmethod
    def validate_cache_ttl(cls, v: int) -> int:
        if v < 0:
            raise ValueError("CACHE_TTL cannot be negative")
        return v

    @field_validator("log_level")
    @classmethod
    def validate_log_level(cls, v: str) -> str:
        allowed = {"debug", "info", "warning", "error", "critical"}
        if v.lower() not in allowed:
            raise ValueError(f"LOG_LEVEL must be one of {allowed}")
        return v.lower()

    @model_validator(mode="after")
    def validate_production_settings(self):
        """Production requires stricter configurations."""
        if self.environment == "production":
            if self.log_level == "debug":
                raise ValueError(
                    "LOG_LEVEL=debug is not allowed in production"
                )
            if self.sentry_dsn is None:
                raise ValueError(
                    "SENTRY_DSN is required in production for error tracking"
                )
        return self

    @property
    def is_development(self) -> bool:
        return self.environment == "development"

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

    @property
    def cors_origins_list(self) -> list[str]:
        return [origin.strip() for origin in self.cors_origins.split(",")]


settings = Settings()

Settings by Group (Nested)

When your app has many services, group the configuration:

# api/config.py
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class RedisSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="REDIS_")

    url: str = "redis://cache:6379"
    ttl: int = 60
    max_connections: int = 10


class LLMSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="LLM_")

    api_key: str
    model: str = "gpt-4o-mini"
    max_tokens: int = 2048
    temperature: float = 0.7

    @field_validator("temperature")
    @classmethod
    def validate_temperature(cls, v: float) -> float:
        if not 0.0 <= v <= 2.0:
            raise ValueError("Temperature must be between 0.0 and 2.0")
        return v


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    environment: str = "development"
    log_level: str = "debug"

    redis: RedisSettings = RedisSettings()
    llm: LLMSettings = LLMSettings()


settings = AppSettings()
# .env for grouped settings
# The prefix determines which group each variable belongs to
ENVIRONMENT=development
LOG_LEVEL=debug
REDIS_URL=redis://cache:6379
REDIS_TTL=120
REDIS_MAX_CONNECTIONS=20
LLM_API_KEY=sk-your-key
LLM_MODEL=gpt-4o-mini
LLM_MAX_TOKENS=4096
LLM_TEMPERATURE=0.3

Use Settings in FastAPI

# api/main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
from config import settings
import logging

logging.basicConfig(level=getattr(logging, settings.log_level.upper()))
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info(f"Starting in {settings.environment} mode")
    logger.info(f"LLM model: {settings.llm.model}")
    yield
    logger.info("Shutting down")


app = FastAPI(
    title="AI API",
    docs_url="/docs" if settings.is_development else None,
    lifespan=lifespan,
)


@app.get("/health")
def health():
    return {
        "status": "healthy",
        "environment": settings.environment,
        "config": {
            "log_level": settings.log_level,
            "cache_ttl": settings.redis.ttl,
            "llm_model": settings.llm.model,
        },
    }

The advantage of Pydantic Settings: if someone deploys without LLM_API_KEY, the app fails immediately with a clear error instead of failing mysteriously on the first request to OpenAI.


Detailed Comparison: .env vs Compose environment vs Override Files

Aspect.env fileCompose environment:Override files
What it configuresEnvironment variablesEnvironment variablesAny service config
ScopeThe whole Compose fileA specific serviceA specific service
When it's readOn docker compose upOn container creationOn merging Compose files
PrecedenceLowestMedium (overrides .env)Highest
Main useShared defaultsFixed values per serviceEnvironment-specific config
CommittedNo (only .env.example)Yes (non-secret values)Yes
ExampleLOG_LEVEL=debug- LOG_LEVEL=warningcommand: ... --workers 4

Precedence order (from lowest to highest)

1. .env file               → LOG_LEVEL=debug
2. .env.production file     → LOG_LEVEL=warning  (with --env-file)
3. Compose environment:     → LOG_LEVEL=info      (overrides .env)
4. Override file            → LOG_LEVEL=error      (overrides everything)
5. Shell export             → LOG_LEVEL=critical   (highest precedence)
# Demonstrate precedence
export LOG_LEVEL=critical
docker compose --env-file .env.production up -d
docker compose exec api env | grep LOG_LEVEL
# LOG_LEVEL=critical  ← The shell variable wins
unset LOG_LEVEL

When to use each one

  • .env file → Variables that change between developer machines (API keys, local paths)
  • Compose environment: → Variables that are part of the architecture (internal service URLs like redis://cache:6379)
  • Override files → Structural changes per environment (workers, resource limits, volumes, commands)

Comparison: Config Methods

MethodWhen to useProsCons
.env fileAlways (base)Simple, native to Docker ComposeOnly 1 default file
--env-file flagSwitch between environmentsEasy to switchYou have to remember the flag
Compose override filesDifferent infra configSeparates concernsMultiple files
docker compose configVerify final configShows merged configVerification only
# See the final config Docker Compose will use
docker compose config
# Shows the merged YAML with substituted variables

docker compose -f docker-compose.yml -f docker-compose.prod.yml config
# Shows the merged production config

Secrets Management

API keys shouldn't be in .env files in production. For real environments, Docker has a secrets mechanism:

Docker Compose Secrets (for production)

# docker-compose.prod.yml
services:
  api:
    environment:
      - ENVIRONMENT=production
    secrets:
      - openai_api_key
      - redis_password

secrets:
  openai_api_key:
    file: ./secrets/openai_api_key.txt
  redis_password:
    file: ./secrets/redis_password.txt
# api/config.py — Read secrets in the app
from pathlib import Path
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    openai_api_key: str = ""
    environment: str = "development"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # In production, Docker mounts secrets in /run/secrets/
        if self.environment == "production":
            secret_path = Path("/run/secrets/openai_api_key")
            if secret_path.exists():
                self.openai_api_key = secret_path.read_text().strip()
# Secrets structure (do NOT commit)
secrets/
├── openai_api_key.txt    # Contains only: sk-prod-key-real
└── redis_password.txt    # Contains only: super-secure-password

# .gitignore
secrets/

For local development, .env files are enough. Docker secrets are for when you deploy on a real server or in Docker Swarm.


Troubleshooting

Problem 1: "The environment variable is empty in the container"

# Verify that the variable exists in .env
cat .env | grep OPENAI_API_KEY

# Verify that Compose reads it
docker compose config | grep OPENAI_API_KEY

# Verify inside the container
docker compose exec api env | grep OPENAI_API_KEY

Problem 2: "The override isn't applied"

# docker-compose.override.yml is applied AUTOMATICALLY
# Only when you run: docker compose up

# It is NOT applied if you specify files explicitly:
docker compose -f docker-compose.yml up  # override is NOT applied

# To include it explicitly:
docker compose -f docker-compose.yml -f docker-compose.override.yml up

Problem 3: "I committed secrets to Git"

# If you already committed .env with API keys:
# 1. Rotate the keys immediately
# 2. Remove them from the Git history
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch .env' HEAD
# 3. Add to .gitignore
echo ".env" >> .gitignore
git add .gitignore && git commit -m "Ignore .env files"

Problem 4: "Pydantic Settings doesn't read my variables"

# Common cause: the variable name doesn't match the field
# Pydantic converts: field "openai_api_key" → looks for OPENAI_API_KEY

# If you use env_prefix, the variable must include the prefix
# Class with env_prefix="REDIS_" → field "url" → looks for REDIS_URL

# Verify what variables Pydantic sees
python -c "
from config import Settings
try:
    s = Settings()
    print('Settings loaded OK')
except Exception as e:
    print(f'Error: {e}')
"

Problem 5: "Shell variables override my .env"

# If you exported a variable in your terminal, it takes precedence over .env
echo $LOG_LEVEL
# If it shows something, that variable is overriding your .env

# Solution: unset the variable or use a clean subshell
unset LOG_LEVEL
docker compose up -d

# Alternative: verify with docker compose config
docker compose config | grep LOG_LEVEL
# Shows the final value Compose will use

Hands-On Exercises

Exercise 1: Create a 3-environment configuration

Create .env files for development, staging, and production with different values for LOG_LEVEL, CACHE_TTL, and API_WORKERS.

See solution
# .env.development
ENVIRONMENT=development
LOG_LEVEL=debug
CACHE_TTL=60
API_WORKERS=1

# .env.staging
ENVIRONMENT=staging
LOG_LEVEL=info
CACHE_TTL=300
API_WORKERS=2

# .env.production
ENVIRONMENT=production
LOG_LEVEL=warning
CACHE_TTL=3600
API_WORKERS=4
docker compose --env-file .env.staging up -d
docker compose exec api env | grep ENVIRONMENT
# ENVIRONMENT=staging

Exercise 2: Compose override for dev with hot reload

Create a docker-compose.override.yml that mounts the code as a volume and enables hot reload.

See solution
# docker-compose.override.yml
services:
  api:
    volumes:
      - ./api:/app
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
    environment:
      - LOG_LEVEL=debug

Now in dev, every change in ./api/ is reflected automatically without a rebuild.

Exercise 3: Config validation

Add the current environment info to the /health endpoint and verify that it changes based on the .env file used.

See solution
@app.get("/health")
def health():
    return {
        "status": "healthy",
        "environment": settings.environment,
        "config": {
            "log_level": settings.log_level,
            "cache_ttl": settings.cache_ttl,
            "workers": settings.api_workers,
        }
    }
docker compose --env-file .env.development up -d
curl localhost:8000/health
# {"environment":"development","config":{"log_level":"debug",...}}

docker compose down
docker compose --env-file .env.production up -d
curl localhost:8000/health
# {"environment":"production","config":{"log_level":"warning",...}}

Exercise 4: Pydantic Settings with validation

Add validation to Settings: OPENAI_API_KEY can't be empty, CACHE_TTL must be >0.

See solution
from pydantic import field_validator
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    openai_api_key: str
    cache_ttl: int = 60

    @field_validator("openai_api_key")
    @classmethod
    def key_not_empty(cls, v):
        if not v or v == "sk-your-key-here":
            raise ValueError("OPENAI_API_KEY must be set to a real key")
        return v

    @field_validator("cache_ttl")
    @classmethod
    def ttl_positive(cls, v):
        if v <= 0:
            raise ValueError("CACHE_TTL must be positive")
        return v

If the API key is empty, the app doesn't start — it fails fast instead of failing on the first request.

Exercise 5: Complete production override

Create a docker-compose.prod.yml that disables hot reload, configures 4 workers, limits the API memory to 1G, and configures logging with rotation.

See solution
# docker-compose.prod.yml
services:
  api:
    volumes: []
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
    environment:
      - LOG_LEVEL=warning
      - ENVIRONMENT=production
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  cache:
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    restart: unless-stopped
# Bring up production
docker compose --env-file .env.production \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  up -d

# Verify resource limits
docker compose exec api cat /sys/fs/cgroup/memory.max
# 1073741824  (1G in bytes)

Summary

  • 3 config levels: .env files, --env-file flag, Compose override files.
  • .env files per environment: development, staging, production with different values.
  • Compose overrides: docker-compose.override.yml (auto in dev), docker-compose.prod.yml (explicit).
  • Pydantic Settings: Reads environment variables with validation, typing, and defaults. Group settings with env_prefix for complex apps.
  • Precedence: shell > override > Compose environment > .env file. Use docker compose config to verify.
  • Never commit the real .env. Commit .env.example as a template. In production, use Docker secrets.
  • docker compose config shows the final merged config — use it for debugging.
  • Validators in Settings make the app fail fast if the configuration is incorrect, instead of failing at runtime.

Additional Resources

  1. Docker Compose Environment Variables — Official reference
  2. Pydantic Settings — Config management in Python
  3. The Twelve-Factor App — Config — Principle of config in environment variables
  4. Docker Compose Override — How overrides work
  5. dotenv Best Practices — Patterns for using .env files
  6. Git Filter-Branch — Remove secrets from Git history
  7. Docker Secrets — Secrets management in Docker Compose
  8. Pydantic Validators — Custom validators for settings