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>
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.ymles 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 → servicioweb, puerto80).webyapideclaranexposepara documentarlo.docker-compose.override.ymlpublica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin-f, es decir, en local; Dokploy usa-f docker-compose.ymly 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 cidel workspace.@pi/uiviene del Nexus privado; el token llega como build secret desde la variableNEXUS_TOKEN(secrets.nexus_tokenendocker-compose.yml), se escribe en unnpmrctemporal y se borra en el mismo paso, así que nunca queda en una capa. SinNEXUS_TOKENla build falla en ese paso. api:nx run api:prunegenera el bundle con unpackage.jsony un lockfile podados a partir deapps/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 aapps/api/package.json.mcp: el servidor MCP, podado igual que la API (nx run mcp:prune), sobre Node 24. Su servicio endocker-compose.ymlva en el perfilmcp: 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 aindex.html, caché larga para los bundles con hash y sin caché paraindex.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 LOCKEDevita duplicados. - Proyecciones: el
EventsBridgeprocesa 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_SYNCHRONIZEpor 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:
- Para la API (o desactiva el bridge en todas las instancias).
- Borra la base de lectura (
taskstream_read) salvo la colecciónusers, que no sale de los eventos sino de Pireon. - Marca los eventos como no publicados:
UPDATE events SET published_at = NULL; - 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.