Ir al contenido

Infraestructura de Producción

uunit9 se despliega sobre servicios gestionados; no hay servidores propios que mantener. La topología de producción es:

Capa Proveedor Detalle
Web (platform SPA) Cloudflare (Workers Static Assets) build estático de apps/platform
Sitio de docs Cloudflare Pages build estático de apps/docs (Astro Starlight)
API Railway Dockerfile.api, node-server en :3000
Base de datos Neon Postgres + extensión pgvector
Redis Upstash solo estado del bot de Telegram
Storage Cloudflare R2 imágenes de menú, API S3-compatible
DNS Cloudflare subdominios app/api/docs

En toda esta documentación se usan dominios placeholder: app.uunit9.com (web), api.uunit9.com (API) y docs.uunit9.com (docs). Reemplázalos por el dominio real al desplegar.

graph TD
USER[Navegador] --> CF[Cloudflare: app.uunit9.com]
CF -->|"/api/* (proxy)"| RAIL[Railway: api.uunit9.com]
CF --> ASSETS[Static Assets: SPA]
RAIL --> NEON[(Neon: Postgres + pgvector)]
RAIL --> UPSTASH[(Upstash: Redis)]
RAIL --> R2[(Cloudflare R2)]
RAIL --> NIM[NVIDIA NIM]
RAIL --> SMTP[Proveedor SMTP]
RAIL --> TG[Telegram API]

El build de producción es pnpm --filter platform build, que genera los estáticos en apps/platform/dist.

