Runbook — Payload CMS¶
Deploy¶
Dónde corre: contenedor Docker construido desde el
Dockerfile
del repo y orquestado por
docker-compose.yml
(servicio payload, puerto 3000:3000, restart: unless-stopped, imagen base
node:22.17.0-alpine, salida standalone de Next).
Staging se despliega solo desde el 2026-08-27
Un push a la rama staging dispara el workflow
imagen.yml:
GitHub construye la imagen, la publica en GHCR (:staging-<sha>) y el runner de la VM de
staging la baja, escribe el .env desde el secret cms-staging-env de GCP Secret Manager
(editar el .env por SSH no sobrevive al próximo despliegue) y levanta con compose.
Verificación: https://stg-cms.compulandia.com.py/api/health debe devolver el SHA del
commit. Ver el análisis del pipeline.
Producción sigue siendo manual hasta la fase 3b: se desplegará por tag v*.
El build necesita la base de datos
Payload se conecta a PostgreSQL en tiempo de build. DATABASE_URI,
PAYLOAD_SECRET y NEXT_PUBLIC_SERVER_URL se pasan como build args
además de como entorno de runtime. Si la base no es alcanzable desde el
host que construye, el docker build falla — no es un problema de runtime.
La vista previa necesita STOREFRONT_PREVIEW_URL
Es la URL del storefront a la que apunta el botón de vista previa. Se lee en ejecución,
así que no hace falta pasarla como build arg, pero sin ella generatePreviewPath devuelve
null y el botón no aparece. Tiene que ser una URL que el navegador del editor pueda abrir:
el marco carga en su navegador, no en el contenedor, así que nunca el nombre interno de
Docker. PREVIEW_SECRET tiene que ser idéntica a la del storefront. Ver
Vista previa en el storefront.
Las migraciones las aplica el arranque — al primer acceso
Desde el ADR-0006 (27/08/2026), con
PAYLOAD_MIGRATE_ON_INIT=true (la define el compose) Payload aplica las
migraciones pendientes de src/migrations/ al inicializarse, que
ocurre con la primera petición que lo necesita — no al arrancar el
proceso. Tras levantar: tocar /admin y mirar los logs (Migrating: ...
/ Migrated: ...). /api/health no dispara la inicialización, a
propósito. Producción nunca usa schema push (eso es solo dev).
# En el host de despliegue, con un .env completo junto al docker-compose.yml
git pull
# 1. Construir la imagen nueva (todavía sin reemplazar la que corre)
docker compose build payload
# 2. Levantar. Las migraciones pendientes se aplican solas al primer acceso
# (ADR-0006) — el paso manual de `payload migrate` ya no existe.
docker compose up -d
# 3. Forzar la inicialización de Payload y ver las migraciones en el log
curl -s -o /dev/null http://localhost:3000/admin
docker compose logs payload | grep -E 'Migrating|Migrated|ERROR'
Cómo verificar que el deploy salió bien:
# 1. El contenedor quedó arriba
docker compose ps
# 2. El admin responde
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/admin # 200 o 307
# 3. La API entrega medios con URL de Cloudflare resuelta
curl -s "http://localhost:3000/api/media?depth=0&limit=1" | jq '.docs[0] | {id, cfImageId, url}'
El script
test-media-api.sh
hace ese último chequeo contra un servidor levantado.
Logs¶
| Qué | Dónde | Cómo verlos |
|---|---|---|
| Aplicación (Next + Payload) | stdout del contenedor | docker compose logs -f payload |
| Subidas y borrados de imágenes | stdout, prefijos [Media] y [Cloudflare] |
docker compose logs payload \| grep -E '\[Media\]\|\[Cloudflare\]' |
| Errores de conexión a la base | stdout, al arranque | docker compose logs payload \| head -50 |
| Build | salida de docker compose up --build |
docker compose build --progress=plain payload |
No hay agregación de logs ni archivos en disco: todo sale por stdout del contenedor y lo retiene el driver de logs de Docker.
Rollback¶
Antes de volver atrás: si la versión que se está bajando aplicó una
migración, el rollback de código no revierte el esquema. Revertir la
migración primero (down del par en src/migrations/) o dejar el esquema
adelantado si es compatible hacia atrás. Un rollback de código sobre un esquema
migrado suele funcionar en Payload mientras la migración solo haya agregado
columnas o tablas.
# 1. Identificar el commit previo estable
git log --oneline -10
# 2. Volver el árbol a ese commit
git checkout <commit-estable>
# 3. Reconstruir y levantar
docker compose up --build -d
# 4. Verificar con los tres chequeos de la sección "Deploy"
Fallas frecuentes¶
1. El docker build falla instalando dependencias¶
- Síntoma: el build corta en la etapa
depscon un error de resolución de dependencias o de lockfile desincronizado. - Evidencia: el commit
fea2e5f("fixes de compilacion y construccion de imagen docker") reemplazó el bloque multi-gestor connpm cipornpm install --legacy-peer-depsy ajustó la versión dereact-hook-formjustamente por esto. - Diagnóstico:
docker compose build --progress=plain payloady mirar la etapadeps. Confirmar que.npmrcsigue conlegacy-peer-deps=true. - Resolución: este repo se instala siempre con
npm install --legacy-peer-deps, nunca connpm cini con pnpm. Si se sumó una dependencia, regenerarpackage-lock.jsoncon ese mismo comando y commitearlo. Ver ADR-0003.
2. Las imágenes no cargan o devuelven 404¶
- Síntoma: las miniaturas del admin quedan rotas, o el storefront recibe
URLs de
imagedelivery.netque dan 404. - Evidencia: sección Troubleshooting de
la guía de Cloudflare Images y los
comentarios de
IMAGE_VARIANTSensrc/utilities/cloudflare-images.ts("If variants don't exist, Cloudflare will return 404 errors"). - Diagnóstico:
Si
curl -s "http://localhost:3000/api/media?depth=0&limit=1" | jq '.docs[0] | {cfImageId, url}' curl -s -o /dev/null -w "%{http_code}\n" "<la url devuelta>"urlesnullo falta, faltan credenciales (buscar elconsole.warnde Cloudflare en los logs de arranque). Si la URL existe pero da 404, el problema es la variante. - Resolución: verificar en el dashboard de Cloudflare que existan las siete
variantes con el nombre exacto (
thumbnail,square,small,medium,large,xlarge,og) — la tabla está en la guía de integración. Como paliativo inmediato, ponerUSE_PUBLIC_VARIANT = trueencloudflare-images.tspara que todo caiga a la variantepublic.
3. Una imagen subida no se reconoce como imagen en el admin¶
- Síntoma: el documento de
mediaexiste y tienecfImageId, pero el panel no lo muestra como archivo subido. - Evidencia: existe
scripts/fix-existing-media.tsescrito exactamente para esto ("fix existing media entries that are missing filename"), y el hook de subida marcafilenameyfilesizecomoRequired for admin panel to detect image. - Diagnóstico:
curl -s "http://localhost:3000/api/media?limit=100" \ | jq '.docs[] | select(.cfImageId != null and .filename == null) | .id' - Resolución: correr el script de reparación sobre los documentos afectados:
(el encabezado del script sugiere
npx tsx scripts/fix-existing-media.tsts-node; usar el runner de TypeScript que esté disponible en el entorno).
Otras trampas conocidas, sin incidente registrado todavía
- Un storefront nuevo cuyo origen no esté en el array
corsdesrc/payload.config.tsrecibe errores de CORS aunque el CMS esté sano. - Nunca definir
PAYLOAD_MIGRATE_ON_INIT=truefuera de un servidor: con esa variable Payload intenta migrar la base que tenga a mano al inicializar, y contra una base de dev (gestionada por push) se cuelga esperando un prompt interactivo (ADR-0006).
4. La vista previa muestra la versión publicada, no el borrador¶
El panel abre y se ve la tienda, pero con el contenido publicado. No hay ningún error a la vista.
Causas, en orden de probabilidad:
- El storefront no tiene credenciales de servicio. El acceso
authenticatedOrPublishedoculta los borradores a quien no esté autenticado, así que Payload devuelve la versión publicada en silencio. Buscar[payload] Error en loginen el log del storefront. - La contraseña del usuario de servicio tiene
#y no está entre comillas. dotenv la trunca ahí y el login falla con 401, aunque probándola a mano funcione. - En local, colisión de cookies. Si el CMS y el storefront corren en la misma IP con puertos distintos comparten el frasco de cookies, y el CMS borra la del modo borrador porque no la reconoce. Hay que darles nombres de host distintos que compartan dominio padre.
Si además el panel no se refresca al guardar, el origen del CMS no coincide exactamente con el
NEXT_PUBLIC_PAYLOAD_URL del storefront; la comparación es literal.