Files
task-stream/docs/07-despliegue.md
Josep Piguillem efa06a0723 feat: notifications in the app and, optionally, by e-mail
Derived from events and kept per person: whoever a ticket is assigned to,
whoever reports or works on it when it moves, fails QA or gets a comment,
and whoever joins a project. Never about what you did yourself, once per
event. They arrive live through a subscription to a bell in the header,
and are copied by e-mail to whoever turns it on when SMTP_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:19:59 +02:00

12 KiB

7. Despliegue

7.1 Docker

docker compose up -d --build                 # todo: web en http://localhost:8080
docker compose up -d postgres mongo redis    # solo infraestructura
docker compose logs -f api
docker compose down                          # parar (-v borra los datos)
Servicio Imagen Puerto en local Función
web task-stream-web (nginx) 8080 SPA y proxy de /api y /graphql (incluido el WebSocket) hacia la API
api task-stream-api (Node 22) 3000 API NestJS
postgres postgres:17-alpine 5432 Event store y lookups
mongo mongo:8 27017 Read model
redis redis:7-alpine (AOF) 6379 Stream de eventos, pub/sub y caché de sesiones

Producción y desarrollo

  • docker-compose.yml es la definición de producción (la que despliega Dokploy) y no publica puertos: los servicios solo se ven entre sí por la red de Compose. Hacia fuera solo sale la web, por el proxy de la plataforma (en Dokploy: dominio → servicio web, puerto 80). web y api declaran expose para documentarlo.
  • docker-compose.override.yml publica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin -f, es decir, en local; Dokploy usa -f docker-compose.yml y no lo lee.

Para probar en local exactamente lo de producción: docker compose -f docker-compose.yml up -d --build.

Nombres únicos en la red

