# Despliegue Sitio de FSPM con micro CRM. Un solo contenedor: Next.js en modo `standalone` y SQLite en un volumen. No hay base de datos externa. ## Cómo está desplegado hoy **En línea:** https://fspm-demo.urieljareth.org No es un recurso de Coolify, y eso es deliberado. La API de esta instancia no expone creación de aplicaciones —las tres rutas `/applications/*` responden 404 con cuerpo vacío, y `/openapi.yaml` devuelve el HTML del panel—, y al crear un servicio por `POST /services` la tarea de despliegue quedó colgada sin escribir nada en `/data/coolify/services/`. La interfaz web reportaba "No available server" aunque el servidor está sano (verificado por API: alcanzable, usable, Traefik corriendo, red `coolify` presente, dominio comodín configurado). Así que el contenedor corre directamente en el Docker del LXC 102, en la red `coolify`, con las etiquetas de Traefik que replican el patrón de las aplicaciones que ya sirven con TLS en ese servidor. Lleva `coolify.managed=false` para que nadie lo confunda con un recurso del panel. - Imagen: `fspm-web:demo`, construida **dentro del LXC 102** desde el repo público de Gitea. No hay registro de contenedores en juego. - Contenedor: `fspm-web`, con `--restart unless-stopped`, así que sobrevive a un reinicio del LXC. - Volumen: `fspm-datos` montado en `/app/data`. - Certificado: lo emite el Traefik que ya estaba, con `letsencrypt`. **La consecuencia operativa importante:** un push a Gitea **no** redespliega. Ver "Publicar una versión nueva" abajo. ### Publicar una versión nueva Tres comandos desde el host Proxmox (`ssh -i keys/proxmox_ed25519 root@192.168.0.200`): ```bash # 1. traer el código nuevo pct exec 102 -- git -C /opt/fspm-src pull # 2. reconstruir la imagen (los NEXT_PUBLIC_* se hornean aquí) pct exec 102 -- docker build -t fspm-web:demo --build-arg NEXT_PUBLIC_SITE_URL=https://fspm-demo.urieljareth.org --build-arg NEXT_PUBLIC_WHATSAPP=525540348395 /opt/fspm-src # 3. recrear el contenedor — el volumen NO se toca, los leads se conservan pct exec 102 -- docker rm -f fspm-web # y volver a correr `deploy/fspm-live.sh` (lleva el AUTH_SECRET sustituido) ``` El script `deploy/fspm-live.sh` del repo tiene el `docker run` completo con las etiquetas de Traefik; el `AUTH_SECRET` va como marcador y se sustituye al ejecutarlo. ### Si algún día se puede usar Coolify Con el correo del panel web se puede crear la aplicación como **Public Repository → Dockerfile** apuntando al repo de Gitea, y entonces sí recupera el redespliegue automático por push. La especificación exacta de campos está en la sección "Coolify" de abajo. En ese momento hay que **eliminar el contenedor manual** para que no compitan dos backends por el mismo dominio en Traefik. ## Variables de entorno | Variable | Requerida | Qué pasa si falta | |---|---|---| | `DATABASE_URL` | Sí | Prisma no arranca. En el contenedor ya viene fijada a `file:/app/data/fspm.db`; **no la cambies** salvo que muevas el volumen. | | `AUTH_SECRET` | Sí | El arranque falla con un error explícito. Firma las cookies de sesión del panel. | | `NEXT_PUBLIC_SITE_URL` | Sí, **en construcción** | Las URL canónicas, el sitemap y Open Graph apuntan al dominio equivocado. Ver la advertencia de abajo. | | `NEXT_PUBLIC_WHATSAPP` | Sí, **en construcción** | El widget de WhatsApp abre una conversación con un número vacío. | | `DEMO_ACCESO_RAPIDO` | No | Con cualquier valor distinto de `false`, el acceso de un clic por perfil está activo en `/admin/login`. | | `AUTH_COOKIE_INSECURE` | No | Solo para pruebas locales sin TLS. Ver la advertencia de HTTPS abajo. **Nunca en producción.** | | `NODE_ENV` | No | Ya viene en `production` dentro de la imagen. | ### Advertencia sobre las variables `NEXT_PUBLIC_*` Next las sustituye **literalmente durante `next build`**, no las lee al arrancar. Cambiar `NEXT_PUBLIC_SITE_URL` o `NEXT_PUBLIC_WHATSAPP` en el entorno del contenedor **no tiene ningún efecto**: hay que reconstruir la imagen pasándolas como `--build-arg`. Es el error más fácil de cometer aquí, y se manifiesta como un sitemap con el dominio incorrecto y un widget de WhatsApp que no marca a nadie. ```bash docker build \ --build-arg NEXT_PUBLIC_SITE_URL=https://fspm-demo.urieljareth.org \ --build-arg NEXT_PUBLIC_WHATSAPP=525540348395 \ -t fspm-web:demo . ``` ### Generar un `AUTH_SECRET` ```bash node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" ``` Uno por entorno. No reutilices el de desarrollo: cualquiera que lo tenga puede firmar una cookie de sesión válida y entrar al panel como administrador. ### El panel exige HTTPS La cookie de sesión va marcada como `Secure`, que es lo correcto. La consecuencia es que **si el panel se sirve por HTTP plano, en Safari y en iOS nadie puede entrar**: el navegador se niega a guardar la cookie, el acceso devuelve a la pantalla de inicio de sesión y no dice por qué. Chromium hace una excepción con `localhost`, así que el problema se ve solo en WebKit y es fácil de atribuir a otra cosa. Detrás de Coolify con Cloudflare esto no ocurre, porque el tráfico llega por HTTPS. Importa en dos casos: si alguien expone el contenedor por IP y puerto sin TLS, y al probar en local. Para lo segundo existe `AUTH_COOKIE_INSECURE=true`, que **no debe usarse en un entorno accesible desde internet**. ## El volumen — la parte frágil **Si no montas `/app/data`, cada redespliegue borra los leads capturados.** La base de demostración se hornea dentro de la imagen durante la construcción. Al arrancar, el punto de entrada la copia al volumen **solo si el volumen está vacío**; si ya hay una base, no la toca. Ese es el mecanismo que permite que los leads que llegan por el formulario y por el widget sobrevivan a una nueva versión de la imagen. Sin volumen, el contenedor sigue funcionando —arranca con los datos sembrados—, así que la falla es silenciosa: nadie se entera hasta que un lead real desaparece. En Coolify: **Storages → Add → Volume Mount**, con destino `/app/data`. Verifícalo después del primer despliegue con: ```bash docker inspect --format '{{json .Mounts}}' ``` ## Local con Docker ```bash docker build -t fspm-web:demo . docker run -d --name fspm-demo -p 3100:3000 \ -v fspm-datos:/app/data \ -e AUTH_SECRET="" \ fspm-web:demo curl -s http://127.0.0.1:3100/api/salud ``` `/api/salud` consulta la base, así que un volumen no escribible se reporta como contenedor enfermo en lugar de pasar por sano. ## Coolify Los endpoints `POST /applications/*` de la API devuelven 404 en esta instancia (verificado en 4.1.2 y de nuevo en 4.3.12), así que la aplicación **se crea desde la interfaz web** y solo el despliegue se dispara por API. 1. Crea la aplicación apuntando al repositorio de Gitea, rama `main`, tipo **Dockerfile**. 2. Fija el dominio, para que Coolify no asigne uno aleatorio y devuelva 503. 3. Carga las variables de entorno. Las `NEXT_PUBLIC_*` van también como argumentos de construcción. 4. Monta el volumen en `/app/data`. 5. Despliega y vigila el registro. Despliegues posteriores: ```bash curl -H "Authorization: Bearer $COOLIFY_TOKEN" \ "$COOLIFY_API_URL/deploy?uuid=" ``` ## Una carrera que confunde: hidratación y latencia El acceso al panel usa una acción de servidor. Hasta que React no hidrata la página, pulsar el botón **no hace nada y no da error**. Con poca latencia no se nota; a través de Cloudflare, sí. Esto costó un diagnóstico equivocado: la primera verificación automatizada pulsaba el botón demasiado pronto, fallaba solo por el dominio público y funcionaba pegándole a Traefik directo, lo que apuntaba a Cloudflare. No era Cloudflare —los chunks de JavaScript son byte a byte idénticos por los dos caminos y la hidratación es igual—, era la espera de la prueba. Si alguien vuelve a ver este síntoma, antes de culpar al proxy conviene esperar `load` más un margen y volver a medir. ## Verificación posterior al despliegue Hazla en este orden; cada punto descarta una clase distinta de falla. ```bash BASE=https://fspm-demo.urieljareth.org curl -s $BASE/api/salud # {"ok":true,...} curl -s $BASE/robots.txt | grep admin # debe prohibir /admin curl -s $BASE/sitemap.xml | grep -c admin # debe dar 0 curl -sI $BASE/icon | grep -i content-type # image/* curl -sI $BASE/opengraph-image | grep -i content-type curl -s $BASE/sitemap.xml | grep -o 'https://[^<]*' | head -3 # ¿el dominio correcto? ``` Después, a mano: 1. Envía el formulario del inicio y confirma que devuelve folio. 2. Abre el widget de WhatsApp, envíalo y revisa que el enlace lleve el prefijo `[DEMO]`, el folio y el mensaje que escribiste. 3. Entra a `/admin/login` —**no está enlazado desde el sitio**, es intencional— y usa el acceso rápido para ver el panel desde los dos roles. 4. Confirma que la oportunidad de los pasos 1 y 2 aparece en el panel con su campaña y su producto de interés. 5. Redespliega y vuelve a entrar al panel: los leads de los pasos 1 y 2 **deben seguir ahí**. Si desaparecieron, el volumen no está montado. ## Antes de enseñarlo a un cliente - `AUTH_SECRET` distinto del de desarrollo. - `DEMO_ACCESO_RAPIDO=false` si la demo queda publicada sin acompañamiento: con el acceso rápido activo, cualquiera que llegue a `/admin/login` entra como administrador. - Los datos del panel son ficticios y los precios de los pedidos son inventados. Conviene decirlo en voz alta antes de abrir la pantalla de pedidos. ## Notas del entorno de desarrollo - **El repositorio vive en una carpeta sincronizada a la nube.** El cliente de sincronización mueve archivos de `.next` a media compilación y `npm run build` falla con `Cannot find module './NNNN.js'` aunque el código esté correcto. Está reproducido. La construcción dentro de Docker no se ve afectada, porque copia el contexto y compila en el contenedor. Para verificar en local, usa la imagen, no `next build`. - `next.config.ts` acepta `NEXT_DIST_DIR` para mover el directorio de salida, pero Next lo resuelve relativo a la raíz del proyecto y no acepta rutas absolutas, así que no resuelve el problema anterior por sí solo. - El `chown` del Dockerfile incluye `/app/prisma-cli` a propósito: al arrancar, `prisma db push` reescribe sus motores dentro de su propio `node_modules`, y sin permiso el contenedor muere con `Can't write to /app/prisma-cli/node_modules/@prisma/engines`. Se detectó con el contenedor ya construido, nunca durante el build.