ADR-020 — Tres modos de despliegue¶
| Campo | Valor |
|---|---|
| Estado | Aceptado |
| Fecha | 5 de mayo de 2026 |
| Decisores | Equipo Custodiam (Marcos Val Sanz, Rodrigo Mulero García) |
Contexto¶
El stack de Custodiam tiene seis servicios productivos (postgres, keycloak, api, web, ntfy, cloudflared) más dos opt-in (n8n, mock-oidc). En distintos momentos del ciclo de desarrollo se necesitan composiciones diferentes del mismo conjunto:
- Iterar código de la app o la API en local. Hot reload de
uvicorny de Flutter, puertos del host expuestos paraadb reversey para entrar con el navegador. No hace falta — y sería contraproducente — atravesar Cloudflare Tunnel. - Probar contra dispositivos móviles reales el flujo de App Links, Universal Links y emails de Keycloak. Los SO móviles exigen verificar manifiestos
.well-known/accesibles por HTTPS desde el dominio real (https://app.custodiam.es), los clientes de email recortan custom schemes, y Keycloak debe emitir URLs públicas. El túnel sí es imprescindible. No hace falta el endurecimiento de producción. - Despliegue real en el PC anfitrión del piloto. Endurecer Keycloak con
KC_HOSTNAME_STRICT=truepara rechazar Host header spoofing, apagarDEBUGen la API, y arrancarcloudflaredcomo parte del stack sin que el operador tenga que pasar manualmente--profile tunnel.
Un modo único parametrizable por .env parece atractivo (un solo wrapper, una sola composición) pero choca con una restricción real de Keycloak. La variable KC_HOSTNAME es escalar y debe coincidir con el host real desde el que entra el cliente:
- Si vale
http://localhost:8080, Keycloak emite todas sus URLs absolutas (form actions de login, redirects OIDC, cookiesDomain=..., manifiesto.well-known/openid-configuration) contralocalhost. Un cliente que entre porhttps://auth.custodiam.esrecibe redirects alocalhost, su navegador no llega ahí y la sesión muere. - Si vale
auth.custodiam.es, ocurre lo simétrico: el cliente local que apunta alocalhost:8080recibe redirects al dominio público, su loopback no resuelve eso, sesión muere.
Esto no es una limitación de Compose ni de los scripts: es una propiedad estructural del flujo OIDC. Cualquier "modo unificado" exige resolver primero qué hace Keycloak con KC_HOSTNAME.
Decisión¶
Tres composiciones distintas, materializadas con el patrón Docker Compose base + override:
| Modo | Composición | Profile extra | KC_HOSTNAME |
STRICT |
DEBUG |
Imágenes api/web |
cloudflared |
Puertos al host |
|---|---|---|---|---|---|---|---|---|
| dev | base + docker-compose.dev.yml |
— | http://localhost:8080 |
false |
true |
build local (custodiam-{api,app}:dev) |
no | 5432, 8080, 8000, 3000, 8090 |
| tunnel | base |
--profile tunnel |
auth.${DOMAIN} |
false |
(sin definir) | GHCR :latest |
sí | ninguno |
| prod | base + docker-compose.prod.yml |
--profile tunnel (interno) |
auth.${DOMAIN} |
true |
false |
GHCR :latest |
sí | ninguno |
Tres scripts simétricos en custodiam-infra/scripts/ exponen un único entry point por modo:
./scripts/dev-up.sh # modo dev (local, hot reload)
./scripts/tunnel-up.sh # modo tunnel (staging vía Cloudflare Tunnel)
./scripts/prod-up.sh # modo prod (endurecido, cloudflared incluido)
./scripts/down.sh # baja el stack (volúmenes sobreviven)
Los tres scripts comparten _lib-env.sh para descifrar docker/.env.sops con sops + age. tunnel-up.sh y prod-up.sh aplican además dos pre-checks:
- Guard de cross-mode: abortan si ya hay contenedores con label
com.docker.compose.project=custodiamarriba. El mensaje de error nombra los contenedores activos y obliga a correr./scripts/down.shantes. Cierra el escenario en el que untunnel-up.shsobre un stack que sigue arriba en modo dev no recrea Keycloak (porque la mayoría de campos coinciden lo suficiente), heredando elKC_HOSTNAME=localhostdel dev y rompiendo el OIDC silenciosamente a través del túnel. - Verificación de
CLOUDFLARE_TUNNEL_TOKEN: abortan si el env file resuelto no contiene un valor no vacío. Sin el tokencloudflaredarranca pero falla al registrar el túnel silenciosamente.
tunnel-up.sh admite --skip-images para arrancar solo postgres + keycloak + cloudflared cuando se itera sobre la configuración del SSO sin reconstruir api/web.
Justificación¶
-
El modelo mental es explícito y verificable. Tres scripts nombrados eliminan ambigüedad. El operador sabe en qué modo está mirando el script que ejecuta (no leyendo variables de un
.envcifrado). Un nuevo miembro del equipo entiende el sistema en cinco minutos. -
KC_HOSTNAMEescalar impone una decisión a tomar. Como se explicó en el contexto, no existe un valor deKC_HOSTNAMEque sirva simultáneamente para clientes delocalhosty clientes deauth.custodiam.es. Forzar la decisión al script (en lugar de a una variable de entorno editable) elimina el modo de fallo "olvidé cambiar el.envantes de arrancar". -
El guard de cross-mode previene fallos silenciosos. Sin él, el escenario tipo "ya tenía el stack en dev, ahora ejecuto
tunnel-up.sh" deja Keycloak conKC_HOSTNAME=localhostaunque el cliente entre por el túnel, porque Compose no recrea contenedores cuyas variables de entorno coinciden lo suficiente. Estos fallos son los más difíciles de diagnosticar (todo "parece" funcionar pero ningún OIDC completa). -
La separación entre
tunnelyprodmantiene el principio "endurecer al final". El modotunneldejaSTRICT=falsepara que los desarrolladores puedan apuntar al túnel desde un dispositivo móvil sin que Keycloak rechace headersHost:que no correspondan exactamente al hostname configurado. El modoprodactivaSTRICT=trueporque ese stack solo lo opera el sistema y los clientes legítimos. -
Cada modo es reproducible y portable. Los tres scripts son CI-friendly: pueden ejecutarse en una máquina virgen tras un
git clone+ descifrado de secretos sin más configuración. La conmutación de modo no exige ediciones de ficheros, solo invocación de un script distinto.
Alternativas evaluadas y descartadas¶
A. Modo único parametrizable por .env¶
Un solo up.sh que lea variables MODE=dev|tunnel|prod y KC_HOSTNAME del .env y construya el comando docker compose con los overrides correctos.
- Pros: un único entry point, lógica centralizada.
- Contras: no simplifica el modelo mental (el operador sigue necesitando entender qué combina cada modo); mete la lógica del modo en
.env, que es donde viven los secretos cifrados — cambiar de modo implicaría editar.env.sopsen lugar de ejecutar un script distinto. Mala fricción operativa. - Descartado por: ergonomía inferior + acoplamiento entre lógica de modo y secretos.
B. Dev + tunnel combinado en un solo proceso¶
Levantar el stack de dev (hot reload + puertos expuestos) y al mismo tiempo activar cloudflared para exponer app.custodiam.es a Internet. Útil en teoría para probar deep links sin perder hot reload.
- Pros: itera la app móvil contra el túnel sin sacrificar el ciclo rápido.
- Contras: choca contra la restricción de
KC_HOSTNAME. Si el túnel atraviesa Keycloak (lo hace porquecloudflaredenrutaauth.custodiam.esal servicio interno),KC_HOSTNAMEdebe serauth.custodiam.es; pero entonces el desarrollador local que apunta alocalhost:8080recibe redirects al dominio público y su sesión se rompe — y viceversa. - Variante explorada: exponer solo
app.custodiam.es(la PWA) por el túnel, dejando Keycloak en modolocalhost. Pero los deep links que llevan a flujos OIDC (reset-password con token de Keycloak, p. ej.) dirigen al usuario aauth.custodiam.esque no estaría servido, así que el caso de uso real (probar emails de Keycloak desde el móvil) no se cubre. - Descartado por: la restricción estructural de
KC_HOSTNAMElo invalida.
C. Multi-hostname experimental de Keycloak 25+¶
Keycloak 25 introdujo soporte experimental para múltiples hostnames vía configuración avanzada. En teoría permitiría que el mismo Keycloak emita URLs contra localhost para clientes que vienen de la red Docker interna y contra auth.custodiam.es para los del túnel.
- Pros: resolvería el problema sin necesidad de tres modos.
- Contras: es experimental y la documentación oficial desaconseja explícitamente su uso en producción; requiere configuración adicional no trivial (separar cookies por hostname, sincronizar realm settings); añade una vía de fallo silencioso entre dev y prod (configuración que funciona en dev pero no en prod) que cuesta más depurar que la conmutación explícita de modos.
- Descartado por: inmadurez del feature + opacidad operativa.
D. "Hard down" automático al cambiar de modo¶
Que tunnel-up.sh ejecute docker compose down automáticamente si detecta un stack en otro modo, en vez de abortar con mensaje. Más cómodo: una sola invocación cambia de modo.
- Pros: ergonomía de una sola invocación.
- Contras: bajar el stack mata las sesiones de los desarrolladores que pudieran estar trabajando contra él desde sus máquinas, y silencia el problema (el operador no se entera de que ha cambiado de modo).
- Descartado por: seguridad operativa — el comportamiento explícito (abortar y mostrar el comando exacto) es preferible.
Implicaciones operativas¶
- Cualquier conmutación de modo pasa por
./scripts/down.sh+ el script del modo nuevo. Los volúmenes (postgres_data,ntfy_data,n8n_data) sobreviven, así que no se pierden datos entre cambios. - El PC anfitrión del piloto solo necesita
prod-up.shen operación normal. Los otros dos scripts son herramientas de desarrollo. - Las imágenes consumidas por
tunnelyprodsonghcr.io/custodiam/custodiam-{app,api}:latest, publicadas por el CI en cada merge amain. Son públicas, así que eldocker compose pullque ejecutan los scripts antes delup -dfunciona sindocker login. - Las protecciones de rama de
main(en ambos repos) son las que aseguran que solo entran ahí cambios revisados, lo que hace seguro queprodconsuma:latest. Cuando se introduzca pineo por SHA o semver (:sha-<7>/:vX.Y.Z),prod-up.shpodrá fijar una versión exacta cambiando variables en.env.sops.
Referencias¶
- Keycloak — Configuring hostname — documentación oficial de
KC_HOSTNAMEy su restricción a un único valor. - Docker Compose — Multiple Compose files — patrón
base + override. - Docker Compose — Profiles — activación selectiva de servicios.
- ADR-010 OAuth + PKCE + Keycloak — base del flujo OIDC sobre el que se asienta esta decisión.