Infraestructura de Producción
Infraestructura de Producción
Sección titulada «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]Web (Cloudflare)
Sección titulada «Web (Cloudflare)»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.
Archivos Docker del Repo
Sección titulada «Archivos Docker del Repo»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.
API (Railway)
Sección titulada «API (Railway)»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 llevarSCHEDULER_ENABLED=false(convención deapps/api/AGENTS.md).
Base de Datos (Neon)
Sección titulada «Base de Datos (Neon)»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).
Dos connection strings distintas
Sección titulada «Dos connection strings distintas»| 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).
Bootstrap del world de workflows
Sección titulada «Bootstrap del world de workflows»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:
WORKFLOW_POSTGRES_URL=<neon-url-directa> pnpm --filter api exec bootstrapEn 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.
Compatibilidad Verificada del Código
Sección titulada «Compatibilidad Verificada del Código»Auditoría del código contra esta arquitectura, punto por punto:
- App ↔ Neon: postgres.js con pooler ✓; sin LISTEN/NOTIFY ni sesiones persistentes en el código de la app (grep: solo
pg_advisory_xact_locktransaccional);drizzle-kit pushdirecto ✓. - Workflows ↔ Neon:
@workflow/world-postgres(graphile-worker + LISTEN/NOTIFY, eventospool:listen:*) requiere el endpoint directo — ver la tabla de connection strings. Sus tablas viven en los schemasworkflow_drizzle/graphile_worker, separados de la app ✓. - Redis/Upstash:
@chat-adapter/state-redisusa node-redis v5 (createClient({ url })), que acepta URLs TLSrediss://de Upstash ✓. Único consumidor: estado del bot de Telegram. - Platform: cero URLs hardcodeadas y cero
import.meta.env(grep enapps/platform/src: sinlocalhost,:3001niVITE_) — 100%/apirelativo, portable a cualquier dominio ✓. - 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 ✓. - Puerto en Railway: Nitro node-server lee
NITRO_PORT ?? PORT; Railway inyectaPORT, así que la imagen arranca sin cambios ✓. - Auth: better-auth con
trustedOrigins: [env.WEB_URL]ybaseURL: env.BETTER_AUTH_URL(apps/api/src/auth.ts) — todo por variables, sin dominios fijos ✓. - Print jobs: polling HTTP (
apps/api/src/routes/print-jobs.ts), sin long-polling ni sockets — compatible ✓.
Redis (Upstash)
Sección titulada «Redis (Upstash)»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.
Storage (R2)
Sección titulada «Storage (R2)»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).
Servicios Auxiliares
Sección titulada «Servicios Auxiliares»-
SMTP: agnóstico del proveedor.
apps/api/src/lib/mailer.tsusa 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