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 fileCompose environment:Override files
Qué configuraVariables de entornoVariables de entornoCualquier config de servicio
ScopeTodo el Compose fileUn servicio específicoUn servicio específico
Cuándo se leeAl hacer docker compose upAl crear el containerAl mergear Compose files
PrecedenciaMás bajaMedia (sobreescribe .env)Más alta
Uso principalDefaults compartidosValores fijos por servicioConfig específica por entorno
Se commiteaNo (solo .env.example)Sí (valores no-secretos)
EjemploLOG_LEVEL=debug- LOG_LEVEL=warningcommand: ... --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

  • .env file → 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 como redis://cache:6379)
  • Override files → Cambios estructurales por entorno (workers, resource limits, volumes, comandos)

Comparación: Métodos de Config

MétodoCuándo usarProsContras
.env fileSiempre (base)Simple, Docker Compose nativoSolo 1 archivo default
--env-file flagCambiar entre entornosFácil de switchearHay que recordar el flag
Compose override filesConfig de infra diferenteSepara concernsMúltiples archivos
docker compose configVerificar config finalMuestra config mergedSolo 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: .env files, --env-file flag, Compose override files.
  • .env files 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_prefix para apps complejas.
  • Precedencia: shell > override > Compose environment > .env file. Usa docker compose config para verificar.
  • Nunca commitees .env real. Commitea .env.example como template. En producción, usa Docker secrets.
  • docker compose config muestra 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

  1. Docker Compose Environment Variables — Referencia oficial
  2. Pydantic Settings — Gestión de config en Python
  3. The Twelve-Factor App — Config — Principio de config en variables de entorno
  4. Docker Compose Override — Cómo funcionan los overrides
  5. dotenv Best Practices — Patrones de uso de .env files
  6. Git Filter-Branch — Eliminar secrets del historial de Git
  7. Docker Secrets — Gestión de secrets en Docker Compose
  8. Pydantic Validators — Validadores custom para settings