# 7. Despliegue ## 7.1 Docker ```sh 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](09-mcp.md), 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`: ```sh # 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=`, 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 ` | 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.