seis contenedores, una puerta
El stack completo son seis servicios en docker compose: postgres, minio, auth, blog, frontend, y caddy sentado delante de todo ello. Solo uno de ellos, caddy, tiene en realidad un puerto expuesto al mundo exterior. Todo lo demás habla con todo lo demás a través de la red de compose, nombre de contenedor a nombre de contenedor, completamente invisible desde fuera de la máquina.
Esa es toda la idea de un proxy inverso, una vez que le quitas las palabras de moda: una sola puerta a la casa, y sea cual sea la habitación que en realidad querías, la puerta lo averigua por ti según la ruta que pediste. El navegador solo conoce una dirección en todo momento. No tiene ni idea de que auth y blog sean siquiera procesos separados corriendo por separado.
handle /media/* {
request_body {
max_size 2MB
}
reverse_proxy minio:9000
}
handle {
request_body {
max_size 2MB
}
reverse_proxy frontend:3000
}Cualquier cosa que no coincida explícitamente con lo anterior cae en ese último bloque handle desnudo del final, que va al frontend. El frontend hace entonces su propio proxying interno hacia auth y blog para las llamadas reales a la API, así que desde el punto de vista de Caddy solo hay realmente dos destinos que importan directamente: minio para los archivos de medios en bruto, y frontend para absolutamente todo lo demás.
TLS automático, lo que en realidad todavía no he necesitado
Toda la reputación de Caddy es el HTTPS automático, va a conseguirte un certificado real de Let's Encrypt solo con nombrar un dominio real en la configuración. En local nada de eso aplica, {$DOMAIN} cae por defecto a localhost y Caddy simplemente sirve HTTP normal, sin ningún baile de certificados en absoluto. En el momento en que esto se ponga de verdad en un dominio real, esa misma línea de configuración se supone que va a empezar a pedir y renovar certificados por su cuenta, sin cron job de certbot aparte, nunca renovación manual. No he probado esa parte de verdad todavía porque este blog aún no está en vivo en un dominio real, lo cual honestamente es la parte de la que menos seguro estoy que vaya a salir bien a la primera. Todo parece siempre sencillo en la documentación hasta que eres tú quien de verdad lo opera.
un salto, de confianza, y nada más atrás que eso
Estar detrás de un proxy rompe dos cosas en las que no piensas hasta que se rompen: la propia comprobación CSRF del frontend, que compara el origen de la petición con lo que cree que es su propia dirección, y el limitador de tasa, que necesita la IP real del visitante, no la de Caddy.
# Behind Caddy the browser's origin is the public DOMAIN, not :3000.
# Derive the origin from Caddy's forwarded headers so adapter-node's
# CSRF/origin check on form POSTs passes for both http://localhost and
# https://<domain>.
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: x-forwarded-for
XFF_DEPTH: "1"Esa última línea, XFF_DEPTH: "1", fue la que más tiempo me llevó entender de verdad. Sin ella, getClientAddress() simplemente devuelve el contenedor que sea que haya abierto el socket, que siempre es el propio Caddy, ya que Caddy es lo único que se conecta directamente al contenedor del frontend. Decirle que confíe en exactamente un salto significa que lee la dirección del cliente de la cabecera que el propio Caddy añadió, no de nada que un cliente pudiera haber falsificado más arriba en la cadena anteponiendo sus propias entradas falsas. Si te equivocas con ese número en cualquier dirección, confiar en cero saltos hace que todo visitante parezca Caddy, confiar en demasiados permite que un cliente malicioso mienta sobre quién es.
healthchecks: hacer que "arriba" signifique algo
Cada servicio en compose tiene un healthcheck, y al principio no me los tomé en serio, simplemente copié uno de un ejemplo y seguí adelante:
# what I copied from an example, didn't think about it further
frontend:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
interval: 5s
timeout: 5s
retries: 5Luego necesité de verdad que depends_on: condition: service_healthy funcionara correctamente, ya que blog ni siquiera debería empezar a aceptar tráfico hasta que postgres esté de verdad listo para responder consultas, no solo "el proceso del contenedor arrancó".
frontend:
healthcheck:
# Use 127.0.0.1, not localhost: in Alpine localhost resolves to IPv6 ::1
# while the Node server listens on IPv4, so a localhost probe fails and
# the container is wrongly marked unhealthy.
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/"]
interval: 5s
timeout: 5s
retries: 5Ese 127.0.0.1 en lugar de localhost me costó una media hora genuinamente confusa. En las imágenes basadas en Alpine sobre las que corre todo esto, localhost se resuelve primero al loopback de IPv6, pero el servidor de Node solo escucha en el socket IPv4, así que el healthcheck se conectaba a nada, expiraba, y marcaba como no saludable un contenedor que funcionaba perfectamente. Compose se negaba entonces a levantar nada que dependiera de él, así que todo el stack se quedaba ahí parado, con todo funcionando en realidad por debajo, y docker convencido de lo contrario. Cambiar a la IP literal lo arregló del todo, y ahora uso 127.0.0.1 por defecto en cada healthcheck por costumbre en vez de confiar en que localhost signifique lo que creo que significa dentro de un contenedor.
El propio healthcheck de Caddy golpea su API interna de administración en lugar del sitio público por el mismo tipo de razón, así que sigue funcionando cuando DOMAIN se convierte en un nombre de host real y una petición normal a http://localhost dejaría de coincidir con nada en absoluto.
El mismo razonamiento recorre postgres, minio, auth, y blog también, cada uno probando algo que de verdad demuestra que está listo (pg_isready, el propio endpoint de salud de MinIO, el /healthz de cada servicio de Go) en lugar de solo comprobar que el proceso no se ha caído directamente. Un proceso caído y un proceso que está arriba pero que todavía está corriendo sus propias migraciones de arranque parecen idénticos desde fuera si todo lo que compruebas es "aceptó una conexión TCP", y a mí solo me importó esa distinción cuando compose de verdad hizo carrera entre las migraciones de auth y blog intentando hablar con una base de datos que aún no estaba lista para ello.
la trampa del emparejamiento de rutas
Una cosa sobre Caddy que no es obvia con una lectura rápida de la documentación: los bloques handle no se emparejan en el orden en que los escribiste en el archivo, se emparejan según lo específico que sea el patrón de ruta. Hay un bloque para /auth/permissions que devuelve un 404 desnudo (ese endpoint sirve una matriz interna de rol a capacidad que no tiene ningún negocio siendo alcanzable desde un GET público), sentado bastante por encima del catch-all handle {} al final del todo del archivo. No importa que no sea el último, la ruta más específica gana siempre sin importar dónde se sitúe. Hizo falta leerme de verdad la documentación de Caddy en vez de adivinar para confiar en eso, ya que es exactamente el tipo de suposición ("seguro que gana la primera coincidencia, como todo lo demás que he usado") que en algún momento habría roto algo en silencio, probablemente en el peor momento posible.
