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 file | Compose environment: | Override files |
|---|---|---|---|
| What it configures | Environment variables | Environment variables | Any service config |
| Scope | The whole Compose file | A specific service | A specific service |
| When it's read | On docker compose up | On container creation | On merging Compose files |
| Precedence | Lowest | Medium (overrides .env) | Highest |
| Main use | Shared defaults | Fixed values per service | Environment-specific config |
| Committed | No (only .env.example) | Yes (non-secret values) | Yes |
| Example | LOG_LEVEL=debug | - LOG_LEVEL=warning | command: ... --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
.envfile → Variables that change between developer machines (API keys, local paths)- Compose
environment:→ Variables that are part of the architecture (internal service URLs likeredis://cache:6379) - Override files → Structural changes per environment (workers, resource limits, volumes, commands)
Comparison: Config Methods
| Method | When to use | Pros | Cons |
|---|---|---|---|
.env file | Always (base) | Simple, native to Docker Compose | Only 1 default file |
--env-file flag | Switch between environments | Easy to switch | You have to remember the flag |
| Compose override files | Different infra config | Separates concerns | Multiple files |
docker compose config | Verify final config | Shows merged config | Verification 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:
.envfiles,--env-fileflag, 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_prefixfor complex apps. - Precedence: shell > override > Compose environment > .env file. Use
docker compose configto verify. - Never commit the real
.env. Commit.env.exampleas a template. In production, use Docker secrets. docker compose configshows 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
- Docker Compose Environment Variables — Official reference
- Pydantic Settings — Config management in Python
- The Twelve-Factor App — Config — Principle of config in environment variables
- Docker Compose Override — How overrides work
- dotenv Best Practices — Patterns for using .env files
- Git Filter-Branch — Remove secrets from Git history
- Docker Secrets — Secrets management in Docker Compose
- Pydantic Validators — Custom validators for settings