Saltar a contenido

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 deps con 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 con npm ci por npm install --legacy-peer-deps y ajustó la versión de react-hook-form justamente por esto.
  • Diagnóstico: docker compose build --progress=plain payload y mirar la etapa deps. Confirmar que .npmrc sigue con legacy-peer-deps=true.
  • Resolución: este repo se instala siempre con npm install --legacy-peer-deps, nunca con npm ci ni con pnpm. Si se sumó una dependencia, regenerar package-lock.json con 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.net que dan 404.
  • Evidencia: sección Troubleshooting de la guía de Cloudflare Images y los comentarios de IMAGE_VARIANTS en src/utilities/cloudflare-images.ts ("If variants don't exist, Cloudflare will return 404 errors").
  • Diagnóstico:
    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>"
    
    Si url es null o falta, faltan credenciales (buscar el console.warn de 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, poner USE_PUBLIC_VARIANT = true en cloudflare-images.ts para que todo caiga a la variante public.

3. Una imagen subida no se reconoce como imagen en el admin

  • Síntoma: el documento de media existe y tiene cfImageId, pero el panel no lo muestra como archivo subido.
  • Evidencia: existe scripts/fix-existing-media.ts escrito exactamente para esto ("fix existing media entries that are missing filename"), y el hook de subida marca filename y filesize como Required 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:
    npx tsx scripts/fix-existing-media.ts
    
    (el encabezado del script sugiere ts-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 cors de src/payload.config.ts recibe errores de CORS aunque el CMS esté sano.
  • Nunca definir PAYLOAD_MIGRATE_ON_INIT=true fuera 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:

  1. El storefront no tiene credenciales de servicio. El acceso authenticatedOrPublished oculta los borradores a quien no esté autenticado, así que Payload devuelve la versión publicada en silencio. Buscar [payload] Error en login en el log del storefront.
  2. 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.
  3. 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.