Ir al contenido

Hosting del portal — contrato con el Caddy del servidor

El portal no tiene contenedor propio: ni Dockerfile, ni docker-compose.yml, ni nginx. Los sitios son estáticos y el Caddy de la máquina los sirve directamente con file_server.

La consecuencia incómoda es que el ruteo del portal no vive en este repo. El workflow deja los ficheros en su sitio; el dominio, las rutas y —cuando llegue— la autenticación son decisiones del Caddyfile del servidor. Este documento es la referencia de lo que esos Caddyfiles tienen que decir. Hay uno por entorno y no se parecen.

Staging Producción
Dominio staging.prismgrp.com, compartido por todas las apps wiki.prismgrp.com, propio
Ruta del portal /wiki raíz
base de Astro /wiki/internal, /wiki/external /internal, /external
Caddy handle_path /wiki/*recorta /wiki sin recorte
Máquina stag-debian-x86-64 prod-debian-x86-64
SITE_ROOT /apps/volumes/wiki /apps/volumes/wiki — la misma ruta

La ruta sigue la convención de Prism, /apps/volumes/<app>, con el nombre corto de la app — igual que /apps/volumes/eprcrm/cache en el CRM, /apps/volumes/coreidentity/logs o /apps/volumes/vendors/tokens. (Los logs van aparte, en /apps/apps_logs/<app>; este portal no genera ninguno: no tiene proceso propio.)

SITE_ROOT es idéntico en los dos entornos y no lleva sufijo, porque son máquinas distintas y cada host tiene su propio /apps/volumes/wiki. Un wiki-stag solo haría falta si staging y producción compartieran máquina.

El reparto es el mismo que ya usan las demás apps en staging: el artefacto se construye con el prefijo en sus URLs (si no, el navegador pediría /internal/_astro/…, que cae fuera del handle_path /wiki/* y da 404) y el árbol de ficheros va sin él, porque Caddy ya lo ha recortado cuando llega a root.

<SITE_ROOT>/
├── releases/
│ ├── <sha-anterior>/
│ │ ├── internal/ # dist del sitio interno
│ │ └── external/ # dist del help desk público
│ └── <sha-actual>/
│ ├── internal/
│ └── external/
├── current -> releases/<sha-actual> # symlink, se mueve con rename(2)
└── .release-anterior # a qué apuntaba current antes (para revertir)

SITE_ROOT es /apps/volumes/wiki por defecto en los dos entornos. Se puede cambiar por entorno con las variables de repo SITE_ROOT_PROD y SITE_ROOT_STAG.

Se conservan las 5 releases más recientes; el resto se poda al final de cada despliegue, saltándose siempre la que current apunta.

Bloque de sitio nuevo, igual que cualquier otra app con dominio propio.

wiki.prismgrp.com {
# La raíz es el SYMLINK, no la release. Así el cambio de versión no requiere
# recargar Caddy: la siguiente petición ya resuelve `current` al árbol nuevo.
root * /apps/volumes/wiki/current
# --- Sitio interno ------------------------------------------------------------
# HOY SIN AUTENTICACIÓN — ver "Autenticación (en stand-by)" más abajo.
# Este bloque es el punto donde se añadirá la validación cuando toque; queda
# separado del externo justo para eso.
@internal path /internal /internal/*
handle @internal {
# MIENTRAS NO HAYA AUTH. El sitio interno emite un sitemap con TODAS sus
# páginas y URLs absolutas, así que sin esto el modelo operativo de Prism
# es indexable por buscadores: no solo "accesible si conoces la URL".
# Cubre también el propio sitemap-*.xml, por estar bajo /internal.
#
# Al activar la autenticación esta cabecera se puede quitar: lo que no se
# puede descargar no se indexa.
header X-Robots-Tag "noindex, nofollow, noarchive"
file_server
}
# --- Help desk externo: público ------------------------------------------------
handle /external* {
file_server
}
# La raíz del dominio no tiene portada propia todavía.
handle / {
redir /internal/ 302
}
# Los dos sitios son estáticos con rutas reales (Astro genera un directorio por
# página), NO SPAs: una ruta que no existe tiene que dar 404, no servir el index.
handle_errors {
@internal_err path /internal/*
handle @internal_err {
rewrite * /internal/404.html
file_server
}
handle {
rewrite * /external/404.html
file_server
}
}
}

Caddyfile de STAGING — dominio compartido, bajo /wiki

Sección titulada «Caddyfile de STAGING — dominio compartido, bajo /wiki»

Se añade dentro del bloque staging.prismgrp.com que ya existe, junto a las demás apps. Sigue el mismo par redir + handle_path que /eprcrm y /customerhub.

staging.prismgrp.com {
# ... el resto de las apps ...
# ==========================================
# N. Portal de documentación (/wiki)
# ==========================================
# Sin la barra, `/wiki` resolvería relativo al padre y rompería los enlaces.
redir /wiki /wiki/
# `handle_path` RECORTA /wiki antes de resolver el fichero. Por eso el árbol en
# disco es current/internal/... y no current/wiki/internal/...
#
# A diferencia de las otras apps de este dominio, aquí no hay `reverse_proxy`:
# el portal no tiene contenedor, Caddy sirve los ficheros. Eso exige que
# /apps/volumes/wiki esté bind-mounted en ESTE contenedor de Caddy.
handle_path /wiki/* {
root * /apps/volumes/wiki/current
# Sitio interno. HOY SIN AUTENTICACIÓN (ver más abajo); el bloque existe
# separado para tener dónde añadirla.
@internal path /internal /internal/*
handle @internal {
# Mientras no haya auth: que no lo indexe nadie. Ver la nota del
# Caddyfile de producción.
header X-Robots-Tag "noindex, nofollow, noarchive"
file_server
}
handle /external* {
file_server
}
# Sin matcher: es el ÚLTIMO recurso del bloque, y Caddy lo ordena al final
# por ser el menos específico. Cubre `/wiki/` y cualquier ruta bajo /wiki
# que no reclame ninguno de los dos sitios (`/wiki/favicon.svg`, por
# ejemplo).
#
# Tiene que existir. Si un `handle_path` casa pero ningún handler de dentro
# responde, la petición NO termina ahí: sigue por el resto del bloque de
# sitio y acaba en el catch-all del dominio, o sea en «Bienvenido al portal
# central de Prism Group» — un 200 que parece que el portal no está
# desplegado cuando en realidad sí lo está.
handle {
redir /wiki/internal/ 302
}
}
}

Cuando /wiki responde «Bienvenido al portal central de Prism Group»

Sección titulada «Cuando /wiki responde «Bienvenido al portal central de Prism Group»»

Ese texto es el catch-all de staging.prismgrp.com: lo que el dominio responde a toda ruta que ningún bloque reclama. Verlo en /wiki significa una sola cosa — el bloque del portal no está en la configuración que Caddy tiene cargada.

El diagnóstico son dos curl, y el que manda es el del redir:

Ventana de terminal
curl -sI https://staging.prismgrp.com/wiki # tiene que dar 302 -> /wiki/
curl -sI https://staging.prismgrp.com/eprcrm # el de al lado, para comparar: 302 -> /eprcrm/

redir se ordena antes que handle y handle_path, y Caddy ordena por directiva, no por el orden del fichero. Así que si /wiki no da 302, ningún error de anidamiento ni de orden lo explica: la línea redir /wiki /wiki/ no está en la config en ejecución. Editar el Caddyfile no basta, y las causas habituales son estas cuatro:

Ventana de terminal
# 1. ¿está el bloque en el fichero que LEE EL CONTENEDOR? (editar el del host no sirve
# si no es el que está montado)
docker compose exec caddy grep -n wiki /etc/caddy/Caddyfile
# 2. ¿se recargó Caddy? Un fichero editado y sin recargar no cambia nada.
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
# 3. ¿existe el bind mount DENTRO del contenedor? Sin él, root apunta al vacío
# (esto da 404, no el catch-all — pero conviene descartarlo de una vez)
docker compose exec caddy ls -l /apps/volumes/wiki/current/
# 4. ¿llegaron los ficheros? En el host de staging:
ls -l /apps/volumes/wiki/current/internal/index.html

El caso 2 es el más frecuente y el más silencioso: docker compose up -d no recarga la configuración si el contenedor ya estaba levantado y solo cambió un fichero montado.

root no es un permiso: es el directorio base desde el que file_server resuelve los ficheros, como el root de nginx. No concede privilegios ni cambia el usuario con el que corre Caddy — lo que hace es limitar lo servible a ese subárbol.

Que el root sea current y no <SITE_ROOT> no es casual: así el árbol servido contiene solo internal/ y external/. El .release-anterior y el propio releases/ (con todas las versiones anteriores) quedan fuera de lo alcanzable por HTTP.

Preocupación Estado
Escapar del root con ../ Caddy normaliza la ruta antes de unirla al root
Listado de directorios file_server no lista sin browse, que no está puesto
Escritura desde Caddy El bind mount es :ro
Releases antiguas o metadatos Fuera del root, por apuntar a current y no al padre
Symlinks que apunten fuera Caddy los sigue. El árbol lo genera nuestro tar desde el dist, así que está controlado: no meter nada a mano en releases/

Decidido posponerla: hoy los dos sitios son públicos, incluido /internal.

Cuando toque implementarla, hay que decidir dónde valida, porque para un sitio estático no son equivalentes:

  • En Caddy, antes de servir (forward_auth): Caddy manda cada petición a un endpoint validador y solo entrega el fichero si responde 2xx. Es control de acceso real.
  • En la propia app, desde el navegador: la página se descarga primero y su JS decide después si pinta o redirige. Para un sitio estático esto no protege el contenido: cualquiera puede pedir el .html o el índice de Pagefind directamente y leerlos, sin pasar por el JS. Sirve para la experiencia de usuario, no para la confidencialidad.

Si el corpus interno tiene que quedar de verdad restringido, la validación va en Caddy. El punto de inserción son los bloques handle @internal de los dos Caddyfiles de arriba.

El workflow ya está preparado: la variable de repo EXPECT_INTERNAL_AUTH controla qué espera el step de humo.

EXPECT_INTERNAL_AUTH Comportamiento del humo
sin declarar / false Informa de que /internal es público y avisa. No bloquea.
true Exige 401/403 sin cookie y falla el despliegue si responde 200.

O sea: al activar la auth se pone esa variable a true y la red de seguridad se enciende sola. Hasta entonces no hay ninguna comprobación que pueda pasar, así que no hay ninguna que mienta.

  1. Caddy tiene que ver los ficheros. Es el precio de no tener contenedor: Caddy corre en Docker, así que SITE_ROOT debe estar bind-mounted en el contenedor de Caddy, en la misma ruta, en las dos máquinas. Montarlo en otra ruta rompe el symlink: su destino es relativo (releases/<sha>) pero root es absoluto, y las dos rutas tienen que coincidir.

    # en el compose de Caddy — la misma linea en las dos maquinas
    volumes:
    - /apps/volumes/wiki:/apps/volumes/wiki:ro

    :ro es suficiente y es lo correcto: Caddy solo lee.

  2. El runner tiene que poder escribir en SITE_ROOT. El preflight del workflow lo comprueba de verdad (crea un fichero de prueba) en vez de mirar si existen binarios, porque el fallo real que se quiere atrapar es un runner en modo docker://: escribiría en la capa efímera de un contenedor y el despliegue saldría en verde sin cambiar nada.

  3. DNS del dominio apuntando a la máquina. En staging no hace falta DNS nuevo: el portal cuelga de staging.prismgrp.com, que ya existe.

Las que gobiernan el ruteo. Las dos primeras tienen que ser coherentes con el Caddyfile o el portal se publica roto:

Variable Producción Staging
SITE_PATH_PREFIX_PROD / _STAG vacía (dominio propio) /wiki (default si no se declara)
SITE_URL_PROD / _STAG https://wiki.prismgrp.com https://staging.prismgrp.com/wiki
SITE_ROOT_PROD / _STAG /apps/volumes/wiki (default en ambos) /apps/volumes/wiki (default en ambos)
SMOKE_URL_PROD / _STAG https://wiki.prismgrp.com https://staging.prismgrp.com/wiki
EXPECT_INTERNAL_AUTH sin declarar mientras la auth esté en stand-by ídem

SMOKE_URL lleva el prefijo incluido: el step de humo le concatena /external/ y /internal/ —las portadas de cada portal, que desde que el sitio publica un solo idioma viven en la raíz y no bajo /es/—. Las dos tienen default (https://staging.prismgrp.com/wiki y https://wiki.prismgrp.com), así que el humo nunca se salta: cuando dependía de que la variable estuviera declarada, no estarlo dejaba el despliegue en verde sin que nadie comprobara que Caddy sirviera nada.

No hace falta el CI. En la máquina:

Ventana de terminal
cd /apps/volumes/wiki
cat .release-anterior # a qué apuntaba antes
ln -sfnT releases/<sha-anterior> current.tmp && mv -T current.tmp current

El mv -T sobre el symlink es un rename(2), o sea atómico: ninguna petición ve un estado intermedio. Es la misma operación que hace el workflow, y por eso revertir cuesta lo mismo que desplegar.

Hoy los dos sitios comparten dominio y se separan por ruta, lo que se aparta del “un portal = un proyecto, con su propio dominio” de architecture.md §8.3. Volver a un dominio por portal son dos cambios:

  1. base: '/' en el astro.config.mjs del portal que se separa.
  2. Su propio bloque de sitio en el Caddyfile, con root apuntando a <SITE_ROOT>/current/<portal>.

El workflow no cambia: seguiría publicando el mismo árbol de releases.