Saltar al contenido principal

Operaciones / DevOps — Xenpia y Xenpdoo

Guía de referencia para desplegar, acceder a los servidores y resolver problemas comunes. Cubre ambos productos (Xenpia chatbot y Xenpdoo/Odoo), porque comparten los mismos dos servidores físicos.

Nunca pongas contraseñas, tokens ni llaves privadas reales en este archivo. Es un doc versionado en git — solo nombres de variables/secrets y cómo obtenerlos, nunca sus valores.


1. Mapa de ambientes​

Rama gitServidorDominio(s) XenpiaDominio(s) XenpdooSupabase
devTest — 15.204.168.50test.xenpia.com / apitest.xenpia.com / docstest.xenpia.com / devdocstest.xenpia.comtest.xenpdoo.com + <cliente>-test.xenpdoo.comfznyaithuxhtfxjpchnd (dev)
mainProducción — 15.204.168.51app.xenpia.com / api.xenpia.com / docs.xenpia.com / devdocs.xenpia.comxenpdoo.com + <cliente>.xenpdoo.comaqzmykwsxntzjkvhkzho (main)

Ambos servidores corren Debian 13 + Docker. Nginx del sistema (no en contenedor) hace de proxy/TLS para todo — cada stack de la app expone sus puertos solo a 127.0.0.1.


2. Acceso a los servidores​

ssh [email protected] # producción
  • Login por contraseña root está disponible, pero el acceso normal de trabajo (yo incluido) es por llave SSH. Las llaves de despliegue (usadas por GitHub Actions) están en ~/.ssh/authorized_keys de los usuarios root y trama en ambos servidores.
  • Hay dos checkouts distintos de Xenpia en el servidor de producción/test, herencia de una migración de usuario a mitad de proyecto — ver §6 "Gotcha: dos directorios de Xenpia".

Directorios por app​

AppDueño/ruta en servidor
Xenpia (prod, .51)/root/xenpia-chatbot (usuario root)
Xenpia (test, .50)/home/trama/xenpia-chatbot (usuario trama)
Xenpdoo (ambos servidores)/root/xenpdoo (usuario root)

El .env de cada app vive directamente en su carpeta (.env, gitignored, nunca se commitea).

Memoria de los bots en Postgres. Hace falta LANGGRAPH_CHECKPOINT_DATABASE_URL en el .env de Xenpia de cada servidor:

  • Qué conexión es: directa al Postgres de Supabase de ese ambiente (test → DEV, prod → PROD), con el usuario postgres, por el pooler en modo sesión (puerto 5432).

  • Requisito: aplicar antes la migración 20261020000003_langgraph_checkpoints.sql.

  • Si falta o no conecta: el backend arranca igual con la memoria en RAM y lo avisa en el log (Memoria de los bots en Postgres es la línea buena). La memoria se pierde en cada deploy, así que las conversaciones vuelven a empezar desde Inicio.

  • Comprobarlo tras desplegar:

    docker compose logs backend | grep -i "memoria de los bots"

3. Pipeline de CI/CD (GitHub Actions)​

En Xenpia, a producción se llega por funcionalidad, no con todo dev (desde 2026-09-19, ADR 0005). El flujo de ramas completo, con los comandos, está en Flujo de ramas.

feat/x (sale de main)
│ PR a dev
▼
"Deploy Test" (automático) ── SSH al servidor de test, git reset --hard origin/dev, rebuild
│ QA prueba y aprueba
│ PR de feat/x a main (la MISMA rama, nunca dev → main)
▼
push a main
├──► "Deploy Production" (automático) ── backup, migraciones, git reset --hard origin/main, rebuild
└──► "Sync main into dev" (automático) ── merge main → dev y redeploy de Test

"Promote to Production" (BOTÓN MANUAL) ── solo corta la versión: notas + etiqueta sobre main,
y relanza Deploy Production y Sync

dev nunca se fusiona en main. Lo que QA no ha aprobado se queda en dev y no llega a producción.