Los servicios se llaman entre sí por alias únicos (task-stream-api, task-stream-postgres, task-stream-mongo, task-stream-redis), no por el nombre del servicio. Al dar dominio a un servicio, Dokploy lo conecta a la red compartida dokploy-network, donde otros proyectos también tienen un api, un postgres o un redis: con nombres genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las peticiones entre ambos (síntoma: la mitad de las llamadas a /api/* responden 404 Cannot GET … y el login dice «no configurado»). Si añades un servicio, dale su alias task-stream-* y úsalo en las URLs.

Todos tienen healthcheck; web espera a que api esté sana, y api a las tres bases de datos.

Imágenes

El Dockerfile tiene tres destinos que comparten la instalación de dependencias:

  • Instalación: npm ci del workspace. @pi/ui viene del Nexus privado; el token llega como build secret desde la variable NEXUS_TOKEN (secrets.nexus_token en docker-compose.yml), se escribe en un npmrc temporal y se borra en el mismo paso, así que nunca queda en una capa. Sin NEXUS_TOKEN la build falla en ese paso.
  • api: nx run api:prune genera el bundle con un package.json y un lockfile podados a partir de apps/api/package.json. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados). Si la API empieza a usar un paquete nuevo en tiempo de ejecución, hay que añadirlo a apps/api/package.json.
  • mcp: el servidor MCP, podado igual que la API (nx run mcp:prune), sobre Node 24. Su servicio en docker-compose.yml va en el perfil mcp: solo arranca con --profile mcp (en Dokploy, COMPOSE_PROFILES=mcp).
  • web: build de producción de Angular servido por nginx (apps/web/nginx.conf): fallback de rutas a index.html, caché larga para los bundles con hash y sin caché para index.html.

Construir

NEXUS_TOKEN tiene que estar en el entorno de quien construye, o en el .env junto al docker-compose.yml:

# Local: reutiliza el token de tu ~/.npmrc
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
docker compose up -d --build

En Dokploy (despliegue con Docker Compose): pestaña Environment del servicio → NEXUS_TOKEN=<token>, junto a PIREON_CLIENT_ID, PIREON_CLIENT_SECRET, PIREON_PASSWORD_CLIENT_ID, PIREON_PASSWORD_CLIENT_SECRET y PIREON_WEBHOOK_SECRET. Dokploy los escribe en el .env del proyecto, de donde los lee Compose.

7.2 Variables de entorno de la API

Variable Por defecto Descripción
PORT 3000 Puerto HTTP
NODE_ENV development En production no se devuelven stacktraces
POSTGRES_URL — (obligatoria) Event store y lookups
POSTGRES_SYNCHRONIZE false Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones
MONGO_URL — (obligatoria) Read model
REDIS_URL — (obligatoria) Stream, pub/sub y caché
EVENT_STREAM_KEY task-stream:events Clave del Redis Stream
EVENT_STREAM_MAX_LENGTH 1000000 Longitud aproximada que se conserva en el stream
EVENTS_BRIDGE_ENABLED true Si esta instancia proyecta eventos hacia Mongo (ver escalado)
PIREON_BASE_URL https://oauth.pireon.dev Servidor de identidad
PIREON_CLIENT_ID / PIREON_CLIENT_SECRET — Cliente OAuth authorization_code: «Entrar con Pireon» y Swagger UI
PIREON_PASSWORD_CLIENT_ID / _SECRET el anterior Cliente OAuth password: login con email y contraseña
PIREON_SCOPES users:read Scopes que se piden al iniciar sesión
PIREON_PASSWORD_GRANT true Permite el login con email y contraseña
PIREON_WEBHOOK_SECRET — Activa y firma los webhooks de Pireon
PIREON_TIMEOUT_MS 5000 Timeout de las llamadas a Pireon
AUTH_SESSION_TTL_SECONDS 60 Cuánto se confía en un token validado antes de volver a preguntar a Pireon
GRAPHQL_PLAYGROUND true GraphiQL e introspección
GRAPHQL_SCHEMA_FILE apps/api/schema.gql Dónde escribir el SDL (vacío = no escribir; así está en Docker)
SWAGGER_ENABLED true API REST documentada: Swagger UI en /api/docs, OpenAPI en /api/docs-json
OPENAPI_FILE apps/api/openapi.json Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker)
ATTACHMENTS_DIR data/attachments Carpeta de los adjuntos de los tickets; en Docker, el volumen attachments-data
SMTP_URL — Servidor de correo para las copias de las notificaciones (smtps://user:pass@host); sin él no se envía nada
MAIL_FROM TaskStream <no-reply@pireon.dev> Remitente de esos correos
WEB_URL — Dirección pública de la web, para los enlaces de los correos
ATTACHMENTS_MAX_MB 25 Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición)

La web no tiene configuración de entorno: llama a la API en su mismo origen (/api, /graphql).

7.3 Escalado

  • API: es stateless (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás del balanceador. El WebSocket necesita que el balanceador permita Upgrade. La excepción son los adjuntos: se guardan en disco (ATTACHMENTS_DIR), así que varias réplicas tienen que compartir ese volumen.
  • Relay: cada instancia publica eventos al stream; FOR UPDATE SKIP LOCKED evita duplicados.
  • Proyecciones: el EventsBridge procesa los eventos en orden. Lo más sencillo es dejarlo activo en una sola instancia (EVENTS_BRIDGE_ENABLED=true) y desactivarlo en las demás. Varias instancias en el mismo consumer group se reparten los eventos y pierden el orden entre ellos.

7.4 Antes de producción

  • Sustituir POSTGRES_SYNCHRONIZE por migraciones de TypeORM.
  • Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
  • Servir por HTTPS (la web usa wss:// automáticamente cuando la página va por HTTPS).
  • Revisar GRAPHQL_PLAYGROUND (introspección) según el entorno.
  • Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (attachments-data): los eventos dicen qué archivos hay, pero los bytes solo están ahí.

7.5 Reconstruir el read model

MongoDB solo contiene proyecciones, así que se puede regenerar desde el event store:

  1. Para la API (o desactiva el bridge en todas las instancias).
  2. Borra la base de lectura (taskstream_read) salvo la colección users, que no sale de los eventos sino de Pireon.
  3. Marca los eventos como no publicados: UPDATE events SET published_at = NULL;
  4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas. Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.

Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa de los tickets anteriores: sin reconstruir, la línea temporal (issue_timeline) solo empieza con los cambios nuevos y las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no lo necesita: la API la rellena al arrancar en los tickets que no la tienen.