La SPA llama a la API con rutas relativas /api (apps/platform/src/lib/api.ts hace fetch con credentials: "include", apps/platform/src/lib/auth-client.ts usa baseURL: window.location.origin + "/api/auth", y los EventSource de cocina apuntan a /api/kds/events). Por tanto, el origen web debe proxear /api/* hacia la API — esto además evita CORS y problemas de cookies cross-site (el CORS de la API está restringido a WEB_URL con credentials, apps/api/src/index.ts).

Recomendación: Workers Static Assets con un worker mínimo que desvíe /api/*:

export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return fetch(`https://api.uunit9.com${url.pathname}${url.search}`, request);
}
return env.ASSETS.fetch(request);
},
};

El fetch de Workers streamea por defecto, así que los endpoints SSE (/api/kds/events) funcionan sin configuración extra.

El repo mantiene exactamente dos archivos Docker, con responsabilidades separadas:

Archivo Entorno Qué hace
docker-compose.yml Solo dev Levanta la infraestructura local: Postgres + pgvector (:5433), Redis (:6380), Mailpit (:1026/:8026) y Ollama (con --profile ollama). El código corre en el host con pnpm dev (recarga en caliente).
Dockerfile.api Solo prod Imagen de la API que construye Railway (multi-stage node:22-alpine).

Además, docker/initdb/01-vector.sql crea la extensión pgvector en el Postgres dev (lo monta el compose).

No hay Dockerfile de platform ni compose de producción: la web se despliega estática en Cloudflare y la DB/Redis son gestionadas. No hay Dockerfile de dev a propósito: el código corre en el host y el compose solo provee infraestructura.

Servicio construido desde Dockerfile.api en el root del repo (multi-stage node:22-alpine; el runner ejecuta node .output/server/index.mjs, expone el puerto 3000 y fija ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres).

  • Health check: GET /api/health{ ok: true }.
  • Una sola réplica con SCHEDULER_ENABLED=true: el scheduler es in-process; las réplicas extra deben llevar SCHEDULER_ENABLED=false (convención de apps/api/AGENTS.md).

packages/db usa el driver postgres (postgres.js), compatible con la connection string pooled de Neon (la que termina en -pooler) con SSL.

La extensión pgvector es obligatoria (la memoria de agentes la usa, apps/api/src/agents/memory.ts; en dev la crea docker/initdb/01-vector.sql): ejecutar CREATE EXTENSION IF NOT EXISTS vector; en la consola SQL de Neon. Aplicar el schema con drizzle-kit push desde packages/db con DATABASE_URL apuntando a Neon (ver packages/db/AGENTS.md).

Variable Endpoint Neon Por qué
DATABASE_URL pooled (…-pooler.…) Conexiones cortas de la API (postgres.js). Los pg_advisory_xact_lock (branch-scope.ts, branches.ts) van dentro de transacción y funcionan con pooler.
WORKFLOW_POSTGRES_URL directa (sin -pooler) graphile-worker (dentro de @workflow/world-postgres) abre LISTEN/NOTIFY sobre su pool; LISTEN/NOTIFY no está soportado por el pooler de Neon.

Nota de compatibilidad: usa la cadena de Neon con sslmode=require, sin channel_binding=require (ni postgres.js ni el driver pg del world soportan channel binding; la consola de Neon ofrece ambas variantes).

El paquete @workflow/world-postgres trae un CLI bootstrap que corre sus migraciones (schemas workflow_drizzle y graphile_worker) contra la base de datos. Hay que ejecutarlo una vez contra Neon antes del primer arranque:

Ventana de terminal
WORKFLOW_POSTGRES_URL=<neon-url-directa> pnpm --filter api exec bootstrap

En runtime el world lee process.env.WORKFLOW_POSTGRES_URL || process.env.DATABASE_URL. Sus tablas viven en schemas separados, así que compartir la misma DB de Neon con las tablas de la app es seguro.

Auditoría del código contra esta arquitectura, punto por punto:

  1. App ↔ Neon: postgres.js con pooler ✓; sin LISTEN/NOTIFY ni sesiones persistentes en el código de la app (grep: solo pg_advisory_xact_lock transaccional); drizzle-kit push directo ✓.
  2. Workflows ↔ Neon: @workflow/world-postgres (graphile-worker + LISTEN/NOTIFY, eventos pool:listen:*) requiere el endpoint directo — ver la tabla de connection strings. Sus tablas viven en los schemas workflow_drizzle/graphile_worker, separados de la app ✓.
  3. Redis/Upstash: @chat-adapter/state-redis usa node-redis v5 (createClient({ url })), que acepta URLs TLS rediss:// de Upstash ✓. Único consumidor: estado del bot de Telegram.
  4. Platform: cero URLs hardcodeadas y cero import.meta.env (grep en apps/platform/src: sin localhost, :3001 ni VITE_) — 100% /api relativo, portable a cualquier dominio ✓.
  5. Streaming/SSE: los endpoints SSE (/api/kds/events) y el streaming de chat (/api/chat) funcionan tras el proxy de Cloudflare Workers (fetch streamea por defecto) y en Railway ✓. No hay WebSockets en el código ✓.
  6. Puerto en Railway: Nitro node-server lee NITRO_PORT ?? PORT; Railway inyecta PORT, así que la imagen arranca sin cambios ✓.
  7. Auth: better-auth con trustedOrigins: [env.WEB_URL] y baseURL: env.BETTER_AUTH_URL (apps/api/src/auth.ts) — todo por variables, sin dominios fijos ✓.
  8. Print jobs: polling HTTP (apps/api/src/routes/print-jobs.ts), sin long-polling ni sockets — compatible ✓.

Crear la base de datos y copiar la URL TLS rediss://... a REDIS_URL. Único consumidor verificado: el estado del bot de Telegram (createRedisState({ url: env.REDIS_URL }), apps/api/src/bot.ts). registerBot retorna temprano si TELEGRAM_BOT_TOKEN está vacío, así que sin bot de Telegram, Upstash no se usa en absoluto — puede posponerse.

Ya implementado vía API S3 (apps/api/src/routes/uploads.ts): endpoint https://<R2_ACCOUNT_ID>.r2.cloudflarestorage.com y URLs públicas https://pub-<R2_ACCOUNT_ID>.r2.dev/<key>.

Pasos: crear el bucket uunit9-assets, generar un API token y habilitar el acceso público r2.dev (o un dominio custom).

  • SMTP: agnóstico del proveedor. apps/api/src/lib/mailer.ts usa nodemailer con SMTP genérico, así que cualquier proveedor real sirve (Resend, SES, Brevo, …). Mailpit es solo dev.

  • Modelos: en producción MODEL_PROVIDER=nim + NVIDIA_API_KEY (Ollama no aplica fuera del host dev).

  • Langfuse: observabilidad LLM opcional; vacío deshabilita la feature con warning.

  • Telegram: tras configurar el token, registra el webhook:

    https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://api.uunit9.com/api/webhooks/telegram