"Sync main into dev" (sync-main-to-dev.yml). Si el merge choca solo en docs/generated/ o docs/static-interno/, los regenera con yarn gen. Si choca en código, abre un PR desde la rama sync/main-a-dev a dev para resolverlo a mano. El PR nunca sale de main: resolver en la web fusionaría dev dentro de main.

Xenpdoo (XenpiaIT/Xenpdoo) conserva el patrón anterior: promover dev entero a main.

Comprobar que la app responde de verdad. next build compila sin renderizar ninguna página, y un deploy se daba por bueno con solo que el contenedor arrancara. El 2026-09-25 Test estuvo unas 7 h y producción casi 1 h con todas las rutas en 500 y todo en verde: un hook quedó fuera de su provider en el layout raíz. Ahora las dos comprobaciones usan scripts/smoke-http.sh, que exige un 200 sin la página de error de Next:

  • CI, job Frontend: después del build arranca el servidor standalone (el mismo de Docker) y pide / y /auth/supabase/sign-in. Si fallan, el PR queda en rojo antes de llegar a dev, y el paso imprime el log del servidor, que es donde está el mensaje de la excepción.
  • Deploy Test y Deploy Production: al terminar, piden /, el login y /api en la URL pública, reintentando hasta 3 min mientras arranca el contenedor. Las URLs salen de las variables APP_PUBLIC_URL y API_PUBLIC_URL del environment, con app/api (prod) y test/apitest (Test) por defecto. Un rojo aquí significa que el código ya está desplegado: se actúa con un hotfix o con "Rollback", no se deshace solo.

"Rollback" (botón manual, workflow_dispatch) — vuelve dev o main a un tag/commit específico y redespliega. Pide el ref (ej. v0.1.0) y el ambiente (test/production). ⚠️ Solo revierte código — no toca la base de datos. Si una migración ya corrió y modificó/borró datos, el rollback de código no deshace eso; para eso está el backup de abajo.

Backup antes de migrar en producción — "Deploy Production" hace un dump completo (schema + datos, vía supabase db dump) de la base de PROD justo antes de supabase db push, y lo sube como artifact del run (prod-db-backup-<run_id>, retenido 30 días, descargable desde la pestaña Actions → esa corrida → Artifacts). Es la única red de seguridad para datos antes de aplicar migraciones nuevas — sin esto, un db push que rompe algo en PROD no tenía forma de revertirse más allá de los backups automáticos de Supabase (que dependen del plan pagado del proyecto — revisar en Dashboard → Database → Backups).

No existe pausa de aprobación automática (GitHub "Required reviewers" en Environments requiere plan de pago Team/Enterprise, que esta organización no tiene). El control de calidad es el merge del PR a main. Solo se pulsa cuando QA dejó su aprobación en el PR, y nadie sin acceso de escritura al repo puede fusionar.

Environments y qué necesita cada uno​

En Settings → Environments de cada repo:

EnvironmentUsoVariables (vars.)Secrets (secrets.)
developmentDeploy TestSERVER_HOST, SSH_USER, DEPLOY_PATHSSH_PRIVATE_KEY (+ Supabase, solo Xenpia)
productionGate de "Promote to Production" (sin credenciales, solo referencia)——
production-deployDeploy Production realSERVER_HOST, SSH_USER, DEPLOY_PATHSSH_PRIVATE_KEY (+ Supabase, solo Xenpia)

Xenpia además necesita, por ambiente (development / production-deploy): vars.SUPABASE_PROJECT_REF y secrets.SUPABASE_DB_URL. Ya no hace falta SUPABASE_ACCESS_TOKEN: las migraciones van por conexión directa a Postgres (supabase db push --db-url), sin supabase link ni Management API.

SUPABASE_DB_URL es la cadena del pooler en modo sesión, puerto 5432. La conexión directa (db.<ref>.supabase.co) es solo IPv6 y los runners de GitHub no la alcanzan:

postgresql://postgres.<project-ref>:<PASSWORD>@aws-0-<region>.pooler.supabase.com:5432/postgres

