Módulo 2: Local & Container Deployment
3. Environment Configuration
Descripción
En esta cápsula vas a dominar la configuración por entorno para Docker Compose: cómo hacer que tu misma app AI corra en development, staging y producción con diferentes configuraciones. Al terminar, podrás cambiar entre entornos con un solo flag, sin modificar código ni Compose files.
Contexto: Un Docker Compose que solo corre en "dev mode" no es deployment real. En producción necesitas logs diferentes, hot reload deshabilitado, variables de producción, y debugging tools apagados. Esta cápsula te enseña a gestionar esas diferencias de forma limpia.
Variables de Entorno: Los 3 Niveles
Nivel 1: Archivo .env
# .env — Variables por defecto (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 lee .env automáticamente cuando haces docker compose up:
# docker-compose.yml
services:
api:
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ENVIRONMENT=${ENVIRONMENT}
- LOG_LEVEL=${LOG_LEVEL}
Nivel 2: .env files por entorno
# .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
# Usar un .env específico
docker compose --env-file .env.staging up -d
Nivel 3: Compose Overrides
# docker-compose.yml — Base (siempre se aplica)
services:
api:
build: ./api
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://cache:6379
# docker-compose.override.yml — Dev overrides (se aplica automáticamente)
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 (usa override automáticamente)
docker compose up -d
# Production (especifica el archivo de override)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Override de Producción Completo para App AI
En producción, un override va mucho más allá de cambiar el log level. Necesitas resource limits, restart policies, no volumes de desarrollo, y health checks más estrictos:
# docker-compose.prod.yml — Override completo para producción
services:
api:
# Sin volumes de desarrollo (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
# Levantar producción con env-file y override
docker compose --env-file .env.production \
-f docker-compose.yml \
-f docker-compose.prod.yml \
up -d
# Verificar la configuración mergeada antes de levantar
docker compose --env-file .env.production \
-f docker-compose.yml \
-f docker-compose.prod.yml \
config
El truco clave: la base (docker-compose.yml) define la estructura, el override de producción ajusta los parámetros operativos. Nunca pongas configuración específica de un entorno en la base.
Patrón Completo: Config por Entorno
Estructura de archivos
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 # Variables dev (default)
├── .env.staging # Variables staging
├── .env.production # Variables production
├── .env.example # Template (commit this)
└── .gitignore # Ignora .env files reales
.gitignore para secrets
# .gitignore
.env
.env.staging
.env.production
!.env.example
.env.example (commitear esto)
# .env.example — Template de variables necesarias
# Copia a .env y llena con tus valores
OPENAI_API_KEY=sk-your-key-here
REDIS_URL=redis://cache:6379
ENVIRONMENT=development
LOG_LEVEL=debug
CACHE_TTL=60
Config en la App: Lectura de 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 — Usar 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 en Profundidad
Pydantic Settings es la forma estándar de leer variables de entorno en apps Python modernas. No es solo "leer env vars" — es validación, tipado, y documentación de tu configuración en un solo lugar.
Instalación
pip install pydantic-settings
# En requirements.txt
pydantic-settings>=2.0
Settings con Validación Completa
# 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",
# Variables case-insensitive: OPENAI_API_KEY == openai_api_key
case_sensitive=False,
)
# Requeridas (la app no arranca sin ellas)
openai_api_key: str
redis_url: str
# Con defaults (opcionales en .env)
environment: str = "development"
log_level: str = "debug"
cache_ttl: int = 60
api_workers: int = 1
model_name: str = "gpt-4o-mini"
# Opcionales
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):
"""Producción requiere configuraciones más estrictas."""
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 por Grupo (Nested)
Cuando tu app tiene muchos servicios, agrupa la configuración:
# 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 para settings agrupados
# El prefix determina a qué grupo pertenece cada variable
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
Usar Settings en 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,
},
}
La ventaja de Pydantic Settings: si alguien despliega sin LLM_API_KEY, la app falla inmediatamente con un error claro en lugar de fallar misteriosamente en la primera request a OpenAI.
Comparación Detallada: .env vs Compose environment vs Override Files
| Aspecto | .env file | Compose environment: | Override files |
|---|---|---|---|
| Qué configura | Variables de entorno | Variables de entorno | Cualquier config de servicio |
| Scope | Todo el Compose file | Un servicio específico | Un servicio específico |
| Cuándo se lee | Al hacer docker compose up | Al crear el container | Al mergear Compose files |
| Precedencia | Más baja | Media (sobreescribe .env) | Más alta |
| Uso principal | Defaults compartidos | Valores fijos por servicio | Config específica por entorno |
| Se commitea | No (solo .env.example) | Sí (valores no-secretos) | Sí |
| Ejemplo | LOG_LEVEL=debug | - LOG_LEVEL=warning | command: ... --workers 4 |
Orden de precedencia (de menor a mayor)
1. .env file → LOG_LEVEL=debug
2. .env.production file → LOG_LEVEL=warning (con --env-file)
3. Compose environment: → LOG_LEVEL=info (sobreescribe .env)
4. Override file → LOG_LEVEL=error (sobreescribe todo)
5. Shell export → LOG_LEVEL=critical (máxima precedencia)
# Demostrar precedencia
export LOG_LEVEL=critical
docker compose --env-file .env.production up -d
docker compose exec api env | grep LOG_LEVEL
# LOG_LEVEL=critical ← La variable del shell gana
unset LOG_LEVEL
Cuándo usar cada uno
.envfile → Variables que cambian entre máquinas de desarrolladores (API keys, paths locales)- Compose
environment:→ Variables que son parte de la arquitectura (URLs de servicios internos comoredis://cache:6379) - Override files → Cambios estructurales por entorno (workers, resource limits, volumes, comandos)
Comparación: Métodos de Config
| Método | Cuándo usar | Pros | Contras |
|---|---|---|---|
.env file | Siempre (base) | Simple, Docker Compose nativo | Solo 1 archivo default |
--env-file flag | Cambiar entre entornos | Fácil de switchear | Hay que recordar el flag |
| Compose override files | Config de infra diferente | Separa concerns | Múltiples archivos |
docker compose config | Verificar config final | Muestra config merged | Solo verificación |
# Ver la config final que Docker Compose va a usar
docker compose config
# Muestra el YAML mergeado con variables sustituidas
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
# Muestra la config de producción mergeada
Secrets Management
Las API keys no deberían estar en .env files en producción. Para entornos reales, Docker tiene un mecanismo de secrets:
Docker Compose Secrets (para producción)
# 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 — Leer secrets en la 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)
# En producción, Docker monta secrets en /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()
# Estructura de secrets (NO commitear)
secrets/
├── openai_api_key.txt # Contiene solo: sk-prod-key-real
└── redis_password.txt # Contiene solo: super-secure-password
# .gitignore
secrets/
Para desarrollo local, .env files son suficientes. Secrets de Docker son para cuando desplegues en un servidor real o en Docker Swarm.
Troubleshooting
Problema 1: "La variable de entorno está vacía en el container"
# Verificar que la variable existe en .env
cat .env | grep OPENAI_API_KEY
# Verificar que Compose la lee
docker compose config | grep OPENAI_API_KEY
# Verificar dentro del container
docker compose exec api env | grep OPENAI_API_KEY
Problema 2: "El override no se aplica"
# docker-compose.override.yml se aplica AUTOMÁTICAMENTE
# Solo cuando haces: docker compose up
# NO se aplica si especificas archivos explícitamente:
docker compose -f docker-compose.yml up # override NO se aplica
# Para incluirlo explícitamente:
docker compose -f docker-compose.yml -f docker-compose.override.yml up
Problema 3: "Commiteé secrets a Git"
# Si ya commiteaste .env con API keys:
# 1. Rota las keys inmediatamente
# 2. Elimina del historial de Git
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch .env' HEAD
# 3. Agrega a .gitignore
echo ".env" >> .gitignore
git add .gitignore && git commit -m "Ignore .env files"
Problema 4: "Pydantic Settings no lee mis variables"
# Causa común: el nombre de la variable no coincide con el campo
# Pydantic convierte: campo "openai_api_key" → busca OPENAI_API_KEY
# Si usas env_prefix, la variable debe incluir el prefijo
# Clase con env_prefix="REDIS_" → campo "url" → busca REDIS_URL
# Verificar qué variables ve Pydantic
python -c "
from config import Settings
try:
s = Settings()
print('Settings loaded OK')
except Exception as e:
print(f'Error: {e}')
"
Problema 5: "Las variables del shell sobreescriben mi .env"
# Si exportaste una variable en tu terminal, tiene precedencia sobre .env
echo $LOG_LEVEL
# Si muestra algo, esa variable está sobreescribiendo tu .env
# Solución: unset la variable o usa un subshell limpio
unset LOG_LEVEL
docker compose up -d
# Alternativa: verificar con docker compose config
docker compose config | grep LOG_LEVEL
# Muestra el valor final que Compose va a usar
Ejercicios Prácticos
Ejercicio 1: Crea configuración 3-entornos
Crea archivos .env para development, staging, y production con diferentes valores de LOG_LEVEL, CACHE_TTL, y API_WORKERS.
Ver solución
# .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
Ejercicio 2: Compose override para dev con hot reload
Crea un docker-compose.override.yml que monte el código como volumen y habilite hot reload.
Ver solución
# 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
Ahora en dev, cada cambio en ./api/ se refleja automáticamente sin rebuild.
Ejercicio 3: Validación de config
Agrega al endpoint /health la información del entorno actual y verifica que cambia según el .env file usado.
Ver solución
@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",...}}
Ejercicio 4: Pydantic Settings con validación
Agrega validación a Settings: OPENAI_API_KEY no puede estar vacío, CACHE_TTL debe ser >0.
Ver solución
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
Si la API key está vacía, la app no arranca — falla rápido en lugar de fallar en la primera request.
Ejercicio 5: Override de producción completo
Crea un docker-compose.prod.yml que desactive hot reload, configure 4 workers, limite memoria a 1G para la API, y configure logging con rotación.
Ver solución
# 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
# Levantar producción
docker compose --env-file .env.production \
-f docker-compose.yml \
-f docker-compose.prod.yml \
up -d
# Verificar resource limits
docker compose exec api cat /sys/fs/cgroup/memory.max
# 1073741824 (1G en bytes)
Resumen
- 3 niveles de config:
.envfiles,--env-fileflag, Compose override files. .envfiles por entorno: development, staging, production con diferentes valores.- Compose overrides:
docker-compose.override.yml(auto en dev),docker-compose.prod.yml(explícito). - Pydantic Settings: Lee variables de entorno con validación, tipado, y defaults. Agrupa settings con
env_prefixpara apps complejas. - Precedencia: shell > override > Compose environment > .env file. Usa
docker compose configpara verificar. - Nunca commitees
.envreal. Commitea.env.examplecomo template. En producción, usa Docker secrets. docker compose configmuestra la config mergeada final — úsalo para debugging.- Validadores en Settings hacen que la app falle rápido si la configuración es incorrecta, en lugar de fallar en runtime.
Recursos Adicionales
- Docker Compose Environment Variables — Referencia oficial
- Pydantic Settings — Gestión de config en Python
- The Twelve-Factor App — Config — Principio de config en variables de entorno
- Docker Compose Override — Cómo funcionan los overrides
- dotenv Best Practices — Patrones de uso de .env files
- Git Filter-Branch — Eliminar secrets del historial de Git
- Docker Secrets — Gestión de secrets en Docker Compose
- Pydantic Validators — Validadores custom para settings