#Qué conectamos ahora
Esta es la segunda parte del proyecto del catálogo. Continúa en la misma carpeta, con app.py, requirements.txt, Dockerfile, .dockerignore, init-db.sh, .env y los tres secretos ya explicados. Ahora añadirás solo dos archivos: Caddyfile y compose.yaml. Después verificarás operaciones reales antes de ensayar fallos.
#Fase 5 · Poner el proxy delante
Guarda este Caddyfile local:
:80 {
request_body {
max_size 8KB
}
reverse_proxy api:8000 {
transport http {
dial_timeout 3s
response_header_timeout 10s
}
}
log {
output stdout
format json
}
}
:80 sirve HTTP para el laboratorio, sin intentar emitir un certificado público. Caddy recibe la petición y conecta a api:8000 por la red interna compartida. Limita cuerpos a 8 KB; la API aplica un límite de 4 KB y valida su significado. Un límite en el proxy protege antes de consumir trabajo de aplicación, y la validación interna sigue siendo necesaria si otra ruta llega directamente a la API.
En esta fase la publicación estará limitada a 127.0.0.1, así que no se enviarán tokens por HTTP a través de Internet. Para un dominio público aplica la fase de HTTPS: cambia el sitio por tu dominio, publica 80/443 y conserva el volumen de certificados.
#Fase 6 · Conectar el sistema con Compose
Guarda compose.yaml:
Ver compose.yaml completo · servicios, redes, volúmenes y secretos
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_DB: catalogo
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password, app_password]
volumes:
- db_data:/var/lib/postgresql/data
- ./init-db.sh:/docker-entrypoint-initdb.d/10-init.sh:ro
networks: [datos]
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "postgres", "-d", "catalogo"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
restart: unless-stopped
mem_limit: 512m
cpus: 1.0
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
api:
image: "${APP_IMAGE:-catalogo:1}"
build: .
environment:
DB_HOST: db
DB_NAME: catalogo
DB_USER: catalogo
DB_PASSWORD_FILE: /run/secrets/app_password
WRITE_TOKEN_FILE: /run/secrets/write_token
secrets: [app_password, write_token]
depends_on:
db:
condition: service_healthy
networks: [entrada, datos]
dns_search: ["."]
dns_opt: ["timeout:1", "attempts:1"]
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
init: true
stop_grace_period: 30s
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=4)"
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
mem_limit: 256m
cpus: 1.0
pids_limit: 64
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
proxy:
image: caddy:2-alpine
ports:
- "127.0.0.1:${HTTP_PORT:-8080}:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [entrada]
depends_on:
api:
condition: service_healthy
restart: unless-stopped
mem_limit: 128m
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
networks:
entrada:
datos:
internal: true
volumes:
db_data:
caddy_data:
caddy_config:
secrets:
db_password:
file: ./secrets/db_password
app_password:
file: ./secrets/app_password
write_token:
file: ./secrets/write_token
Lee el archivo siguiendo una petición: proxy → api por entrada → db por datos. La base no pertenece a entrada, y no tiene ports. La API tiene acceso a ambas redes pero no publica el 8000. Los secretos se asignan por servicio; el proxy no recibe ninguno de los secretos de base o escritura.
El healthcheck de PostgreSQL usa -h 127.0.0.1 para comprobar TCP. Durante la inicialización la imagen oficial puede levantar un servidor temporal accesible solo por socket Unix; comprobar ese socket podría declarar disponibilidad antes de terminar los scripts. Aun con TCP correcto, la API comprueba además tabla y permisos con su usuario real.
dns_search: ["."] evita heredar sufijos de búsqueda del host para un servicio que usa nombres internos. dns_opt limita a un segundo cada espera y a un intento del resolutor de esta imagen Linux. Si detienes db, su nombre puede desaparecer del DNS de Docker: sin límites DNS, connect_timeout=3 no impediría esperar mucho más antes de intentar conectar. Un plazo DNS no equivale por sí solo a un plazo total —puede haber varias consultas—; se combina con los límites de conexión, consulta y proxy, y se comprueba mediante la prueba de caída. Si tu entorno necesita dominios de búsqueda corporativos, configúralos deliberadamente y vuelve a medir.
read_only, tmpfs, cap_drop y no-new-privileges se aplican a la API que diseñamos para funcionar así. Los límites de memoria son un punto inicial para este laboratorio, no una garantía de capacidad: observa arranque y carga antes de ajustarlos. Los tres servicios rotan logs mediante el driver local.
#Fase 7 · Arrancar y probar contratos
Desde la misma carpeta, con Docker y Compose en marcha:
docker compose config --quiet
docker compose build api
docker compose up -d --wait --wait-timeout 120
docker compose ps -a
curl --fail http://127.0.0.1:8080/health/ready
curl --fail http://127.0.0.1:8080/libros
Si cambiaste HTTP_PORT, usa ese puerto en todas las peticiones. Esperas {"ready": true} y {"libros": []} en el primer arranque. --wait comprueba salud de los servicios que tienen healthcheck; la petición por el proxy demuestra el camino completo.
Prueba primero el rechazo:
curl -i -X POST http://127.0.0.1:8080/libros \
-H 'Content-Type: application/json' \
--data '{"titulo":"El libro sin permiso"}'
Espera 401 Unauthorized. Ahora crea una cabecera en un archivo privado para no escribir el token literal en el historial ni pasarlo como argumento visible de curl:
printf 'Authorization: Bearer %s\n' "$(cat secrets/write_token)" > secrets/write-header
chmod 600 secrets/write-header
curl --fail-with-body -i http://127.0.0.1:8080/libros \
-H @secrets/write-header \
-H 'Content-Type: application/json' \
--data '{"titulo":"Diseñar sistemas que se recuperan"}'
curl --fail http://127.0.0.1:8080/libros
Espera 201 Created, un id numérico y el libro en la lectura posterior. El identificador no tiene por qué ser 1 si ya hiciste pruebas: las secuencias no garantizan números sin huecos. Prueba también un título vacío con token: debe dar 400 y no insertar ninguna fila.
#Comprobar el límite de permisos
docker compose exec api id
docker compose exec api python -c \
'from app import connect; c=connect(); print(c.execute("SELECT current_user").fetchone()[0]); c.close()'
docker compose exec api python -c \
'from app import connect; c=connect(); c.execute("DELETE FROM libros"); c.commit()'
Esperas UID 10001, usuario SQL catalogo y un error InsufficientPrivilege al borrar. Ese último fallo es el resultado correcto: la API puede cumplir su función, pero no tiene permisos que no necesita. No lo "arregles" concediendo superusuario.
#Resultado de esta parte
El sistema ya recibe HTTP por el proxy, autentica escrituras, conserva libros en PostgreSQL y limita el rol de la aplicación. Haberlo arrancado una vez no demuestra recuperación: la siguiente página detiene una dependencia, reemplaza la API y restaura una copia por separado.
#Fuentes de esta parte
-
Dependencias y salud en Compose: qué espera cada servicio.
-
Proxy de Caddy: conexión al upstream y tiempos de espera.
-
Gunicorn y Docker: servidor, logs y apagado.
-
Opciones del resolutor Linux: búsqueda DNS, tiempos e intentos.