⚠️ La password va percent-encoded dentro de la URL (@ → %40, # → %23, / → %2F…). Si tiene caracteres especiales sin escapar, el CLI parsea mal el host y falla con un error de conexión que no menciona la password por ningún lado.

⚠️ SUPABASE_DB_URL debe ser un Secret, no una Variable — si se guarda como Variable, secrets.SUPABASE_DB_URL llega vacío al workflow. El paso "Validate database URL target" corta ahí con un mensaje explícito, y de paso verifica que la URL contenga el SUPABASE_PROJECT_REF del ambiente, para que un secret mal cargado no aplique migraciones de prod contra test.

Por qué se sacó el PAT (2026-09-10): un Personal Access Token de Supabase es una credencial de toda la organización — puede leer las API keys de cualquier proyecto, cambiar la config de auth, borrar proyectos — y depende de que la cuenta que lo generó conserve rol Owner/Administrator. Cuando esa cuenta perdió la membresía, supabase link empezó a responder 403 (Your account does not have the necessary privileges) y el deploy de producción se caía entero antes de tocar nada. La URL de Postgres solo alcanza la base de un proyecto: menos privilegio y una dependencia externa menos. Para diagnosticar un PAT sospechoso: GET https://api.supabase.com/v1/projects con Authorization: Bearer <token> — si devuelve 200 con lista vacía, esa cuenta no ve ningún proyecto.

production y production-deploy están separados a propósito: si compartieran nombre, cada deploy real pediría "aprobación" dos veces (una en el gate, otra al desplegar).


4. Desplegar / redesplegar a mano​

Cuando el pipeline falla o hay que forzar algo ya:

# Xenpia — prod
cd /root/xenpia-chatbot
git fetch origin && git reset --hard origin/main
./deploy.sh --build

# Xenpia — test
cd /home/trama/xenpia-chatbot
git fetch origin && git reset --hard origin/dev
./deploy.sh --build

# Xenpdoo — prod o test (mismo patrón, incluye submódulos OCA)
cd /root/xenpdoo
git fetch origin && git reset --hard origin/main # o origin/dev en test
git submodule sync --recursive && git submodule update --init --recursive --depth 1
docker compose build odoo && docker compose up -d db odoo

deploy.sh (solo Xenpia) exige que exista .env en la carpeta — si no existe, lo crea vacío desde .env.example y aborta sin desplegar nada. Ver §6.

Verificación rápida​

curl -I https://app.xenpia.com/ https://api.xenpia.com/api
curl -I https://test.xenpia.com/ https://apitest.xenpia.com/api
curl -I https://xenpdoo.com/ https://xenpia.xenpdoo.com/
curl -I https://test.xenpdoo.com/ https://xenpia-test.xenpdoo.com/
docker ps --filter name=xenpia-chatbot
docker logs --tail 100 xenpia-chatbot-backend-1

5. Certificados / dominios​

  • Xenpia: certbot + Let's Encrypt normal (renovación automática), un dominio por hostname.
  • Xenpdoo: usa Cloudflare Origin Certificates en vez de certbot, porque producción usa un wildcard (*.xenpdoo.com) — Let's Encrypt no emite wildcards sin validación DNS especial. Los certs viven en /etc/nginx/ssl/xenpdoo-origin.{pem,key} (prod) y xenpdoo-test-origin.{pem,key} (test), válidos ~15 años. Si hay que regenerarlos: Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate, con los hostnames exactos.
  • Ambos dominios están en Cloudflare, proxied (nube naranja). Modo SSL de la zona: Full (strict).

5.b Sitios de documentación (docs y devdocs)​

Dos contenedores más en el mismo docker-compose.yml, salidos de la misma imagen (docs/Dockerfile), que solo se diferencian en la variable SITIO. Corren en test y producción (mismo compose); lo que cambia es el dominio y a qué servidor apunta el DNS.

Los puertos locales son 3840 / 3841 a propósito: lejos de los 300x que suelen ocupar Node, Next y otros servicios del stack.

ServicioSITIOPuerto localTestProducciónAcceso
docsuser127.0.0.1:3840docstest.xenpia.comdocs.xenpia.comPúblico
devdocsinterno127.0.0.1:3841devdocstest.xenpia.comdevdocs.xenpia.comSolo el equipo, por Cloudflare Access

Se despliegan solos: ./deploy.sh --build levanta todos los servicios del compose.

Si el dominio no abre, casi siempre falta el DNS o el bloque de nginx en ese servidor: los contenedores solo escuchan en 127.0.0.1, no en la IP pública.

DNS​

La zona ya tiene *.xenpia.com (proxied). Eso alcanza los hostnames de producción (docs.xenpia.com, devdocs.xenpia.com) sin crear registros nuevos, si el wildcard apunta al servidor de prod (.51).

Los de test no pueden vivir solo del wildcard: un * solo apunta a una IP. Igual que test.xenpia.com / apitest.xenpia.com, hay que crear registros explícitos a .50:

HostnameRegistroDestino
docstest.xenpia.comA (o CNAME) proxiedservidor de test (.50)
devdocstest.xenpia.comA (o CNAME) proxiedservidor de test (.50)
docs.xenpia.com / devdocs.xenpia.comcubiertos por *.xenpia.comprod (.51)

Sin el proxy (nube naranja), Access no puede interponerse en los devdocs*.

Access: no lo pongas sobre *.xenpia.com entero. El manual público (docs* / docstest*) tiene que quedar abierto; Access solo en devdocs.xenpia.com y devdocstest.xenpia.com.

nginx del sistema​

El mismo patrón en ambos servidores. En test, cambia server_name a docstest / devdocstest; el puerto local es el mismo (3840 / 3841).

server {
server_name docs.xenpia.com; # en test: docstest.xenpia.com
location / {
proxy_pass http://127.0.0.1:3840;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# listen 443 ssl + certbot, igual que app.xenpia.com / test.xenpia.com
}

El bloque de devdocs es idéntico salvo el puerto (3841) y una diferencia importante:

server {
server_name devdocs.xenpia.com; # en test: devdocstest.xenpia.com

# Solo Cloudflare puede llegar al origen. Sin esto, cualquiera que descubra la
# IP del servidor se salta Access apuntando un /etc/hosts.
# Lista actualizada en https://www.cloudflare.com/ips/
include /etc/nginx/cloudflare-ips.conf; # allow <rango>; por cada rango
deny all;

location / {
proxy_pass http://127.0.0.1:3841;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Cloudflare Access para devdocs / devdocstest​

Todo en el panel de Cloudflare, nada de esto vive en el repositorio. Haz dos aplicaciones (o una con ambos hostnames):

  1. Zero Trust → Access → Applications → Add an application → Self-hosted.
  2. Dominios: devdocs.xenpia.com y devdocstest.xenpia.com.
  3. Política de tipo Allow, con la regla Emails ending in @xenpia.com, o la lista explícita de correos del equipo si se prefiere control fino.
  4. Método de login: código de un solo uso al correo (no requiere configurar nada más) o Google.
  5. Duración de sesión: 24 h está bien; obliga a volver a identificarse a diario sin ser molesto.

Gratis hasta 50 usuarios. Dar o quitar acceso a alguien es editar esa lista: no hay usuarios ni contraseñas que gestionar en el sitio.

Comprobación después de configurarlo, en una ventana de incógnito:

  • https://devdocs.xenpia.com y https://devdocstest.xenpia.com deben pedir identificación.
  • curl -H "Host: devdocs.xenpia.com" http://<IP_PROD> (y lo mismo con devdocstest contra la IP de test) debe devolver 403.

Si la segunda comprobación devuelve HTML, falta la restricción por IP en nginx y el sitio está expuesto. El razonamiento completo está en la decisión 0002.


6. Migraciones de Supabase — flujo obligatorio​

Para evitar prefijos de timestamp repetidos entre dos personas (o entre una persona y una herramienta de IA) trabajando en paralelo:

  1. git pull antes de crear la migración, para ver qué ya existe en supabase/migrations/ en otras ramas/PRs recientes.
  2. Generar el archivo con la CLI, nunca escribiendo el timestamp a mano:
    npx supabase migration new <nombre_descriptivo>
    Esto pone el prefijo YYYYMMDDHHMMSS_ con precisión de segundos — un timestamp escrito a mano (o copiado de una migración vieja) es lo que causa la colisión del gotcha de abajo.
  3. Nunca editar ni renombrar una migración ya pusheada. Si hay que corregir algo, se crea una migración nueva — editar una ya aplicada rompe el checksum que usa la CLI para saber qué ya corrió.
  4. Aplicar con la CLI, no con el SQL Editor del dashboard (salvo DEV cuando la CLI no alcanza — ver §1 mapa de ambientes): npx supabase link --project-ref <ref> → npx supabase db push --dry-run → npx supabase db push (agregar --include-all si el historial remoto no coincide con el orden local).

7. Gotchas conocidos (cosas que ya rompieron el deploy una vez)​

  • Dos directorios de Xenpia en el mismo servidor: /root/xenpia-chatbot (root, el original de esta sesión) y /home/trama/xenpia-chatbot (trama, de una migración posterior). Ambos generan contenedores con el mismo nombre (xenpia-chatbot-*) porque ninguno fija name: en el compose — el que corra docker compose up último "gana" los contenedores. Antes de redesplegar a mano, confirma con docker ps cuál checkout es el que realmente está sirviendo tráfico, para no pisar producción con código de test (ya pasó una vez).
  • .env faltante silencioso: deploy.sh no avisa fuerte si falta .env, solo lo crea vacío y sale con código de error — un git push a dev/main puede fallar en rojo por esto sin que se note a simple vista en el resumen del run.
  • Migraciones de Supabase con versión duplicada: dos archivos en supabase/migrations/ con el mismo prefijo de fecha (ej. dos 20260861_*.sql) hacen que supabase db push falle con "duplicate key value... schema_migrations_pkey". Pasa cuando alguien escribe el timestamp a mano en vez de usar supabase migration new (ver §6). Renombrar el duplicado NO basta y puede ser peor. La CLI decide qué falta mirando solo la versión, nunca el nombre. Si un entorno ya había aplicado uno de los dos archivos con esa versión, el que se queda con ella pasa allí por aplicado sin haber corrido nunca, y el fallo aparece semanas después en otra migración (relation "public.X" does not exist). Ya pasó tres veces en PROD: 20260861 (sellada con sandbox_channel_pack, el archivo es scheduling_reschedule_offers), 20260862 (whatsapp_two_step_pin / billing_mirror) y 20260915000001 (prescription_resolve_linked_user / channel_media_email_provider); se reparó con 20260911125900_repair_migration_version_collisions.sql. Antes de renombrar, mirar en cada entorno qué nombre selló la versión:
    select version, name from supabase_migrations.schema_migrations where version = '<version>';
    El archivo que conserva la versión tiene que ser el que figura ahí en todos los entornos. Si los entornos no coinciden, no hay renombrado correcto: hace falta una migración nueva que aplique el contenido del huérfano guardada por el objeto (to_regclass(...) IS NULL), para que sea un no-op donde sí corrió.
  • supabase db push "before the last migration": si el historial remoto de migraciones no coincide exactamente con el orden local (pasa si alguien migró a mano alguna vez), hace falta el flag --include-all (ya está en los workflows).
  • Bug de WhatsApp wa_phone_number vacío: si una cuenta de canal tiene más de una plataforma conectada (ej. WhatsApp + web), cualquier consulta a channel_account_channels que filtre solo por channel_account_id con .maybeSingle() falla silenciosamente (hay más de una fila). Siempre hay que sumar el filtro por channel_code (join con communication_channels). Ver frontend/src/actions/bot.ts y backend/src/channels/meta/whatsapp/whatsapp.service.ts para el patrón correcto.
  • Alta de usuario en tenant crea un segundo tenant y al impersonar no hay menú: auth.admin.createUser sin user_metadata.invited_flow: true deja que el trigger de signup abra un tenant personal; dos memberships rompían .maybeSingle() en el auth provider. Toda alta que no sea signup público debe pasar ese flag; el tenant activo se resuelve en resolveTenantId. Ver Alta de usuarios e invited_flow.
  • Contenedor renombrado que bloquea TODOS los despliegues siguientes: al recrear, Compose renombra el contenedor viejo con su id delante (4de05d430ae0_xenpia-chatbot-docs-1). Si el up se cae después, ese nombre queda ocupado y a partir de ahí cada despliegue construye las imágenes enteras y muere con «container name is already in use» sin arrancar nada. deploy.sh los limpia antes de levantar y reintenta una vez si el arranque los deja a medias. Ojo con el detalle que lo tuvo roto: la detección usaba awk con un intervalo {8,}, y el awk del servidor es mawk, que no los soporta — el patrón no casaba nunca y la limpieza no borraba nada, en silencio. Ahora va con grep -E. Si algún día hay que hacerlo a mano:
    docker ps -a --format '{{.ID}} {{.Names}}' | grep -E ' [0-9a-f]{8,}_xenpia-chatbot-'
    docker rm -f <ids>
  • git fetch que no puede traer ninguna rama qa/...: si en el servidor quedó un refs/remotes/origin/qa de cuando existió una rama llamada qa a secas, git no puede crear refs/remotes/origin/qa/loquesea ("cannot lock ref... exists") y el fetch termina en rojo. Los workflows usan git fetch --prune origin, que borra lo obsoleto ANTES de traer. A mano: git remote prune origin o git update-ref -d refs/remotes/origin/qa.
  • SERVER_HOST apuntando al servidor equivocado: si alguna vez "Deploy Test" despliega código de dev sobre producción (contenedores con nombres xenpia-chatbot-* en .51 recreándose solos sin que nadie haya tocado main), lo primero a revisar es vars.SERVER_HOST del environment development — debe ser 15.204.168.50, nunca .51.

8. Copiar datos de prod → test (superadmin)​

Para depurar con datos reales sin dar al servidor de test acceso a prod.

UI/admin/data-sync en prod y test (solo superadmin)
Direcciónlee prod y escribe en test — nunca al revés
Alcanceun tenant (reemplaza ese tenant en test) o toda la base de test
Confirmaciónhay que escribir OVERWRITE-TEST
No copiaStorage (archivos), Redis, .env de la app

Variables (en el servidor que ejecute el job)​

Recomendado: solo en producción (/root/xenpia-chatbot en .51), para que test no tenga la password de prod.

# Pooler SESIÓN (puerto 5432), no transaction mode
PROD_MIGRATION_DATABASE_URL=postgresql://postgres.aqzmykwsxntzjkvhkzho:...@aws-0-….pooler.supabase.com:5432/postgres
DEV_MIGRATION_DATABASE_URL=postgresql://postgres.fznyaithuxhtfxjpchnd:...@aws-0-….pooler.supabase.com:5432/postgres

DEV_MIGRATION_DATABASE_URL es el mismo proyecto (fznyaithuxhtfxjpchnd) que ya usan los scripts de debug locales de migraciones — una sola variable para ambos usos.

Tras añadirlas: ./deploy.sh --build (o al menos recrear el contenedor backend para que lea el .env).

En test la pantalla sí aparece para superadmin; si faltan esas URLs, muestra “no configurado” y no deja ejecutar. Si algún día quisieras disparar el job desde el backend de test, haría falta poner también PROD_MIGRATION_DATABASE_URL (prod) + DEV_MIGRATION_DATABASE_URL ahí — eso sí expone la password de prod al servidor de test.

CLI de emergencia (máquina con ambas URLs)​

cd backend
npm run db:pull-prod -- --action list-tenants
npm run db:pull-prod -- --action copy-one-tenant --tenant-id <uuid> --dry-run
npm run db:pull-prod -- --action copy-one-tenant --tenant-id <uuid> --confirm OVERWRITE-TEST

Límites / riesgos​

  • Copia datos reales (HCE, chats, tokens de canal en tablas). Test usa CHANNEL_MODE=sandbox, lo que mitiga envíos live, pero las credenciales de canal de prod sí aterrizan en test.
  • «Toda la base» borra tenants que solo existen en test (p. ej. demos).
  • Un job a la vez; el progreso se ve en la misma pantalla (poll).