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 git | Servidor | Dominio(s) Xenpia | Dominio(s) Xenpdoo | Supabase |
|---|---|---|---|---|
dev | Test — 15.204.168.50 | test.xenpia.com / apitest.xenpia.com / docstest.xenpia.com / devdocstest.xenpia.com | test.xenpdoo.com + <cliente>-test.xenpdoo.com | fznyaithuxhtfxjpchnd (dev) |
main | Producción — 15.204.168.51 | app.xenpia.com / api.xenpia.com / docs.xenpia.com / devdocs.xenpia.com | xenpdoo.com + <cliente>.xenpdoo.com | aqzmykwsxntzjkvhkzho (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
- 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_keysde los usuariosrootytramaen 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
| App | Dueñ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 Postgreses 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 adev, 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/apien la URL pública, reintentando hasta 3 min mientras arranca el contenedor. Las URLs salen de las variablesAPP_PUBLIC_URLyAPI_PUBLIC_URLdel environment, conapp/api(prod) ytest/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:
| Environment | Uso | Variables (vars.) | Secrets (secrets.) |
|---|---|---|---|
development | Deploy Test | SERVER_HOST, SSH_USER, DEPLOY_PATH | SSH_PRIVATE_KEY (+ Supabase, solo Xenpia) |
production | Gate de "Promote to Production" (sin credenciales, solo referencia) | — | — |
production-deploy | Deploy Production real | SERVER_HOST, SSH_USER, DEPLOY_PATH | SSH_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) yxenpdoo-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.
| Servicio | SITIO | Puerto local | Test | Producción | Acceso |
|---|---|---|---|---|---|
docs | user | 127.0.0.1:3840 | docstest.xenpia.com | docs.xenpia.com | Público |
devdocs | interno | 127.0.0.1:3841 | devdocstest.xenpia.com | devdocs.xenpia.com | Solo 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:
| Hostname | Registro | Destino |
|---|---|---|
docstest.xenpia.com | A (o CNAME) proxied | servidor de test (.50) |
devdocstest.xenpia.com | A (o CNAME) proxied | servidor de test (.50) |
docs.xenpia.com / devdocs.xenpia.com | cubiertos por *.xenpia.com | prod (.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):
- Zero Trust → Access → Applications → Add an application → Self-hosted.
- Dominios:
devdocs.xenpia.comydevdocstest.xenpia.com. - 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. - Método de login: código de un solo uso al correo (no requiere configurar nada más) o Google.
- 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.comyhttps://devdocstest.xenpia.comdeben pedir identificación.curl -H "Host: devdocs.xenpia.com" http://<IP_PROD>(y lo mismo condevdocstestcontra 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:
git pullantes de crear la migración, para ver qué ya existe ensupabase/migrations/en otras ramas/PRs recientes.- Generar el archivo con la CLI, nunca escribiendo el timestamp a mano:
Esto pone el prefijonpx supabase migration new <nombre_descriptivo>
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. - 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ó.
- 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-allsi 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 fijaname:en el compose — el que corradocker compose upúltimo "gana" los contenedores. Antes de redesplegar a mano, confirma condocker pscuál checkout es el que realmente está sirviendo tráfico, para no pisar producción con código de test (ya pasó una vez). .envfaltante silencioso:deploy.shno avisa fuerte si falta.env, solo lo crea vacío y sale con código de error — ungit pushadev/mainpuede 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. dos20260861_*.sql) hacen quesupabase db pushfalle con "duplicate key value... schema_migrations_pkey". Pasa cuando alguien escribe el timestamp a mano en vez de usarsupabase 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 consandbox_channel_pack, el archivo esscheduling_reschedule_offers),20260862(whatsapp_two_step_pin/billing_mirror) y20260915000001(prescription_resolve_linked_user/channel_media_email_provider); se reparó con20260911125900_repair_migration_version_collisions.sql. Antes de renombrar, mirar en cada entorno qué nombre selló la versión: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 (select version, name from supabase_migrations.schema_migrations where version = '<version>';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_numbervacío: si una cuenta de canal tiene más de una plataforma conectada (ej. WhatsApp + web), cualquier consulta achannel_account_channelsque filtre solo porchannel_account_idcon.maybeSingle()falla silenciosamente (hay más de una fila). Siempre hay que sumar el filtro porchannel_code(join concommunication_channels). Verfrontend/src/actions/bot.tsybackend/src/channels/meta/whatsapp/whatsapp.service.tspara el patrón correcto. - Alta de usuario en tenant crea un segundo tenant y al impersonar no hay menú:
auth.admin.createUsersinuser_metadata.invited_flow: truedeja 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 enresolveTenantId. 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 elupse 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.shlos 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 usabaawkcon 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 congrep -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 fetchque no puede traer ninguna ramaqa/...: si en el servidor quedó unrefs/remotes/origin/qade cuando existió una rama llamadaqaa secas, git no puede crearrefs/remotes/origin/qa/loquesea("cannot lock ref... exists") y el fetch termina en rojo. Los workflows usangit fetch --prune origin, que borra lo obsoleto ANTES de traer. A mano:git remote prune originogit update-ref -d refs/remotes/origin/qa.SERVER_HOSTapuntando al servidor equivocado: si alguna vez "Deploy Test" despliega código dedevsobre producción (contenedores con nombresxenpia-chatbot-*en.51recreándose solos sin que nadie haya tocadomain), lo primero a revisar esvars.SERVER_HOSTdel environmentdevelopment— debe ser15.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ón | lee prod y escribe en test — nunca al revés |
| Alcance | un tenant (reemplaza ese tenant en test) o toda la base de test |
| Confirmación | hay que escribir OVERWRITE-TEST |
| No copia | Storage (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).