Files
task-stream/docs/07-despliegue.md
Josep Piguillem a32c937765 fix(api): a read-side failure waits for its event instead of losing it
The Redis consumer used to leave a failed event pending and go on to the
next ones, retrying it only at the next start. With Mongo down for a
while, a later change of the same ticket was then projected first, and
the version check dropped the earlier one for good (a status change
could vanish from the read model), besides the read side lagging until
a restart.

Now a failed event is retried in place, waiting longer each time (up to
30 s), and the next one waits for it. Failures of the infrastructure
(Mongo, Redis, the network: isTransientError) wait as long as it takes,
so the read side catches up by itself; an event that keeps failing on
its own goes, after 5 attempts, to <stream>:dead with its error, so it
does not hold up the rest.

GET /api/health reports the way from write to read (events waiting in
the outbox, pending to be projected, set aside) and says `degraded`,
still with 200, when something is stuck.

Tried for real: with Mongo stopped, two status changes went in; the
first failed to project with ECONNREFUSED, and once Mongo was back the
ticket's history had both steps, in order, without restarting the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:24:19 +02:00

16 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. GET /api/health responde 503 si cae una base de datos; con las tres en pie, status: degraded (200) avisa de eventos que no llegan a la lectura (events.problems). Los eventos apartados por fallar una y otra vez se ven con redis-cli XRANGE task-stream:events:dead - +; arreglado el fallo, se reconstruye la lectura (abajo) o se vuelven a publicar.

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_PASSWORD_WEBHOOK_SECRET — El del webhook del cliente de contraseña, si es otro cliente
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
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY — Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con npx web-push generate-vapid-keys; cambiarlas anula las suscripciones
VAPID_SUBJECT mailto:no-reply@pireon.dev Contacto (mailto: o https:) que los servicios push pueden usar si algo va mal
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)
WEBHOOKS_ALLOW_PRIVATE false Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos)
REMINDERS_ENABLED true Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido
REMINDERS_TIMEZONE Europe/Madrid Zona horaria en la que cambia el día para los recordatorios
TRASH_DAYS 30 Días que un ticket pasa en la papelera antes de borrarse del todo

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.