Runbook¶
Variables de entorno¶
El único chequeo automático es
check-env-variables.js,
que corre al inicio de next.config.js y aborta el build si falta
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY. Todas las demás variables faltan en
silencio y fallan en runtime.
| Variable | Lado | Sin ella |
|---|---|---|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY |
ambos | El build aborta con error explícito, y el contenedor no arranca |
MEDUSA_BACKEND_URL |
server, runtime | El middleware lanza Error fetching regions: no carga ninguna página |
NEXT_PUBLIC_MEDUSA_BASE_URL |
navegador, build | El SDK y todo el código de cliente caen a http://localhost:9000 |
NEXT_PUBLIC_DEFAULT_REGION |
ambos | Se asume us |
NEXT_PUBLIC_BASE_URL |
ambos | URLs absolutas mal armadas |
PAYLOAD_PUBLIC_URL, PAYLOAD_USER_EMAIL, PAYLOAD_USER_PASSWORD |
server | Home sin contenido, menú vacío, /api/credit-form en 500. Sin las credenciales, además, la vista previa muestra la versión publicada en lugar del borrador sin dar ningún error |
NEXT_PUBLIC_ALGOLIA_APP_ID, NEXT_PUBLIC_ALGOLIA_SEARCH_KEY, NEXT_PUBLIC_ALGOLIA_PRODUCT_INDEX, NEXT_PUBLIC_ALGOLIA_SUGGESTIONS_INDEX |
browser | Búsqueda y grillas de la home sin resultados |
ALGOLIA_ADMIN_API_KEY |
server | Fallan los scripts de indexación (scripts/populate-suggestions.js) |
NEXT_PUBLIC_STRIPE_KEY |
browser | No se puede pagar con tarjeta |
NEXT_PUBLIC_GOOGLE_CLIENT_ID |
browser | Sin login con Google |
NEXT_PUBLIC_RECAPTCHA_SITE_KEY |
navegador, build | Sin ella el proveedor de reCAPTCHA no se monta y el botón de crédito falla con "GoogleReCaptcha Context has not yet been implemented". Se inlinea en el build: cargarla en el secret no alcanza si no llega como build-arg. El dominio del ambiente debe estar autorizado en la consola de Google |
RECAPTCHA_SECRET_KEY |
server, runtime | La valida el backend. Ojo con el par: si el backend la tiene y el front no manda token, /store/credit-request responde 400; si el backend no la tiene, omite la verificación |
NEXT_PUBLIC_BANCARD_QR_SIMULATOR, NEXT_PUBLIC_BANCARD_VPOS_SIMULATOR |
browser (solo desarrollo) | Sin ellas no aparecen los botones del simulador de Bancard; deben ir junto con BANCARD_QR_SIMULATOR / BANCARD_VPOS_SIMULATOR en el backend |
GEOZONES_REVALIDATE_SECONDS |
server | La lista de ciudades del checkout se guarda 300 s por defecto; 0 desactiva el caché |
NEXT_PUBLIC_BANCARD_VPOS_ORIGIN |
browser | Sin pago con tarjeta: el origen de Bancard falta en la CSP y no se elige el script (:8888 en staging) |
NEXT_DEV_ORIGINS |
server (solo desarrollo) | Hosts además de localhost por los que se entra al dev server (ej. 192.168.0.220). Sin ella, Next 16 bloquea el HMR y rechaza las acciones de servidor al entrar por IP o dominio: la tienda carga pero ningún botón hace nada |
REVALIDATE_SECRET |
server | Revalidación bajo demanda sin proteger |
NEXT_PUBLIC_PAYLOAD_URL |
navegador, build | Vista previa del CMS a medias: no se refresca sola al guardar, y frame-ancestors queda en 'none', así que con CSP_ENFORCE=true el panel queda en blanco. Se inlinea en el build: ponerla solo en el .env del host no tiene efecto |
PREVIEW_SECRET |
server, runtime | /api/draft responde 501 y no hay vista previa de borradores. Tiene que ser idéntica al PREVIEW_SECRET del CMS |
CSP_ENFORCE |
server, runtime | Sin ella la política de seguridad de contenido va en modo reporte: el navegador avisa en consola pero no bloquea. Con true bloquea de verdad. Ver más abajo |
COTIZACION_PUNTOS_AJUSTE |
server, runtime | Se asume 50, el mismo ajuste que el integrador, sobre la tasa de venta de Cambios Chaco |
Un valor con # va entre comillas
dotenv —lo que usan Next y Payload para leer los .env— interpreta # como inicio de
comentario y trunca el valor. source de bash no lo hace, así que la prueba manual pasa
y la aplicación falla. Pasó con PAYLOAD_USER_PASSWORD: 17 caracteres por bash, 15 por
dotenv, y el CMS respondía 401. Escribir CLAVE="valor#con#almohadilla".
Las NEXT_PUBLIC_* son públicas
Next.js las inlinea en el bundle del navegador. ALGOLIA_ADMIN_API_KEY,
RECAPTCHA_SECRET_KEY, PAYLOAD_USER_PASSWORD y REVALIDATE_SECRET
nunca deben renombrarse con ese prefijo.
Deploy¶
No hay pipeline de deploy en este repositorio
El único workflow de GitHub Actions es
.github/workflows/notificar-portal.yml (avisa al portal de documentación).
.github/ISSUE_TEMPLATE/ viene heredado del starter de Medusa. El deploy se
hace a mano, con Docker. Quién lo ejecuta y sobre qué host no está
registrado en el repo — ver Pendiente al final.
El artefacto es la imagen definida en
Dockerfile:
build multi-stage sobre node:24-alpine que instala con npm ci, corre
npm run build con output: "standalone" y en la segunda etapa copia el
servidor autocontenido (.next/standalone) más .next/static, public/ y
check-env-variables.js. Expone el puerto 8000 y arranca con
node server.js, previa validación de entorno. Pesa unos 235 MB — ver
ADR-0005.
# En el host, sobre la rama a desplegar
git pull
docker compose build --no-cache # NEXT_PUBLIC_MEDUSA_BACKEND_URL se pasa como ARG
docker compose up -d
docker-compose.yml levanta el servicio storefront como contenedor
medusa_storefront, publica 8000:8000, usa restart: unless-stopped y lee
env_file: .env.local — la misma fuente que consume el build.
PORT se fija en el compose a propósito
.env.local define PORT=8001 para el dev local. El server.js del
standalone lee PORT y se ataría a 8001 dentro del contenedor, dejándolo
inalcanzable detrás del mapeo 8000:8000. El bloque environment del
compose lo fija en 8000 y gana sobre env_file.
Las NEXT_PUBLIC_* se congelan en el build
Se inlinean durante npm run build, dentro de la imagen. Cambiar una en el
.env del host y reiniciar el contenedor no tiene efecto: hay que
reconstruir la imagen.
Política de seguridad de contenido (CSP)¶
La política se arma en src/proxy.ts con un nonce por petición y sale en una de dos cabeceras,
según CSP_ENFORCE:
CSP_ENFORCE |
Cabecera | Qué hace el navegador |
|---|---|---|
| sin definir o cualquier otro valor | Content-Security-Policy-Report-Only |
Avisa en consola lo que bloquearía. Modo por defecto |
true |
Content-Security-Policy |
Bloquea lo que no está en la política |
Es una variable de runtime: se cambia en el .env del ambiente y se reinicia el contenedor.
No hace falta reconstruir la imagen ni desplegar, y volver atrás es el mismo gesto al revés. Por
eso conviene activarla en staging antes que en producción.
# Comprobar en qué modo está un ambiente
curl -sD - -o /dev/null https://stg-store.compulandia.com.py/py | grep -i content-security-policy
Cómo pasar un ambiente a bloqueante
- Con el ambiente en modo reporte, recorrer con la consola del navegador abierta: home, búsqueda, ficha de producto, carrito, checkout completo (incluido el pago con tarjeta y el QR) y la página del pedido.
- Anotar cada aviso de la política. Cada uno es un origen que falta o un script sin nonce.
- Corregir: si es un origen legítimo, agregarlo a la directiva que corresponda en
proxy.ts; si es un script inyectado sin nonce, resolverlo donde se inyecta. - Con la consola limpia, poner
CSP_ENFORCE=truey reiniciar. - Recorrer lo mismo otra vez. Ante cualquier rotura, sacar la variable y reiniciar: vuelve al modo reporte al instante.
Cloudflare Zaraz
Zaraz inyecta su script después de que la respuesta sale de Next, así que esa etiqueta no
lleva nuestro nonce. Como la política usa 'strict-dynamic', agregar el dominio no
alcanza: un script sin nonce se bloquea igual. Hay que verificarlo en staging antes de
activar el bloqueo en producción, y resolverlo del lado de Cloudflare.
Señal de salud¶
GET /api/health responde por la aplicación, no por sus dependencias: no consulta Medusa,
Payload ni Algolia, y el matcher del middleware excluye /api, así que tampoco dispara el fetch de
regiones que tumba el resto del sitio cuando Medusa no responde.
{ "status": "ok", "commit": "abe849f", "version": "v1.3.0",
"uptime_s": 42, "timestamp": "2026-08-27T13:28:15.437Z" }
| Campo | Para qué |
|---|---|
commit y version |
Confirman qué imagen está corriendo. Los inyecta el Dockerfile como ENV |
uptime_s |
Un valor bajo confirma que el contenedor se recreó y no que quedó el viejo arriba |
Son dos señales distintas y conviene no confundirlas: /api/health en 200 con /py en 500
significa que el front está sano y que el backend no responde.
En healthchecks usar 127.0.0.1, nunca localhost
Dentro del contenedor localhost resuelve a ::1 (IPv6) y el servidor escucha en 0.0.0.0
(IPv4). busybox wget no reintenta con la otra familia: con localhost el healthcheck falla
siempre, con la aplicación funcionando perfecto.
Logs¶
- Aplicación:
docker compose logs -f storefront(odocker logs -f medusa_storefront). Es stdout de Next.js; no hay archivo de log ni agregador configurado. next.config.jstienelogging.fetches.fullUrl = true: el log del servidor muestra la URL completa de cada fetch a Medusa y a Payload. Es la forma más rápida de ver si el backend responde y con qué URL se lo está llamando.- Los proxies a Payload loguean con prefijo (
🛠️ Next.js API Header,📡 API Route llamando a Payload:) ensrc/api/header/route.ts. - No hay Sentry ni monitoreo de errores. Un error de runtime en producción solo se ve en el stdout del contenedor.
Rollback¶
No hay registro de imágenes ni tags: la imagen se construye en el host desde el código. El rollback es volver al commit anterior y reconstruir.
git log --oneline -10 # identificar el commit bueno
git checkout <commit-anterior>
docker compose build --no-cache && docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/py # espera 200
Tarda lo que tarde el build completo (npm ci + next build), del orden de
minutos. Mejora pendiente: taguear imágenes por commit para poder volver sin
recompilar.
Fallas frecuentes¶
1. Cae todo el sitio: Error fetching regions — nombre de la variable del backend¶
Síntoma. Ninguna página carga; el log muestra el error del middleware
pidiendo MEDUSA_BACKEND_URL.
Evidencia. Cinco commits consecutivos con el mismo mensaje —39f32b3,
15b16d1, 786d3de, 4f50f40, 0ef751a, "cambio de nombre de variable de
entorno del backend"— más un sexto (21111cc, "cambio de puerto de
produccion"). Llegaron a convivir tres nombres para la misma URL:
| Nombre | Dónde se leía |
|---|---|
MEDUSA_BACKEND_URL |
src/middleware.ts — el único que el middleware acepta |
NEXT_PUBLIC_MEDUSA_BASE_URL |
src/lib/config.ts (primera opción del SDK) |
NEXT_PUBLIC_MEDUSA_BACKEND_URL |
Botón de login con Google. Eliminada el 2026-08-27 |
El propio mensaje de error del middleware advierte que la variable "ya no se
llama NEXT_PUBLIC_MEDUSA_BACKEND_URL".
Solución. Quedan dos nombres, no tres: NEXT_PUBLIC_MEDUSA_BACKEND_URL
se eliminó el 2026-08-27 (tenía un solo consumidor, el botón de login con
Google). Definir los dos restantes apuntando a la misma URL.
Existen por razones distintas y no son intercambiables:
| Variable | Dónde se lee | Para cambiarla |
|---|---|---|
MEDUSA_BACKEND_URL |
src/middleware.ts, en runtime |
Reiniciar el contenedor |
NEXT_PUBLIC_MEDUSA_BASE_URL |
SDK y código de cliente, inlineada en el build | Reconstruir la imagen |
MEDUSA_BACKEND_URL no sirve como fallback de la pública
src/lib/config.ts y use-bancard-payment-sse.ts la usaban así. En el
navegador vale undefined —solo se inlinean las NEXT_PUBLIC_*—, de modo
que el fallback nunca cubría el caso que aparentaba cubrir: si faltaba la
pública, el cliente caía directo a localhost:9000. Se eliminaron los dos.
2. La búsqueda no devuelve nada: la key de Algolia que no existe¶
Síntoma. El buscador no responde o falla al inicializar el cliente.
Evidencia. src/lib/search-client.ts
lee process.env.NEXT_PUBLIC_ALGOLIA_API_KEY, variable que no está definida en
el entorno (el .env.local define NEXT_PUBLIC_ALGOLIA_SEARCH_KEY). Está
registrado como problema CRÍTICO n.º 1 en el
plan de resolución de problemas,
y verificado como vigente en develop al 2026-08-19.
Solución. Corregir el código a NEXT_PUBLIC_ALGOLIA_SEARCH_KEY (arreglo de
fondo) o, como parche de entorno, definir también NEXT_PUBLIC_ALGOLIA_API_KEY
con el mismo valor y reconstruir la imagen.
3. La home devuelve 404 en regiones nuevas¶
Síntoma. Se da de alta una región en Medusa Admin, el catálogo funciona en
/{cc}/store, pero /{cc} responde 404.
Evidencia. src/app/[countryCode]/(main)/page.tsx
tiene la lista fija const ALLOWED = ["py", "ar", "br"] y llama a notFound()
para cualquier otro código. Registrado como problema n.º 3 del mismo plan y
verificado como vigente al 2026-08-19.
Solución. Agregar el código de país a ALLOWED, o reemplazar la lista fija
por una validación contra las regiones de Medusa.
4. Home vacía: Payload no responde¶
Síntoma. La página muestra "No se encontró la homepage en Payload" o el menú de categorías aparece vacío, con el resto del sitio funcionando.
Evidencia. El fallback está escrito en la propia home; src/lib/payload.ts
avisa por consola cuando faltan PAYLOAD_PUBLIC_URL, PAYLOAD_USER_EMAIL o
PAYLOAD_USER_PASSWORD, y devuelve null en vez de romper.
Solución. Verificar que Payload esté arriba y que exista el slug homepage
publicado. El token JWT se cachea en memoria del proceso: si se rotaron las
credenciales, reiniciar el contenedor.
5. Todas las páginas en 500: el WAF de Cloudflare desafía al middleware¶
Síntoma. El contenedor arranca y queda healthy, /api/health responde 200, pero cualquier
página devuelve 500. En el log del contenedor:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
at JSON.parse (<anonymous>)
at async (.next/server/src/middleware.js…)
Evidencia. Ocurrió en el primer despliegue a producción (28/08/2026). El log del edge de
Cloudflare para medusa.compulandia.com.py:
| Campo | Valor |
|---|---|
ClientRequestPath |
/store/regions |
ClientRequestUserAgent |
Next.js Middleware |
ClientIP |
IP de egreso de la VM de GCP |
EdgeResponseStatus |
403 |
SecurityAction |
managedChallenge |
SecurityRuleDescription |
manage definite bots |
OriginResponseStatus |
0 — nunca llegó a Medusa |
Causa. El middleware hace fetch a /store/regions desde el servidor, con User-Agent
Next.js Middleware y desde una IP de datacenter. El WAF gestionado lo clasifica como bot y le
responde un managed challenge: una página HTML. Un fetch no puede resolver un desafío, así que
recibe HTML donde esperaba JSON.
Por eso staging no fallaba: sale desde la red local, con otra reputación de IP.
Solución. Custom Rule con acción Skip en Security → WAF → Custom rules, por encima de las reglas gestionadas:
(ip.src eq <IP-de-egreso-de-la-VM> and http.host eq "medusa.compulandia.com.py")
Marcar Managed Rules y, si está activo, Super Bot Fight Mode. La IP de egreso es fija, así que la regla no debilita nada para el resto del tráfico.
Pendiente: /store/* también lo consume el navegador
El SDK corre además del lado del cliente —NEXT_PUBLIC_MEDUSA_BASE_URL va inlineada en el
bundle—, así que el carrito, el checkout y la ficha de producto llaman a esa API desde el
navegador del usuario. Si el WAF desafía esa ruta con criterio agresivo, rompe compras reales
sin dejar rastro en ningún log del storefront.
Revisar en los eventos de seguridad si hay managedChallenge sobre /store/* con
User-Agents de navegador. Si aparecen, la excepción debe ser por ruta y no solo por IP:
(http.host eq "medusa.compulandia.com.py" and starts_with(http.request.uri.path, "/store/"))
/store/* es una API pública cuya autenticación es la publishable key. Queda para el
endurecimiento previo a la publicación del sitio.
Pendiente¶
- Excepción del WAF para
/store/*desde el navegador: ver la falla 5. Queda para el endurecimiento previo a la publicación. REVALIDATE_SECRETes configuración inerte: ningún archivo del repositorio la lee, y el valor essupersecret, el placeholder del starter de Medusa, idéntico en los tres entornos. Si se implementa la revalidación bajo demanda, generar uno aleatorio por entorno (openssl rand -hex 32); si no, sacarla de los.env.- Host y procedimiento real de deploy: no hay evidencia en el repo de dónde corre el contenedor ni quién lo despliega. Completar con el dato del equipo.
- Backups y restauración: no aplican a este repo (no tiene estado propio); dependen del backend Medusa.
- Sin post-mortems registrados: las fallas de arriba salen del historial de
git y del código, no de incidentes documentados. Cuando ocurra uno, va a
docs/incidentes/AAAA-MM-tema.md.