Skip to content

Deploy e infraestructura

Esta página es la referencia de "deploy desde cero" para SipSop. Cubre las imágenes Docker, el target de Azure Container Apps, secretos en Key Vault, configuración del BullMQ worker, variables de entorno, restricciones de FreePBX, CI/CD y estrategia de migraciones de base de datos.

Visión general de la arquitectura

┌─────────────────────────────────────────────────────────────────┐
│ Azure Container Apps Environment                                 │
│                                                                  │
│  ┌──────────────┐  ┌───────────────┐  ┌────────────────────┐  │
│  │  sipsop-api   │  │ sipsop-worker  │  │  sipsop-landing    │  │
│  │  (Hono HTTP)  │  │ (BullMQ jobs)  │  │  (nginx + React)   │  │
│  │  puerto 3002  │  │  sin HTTP      │  │  puerto 80         │  │
│  └──────────────┘  └───────────────┘  └────────────────────┘  │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  sipsop-portal (nginx + React PWA)   puerto 80            │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
        │                        │                       │
        ▼                        ▼                       ▼
Azure Database for          Azure Cache              Azure Key Vault
PostgreSQL Flexible         for Redis                (secretos)
Server (PostgreSQL 16)

FreePBX en pbx.sopinf.xyz es un sistema externo pre-existente. El worker de la API se conecta a su base de datos MariaDB solo lectura para sincronización de CDR.

Imágenes Docker

Cuatro Dockerfiles en infrastructure/docker/:

ImagenDockerfileDescripción
API serverDockerfile.apiServidor HTTP Hono en puerto 3002
BullMQ workerDockerfile.workerProcesador de jobs (sin puerto HTTP)
PortalDockerfile.portalReact PWA + servidor SPA nginx
LandingDockerfile.landingApp React de marketing + nginx

Imagen base

Todas las imágenes usan node:22-alpine (multi-stage):

dockerfile
# Stage 1: Instalar dependencias
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app

COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/

RUN pnpm install --frozen-lockfile

# Stage 2: Runtime
FROM node:22-alpine
# ... copiar desde deps, setear NODE_ENV=production

La imagen del worker es idéntica a la de la API excepto el CMD:

  • API: CMD ["npx", "tsx", "apps/api/src/index.ts"]
  • Worker: CMD ["npx", "tsx", "apps/api/src/jobs/worker.ts"]

Build de imágenes

bash
# Desde la raíz del proyecto
docker build -f infrastructure/docker/Dockerfile.api -t sipsop-api:latest .
docker build -f infrastructure/docker/Dockerfile.worker -t sipsop-worker:latest .
docker build -f infrastructure/docker/Dockerfile.portal -t sipsop-portal:latest .
docker build -f infrastructure/docker/Dockerfile.landing -t sipsop-landing:latest .

Config nginx SPA

Tanto portal como landing usan infrastructure/docker/nginx-spa.conf:

nginx
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;  # Fallback de React Router
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";  # Cache de assets de larga vida
    }
}

Desarrollo local

bash
# Iniciar infraestructura (Postgres :5434, Redis :6380, mock MariaDB FreePBX :3307)
docker compose -f infrastructure/docker/docker-compose.yml up -d

# Instalar dependencias
pnpm install

# Configurar DB
pnpm --filter @sipsop/db db:push
pnpm --filter @sipsop/db db:apply-rls
pnpm --filter @sipsop/db db:seed

# Iniciar todas las apps en paralelo (portal :5175, landing :5176, api :3002)
pnpm dev

# Iniciar BullMQ worker (terminal separada)
pnpm --filter @sipsop/api worker

El docker-compose.yml local incluye:

  • postgres:16-alpine en puerto 5434 (db: sipsop, user: sipsop, password: sipsop_dev)
  • redis:7-alpine en puerto 6380
  • mariadb:11 en puerto 3307 (simula FreePBX asteriskcdrdb, con seed desde seed-freepbx-cdr.sql)

Variables de entorno

Todas las variables validadas por Valibot al iniciar (apps/api/src/lib/env.ts). Si la validación falla, el proceso sale con un mensaje de error claro.

VariableRequeridaDefaultDescripción
DATABASE_URLConnection string de PostgreSQL: postgresql://user:password@host:port/dbname
REDIS_URLNoredis://localhost:6379Conexión Redis: redis://host:port
BETTER_AUTH_SECRETMínimo 32 caracteres. Firma session tokens.
BETTER_AUTH_URLURL pública de la API: https://api.sipsop.net
BETTER_AUTH_TRUSTED_ORIGINSURL del portal para CORS: https://app.sipsop.net
CORS_ORIGINOrigen del portal: https://app.sipsop.net
API_PORTNo3002Puerto HTTP del servidor API
STRIPE_SECRET_KEYNo''API key de Stripe (sk_live_...)
STRIPE_WEBHOOK_SECRETNo''Signing secret del webhook de Stripe (whsec_...)
FREEPBX_DB_HOSTNolocalhostHost MariaDB de FreePBX
FREEPBX_DB_PORTNo3306Puerto MariaDB de FreePBX
FREEPBX_DB_USERNoUsuario MariaDB de FreePBX (solo lectura)
FREEPBX_DB_PASSWORDNoContraseña MariaDB de FreePBX
FREEPBX_DB_NAMENoasteriskcdrdbNombre de la base de datos CDR de FreePBX

Archivo .env de dev (raíz del proyecto)

bash
DATABASE_URL=postgresql://sipsop:sipsop_dev@localhost:5434/sipsop
REDIS_URL=redis://localhost:6380
BETTER_AUTH_SECRET=dev-secret-minimum-32-characters-long!!
BETTER_AUTH_URL=http://localhost:3002
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5175
CORS_ORIGIN=http://localhost:5175
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
FREEPBX_DB_HOST=localhost
FREEPBX_DB_PORT=3307
FREEPBX_DB_USER=root
FREEPBX_DB_PASSWORD=freepbx_dev
FREEPBX_DB_NAME=asteriskcdrdb

Cargado via tsx --env-file=../../.env apps/api/src/index.ts.

Azure Key Vault (producción)

Cero archivos .env en producción. Todos los secretos están en Azure Key Vault.

bash
# Crear Key Vault
az keyvault create --name sipsop-kv --resource-group sipsop-rg --location eastus

# Almacenar secretos
az keyvault secret set --vault-name sipsop-kv --name sipsop-database-url \
  --value "postgresql://sipsop:PASSWORD@sipsop-postgres.postgres.database.azure.com:5432/sipsop?sslmode=require"

az keyvault secret set --vault-name sipsop-kv --name sipsop-auth-secret \
  --value "TU_SECRETO_MINIMO_32_CHARS"

az keyvault secret set --vault-name sipsop-kv --name sipsop-stripe-key \
  --value "sk_live_..."

az keyvault secret set --vault-name sipsop-kv --name sipsop-stripe-webhook-secret \
  --value "whsec_..."

az keyvault secret set --vault-name sipsop-kv --name sipsop-freepbx-password \
  --value "..."

En Azure Container Apps, referenciar los secretos via Key Vault references en la sección de variables de entorno:

json
{
  "name": "DATABASE_URL",
  "secretRef": "sipsop-database-url",
  "keyVaultUrl": "https://sipsop-kv.vault.azure.net/secrets/sipsop-database-url"
}

La managed identity de Container Apps necesita el rol Key Vault Secrets User en el vault.

Despliegue en Azure Container Apps

Setup recomendado

Container AppIngressMin replicasMax replicas
sipsop-apiHTTPS externo, puerto 300215
sipsop-workerNinguno (sin ingress)13
sipsop-portalHTTPS externo, puerto 8013
sipsop-landingHTTPS externo, puerto 8012

El worker NO necesita ingress externo — solo se conecta de salida a Postgres, Redis y FreePBX.

Crear environment y apps

bash
# Crear environment
az containerapp env create \
  --name sipsop-env \
  --resource-group sipsop-rg \
  --location eastus

# Crear API app
az containerapp create \
  --name sipsop-api \
  --resource-group sipsop-rg \
  --environment sipsop-env \
  --image sipsopacr.azurecr.io/sipsop-api:latest \
  --target-port 3002 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 5

# Crear worker (sin ingress)
az containerapp create \
  --name sipsop-worker \
  --resource-group sipsop-rg \
  --environment sipsop-env \
  --image sipsopacr.azurecr.io/sipsop-worker:latest \
  --ingress disabled \
  --min-replicas 1 \
  --max-replicas 3

Dominio personalizado

Mapear api.sipsop.net → Container App sipsop-api, app.sipsop.netsipsop-portal, etc. via custom domain + Managed Certificate de Azure Container Apps.

BullMQ workers

Cuatro colas de jobs definidas en apps/api/src/jobs/worker.ts:

ColaScheduleQué hace
cdr-syncCada 15 minutosHace polling de MariaDB de FreePBX, normaliza CDRs, inserta en cdr_records con ON CONFLICT DO NOTHING
billing-calcDiariamente a las 2am (cron)Recalcula totales para billing_periods abiertos
invoice-gen1ro de cada mes a las 3amCierra períodos abiertos, genera filas de invoices, crea factura en Stripe
stripe-syncCada 6 horasSincroniza estado de suscripción Stripe → tenants.stripe_status y tenants.status

El proceso del worker corre separado de la API (Dockerfile.worker). Comparten el mismo codebase pero solo el worker inicia las instancias de Worker y QueueScheduler de BullMQ.

Monitorear colas

BullMQ almacena datos de colas en Redis. Verificar salud de colas:

bash
# Jobs esperando
redis-cli -p 6380 LLEN bull:cdr-sync:wait

# Jobs activos
redis-cli -p 6380 LLEN bull:cdr-sync:active

# Jobs fallidos
redis-cli -p 6380 LLEN bull:cdr-sync:failed

# Todas las colas a la vez
for q in cdr-sync billing-calc invoice-gen stripe-sync; do
  echo "$q: wait=$(redis-cli -p 6380 LLEN bull:$q:wait) active=$(redis-cli -p 6380 LLEN bull:$q:active) failed=$(redis-cli -p 6380 LLEN bull:$q:failed)"
done

Restricción FreePBX

SOLO LECTURA

FreePBX en pbx.sopinf.xyz es un PBX de producción sirviendo llamadas en vivo. SipSop NUNCA debe modificarlo:

  • Sin cambios de schema en su MariaDB asteriskcdrdb
  • Sin cambios a la configuración de FreePBX, extensiones o dialplan
  • Sin auto-patches ni auto-reinicios
  • El job de CDR sync usa un usuario MariaDB de solo lectura con privilegio SELECT únicamente en la tabla cdr

El entorno dev usa un container mariadb:11 (puerto 3307) con una tabla cdr con seed que imita el schema de producción. Nunca apuntar herramientas de dev al PBX de producción.

CI/CD — GitHub Actions

Tres archivos de workflow en .github/workflows/:

ci.yml — corre en cada PR y push a main

  1. pnpm install --frozen-lockfile
  2. Ejecutar tests de API: pnpm --filter @sipsop/api test
  3. Ejecutar tests del portal: pnpm --filter @sipsop/portal test
  4. Build del portal: pnpm --filter @sipsop/portal build
  5. Build del landing: pnpm --filter @sipsop/landing build

Los tests corren con DB mockeada (sin conexión real a Postgres en CI). Las env vars están hardcodeadas en el workflow para modo test.

typecheck.yml

Ejecuta tsc --noEmit en todos los workspaces para detectar errores de tipos.

claude-code-review.yml y claude.yml

Integración de revisión con Claude Code. Corre en PRs etiquetados para revisión.

Workflow de despliegue (aún no automatizado)

El despliegue actual es manual. Para desplegar una nueva versión:

bash
# 1. Build y push a Azure Container Registry
az acr build --registry sipsopacr --image sipsop-api:$GIT_SHA infrastructure/docker/ \
  --file infrastructure/docker/Dockerfile.api

az acr build --registry sipsopacr --image sipsop-worker:$GIT_SHA infrastructure/docker/ \
  --file infrastructure/docker/Dockerfile.worker

# 2. Actualizar Container Apps a la nueva revisión
az containerapp update \
  --name sipsop-api \
  --resource-group sipsop-rg \
  --image sipsopacr.azurecr.io/sipsop-api:$GIT_SHA

az containerapp update \
  --name sipsop-worker \
  --resource-group sipsop-rg \
  --image sipsopacr.azurecr.io/sipsop-worker:$GIT_SHA

Migraciones de base de datos

Desarrollo: db:push

Para dev, usar pnpm --filter @sipsop/db db:push — Drizzle introspecciona el schema actual y aplica cambios directamente (sin crear archivo de migración). Rápido pero no seguro para producción.

Producción: db:migrate

Para staging y producción, usar pnpm --filter @sipsop/db db:migrate que aplica archivos de migración pendientes de packages/db/src/migrations/.

Generar un archivo de migración antes de desplegar:

bash
# Después de cambiar archivos de schema:
pnpm --filter @sipsop/db db:generate
# → crea packages/db/src/migrations/XXXX_descripcion.sql

# Revisar el SQL generado antes de aplicar
cat packages/db/src/migrations/XXXX_descripcion.sql

# Aplicar en producción (ejecutar antes de desplegar la nueva versión de la app)
DATABASE_URL="<url_prod>" pnpm --filter @sipsop/db db:migrate

RLS — re-aplicar después de cambios de schema

deploy-api.yml ya lo corre después de cada step de migraciones, así que rara vez hace falta a mano. Para re-aplicar manualmente:

bash
DATABASE_URL="<url_prod>" pnpm --filter @sipsop/db db:apply-rls-policies

Nunca corras db:apply-rls contra producción

db:apply-rls también corre el bootstrap del rol, que rota la password de sipsop_app. La real vive en Key Vault (database-url-app); sobrescribirla tumba la API. db:apply-rls-policies solo aplica policies y grants — nunca toca credenciales.

Seed de productos de Stripe

Los productos/precios de Stripe se crean una vez por entorno (dev, producción). No es una migración — ejecutar manualmente:

bash
STRIPE_SECRET_KEY="sk_live_..." pnpm --filter @sipsop/db db:seed-stripe

Esto crea productos de Stripe para los planes Starter/Business/Enterprise y guarda los IDs de precio de Stripe de vuelta en la tabla plans.

Referencia de puertos

ServicioPuerto localPuerto container
API30023002
Portal517580
Landing517680
Docs5180
PostgreSQL54345432
Redis63806379
Mock FreePBX (dev)33073306

Deploy de documentación

El sitio de documentación de SipSop se construye desde una única fuente (docs/site/) y se despliega en 3 proyectos de Cloudflare Pages separados, cada uno sirviendo a una audiencia diferente bajo su propio subdominio.

Arquitectura

TargetSubdominioContenidoAcceso
publicdocs.sipsop.net/product/Público — sin auth
partnerspartners.sipsop.net/commissions/Cloudflare Access (emails de vendedores aprobados)
internalinternal.sipsop.net/internal/Cloudflare Access (Google SSO @sopinf.com)

Los 3 builds vienen de la misma fuente docs/site/. Una env var BUILD_TARGET controla qué secciones se incluyen y qué homepage se muestra. VitePress srcExclude elimina las secciones no deseadas a nivel de fuente, para que nunca aparezcan en el output.

Dev local (pnpm docs:dev) corre con BUILD_TARGET=full — las 3 secciones visibles juntas. Los deploys de producción siempre usan uno de los 3 targets.

Setup inicial (Marco)

1. Crear los 3 proyectos de Cloudflare Pages

Via el dashboard de Cloudflare o wrangler CLI:

bash
wrangler pages project create sipsop-docs-public
wrangler pages project create sipsop-docs-partners
wrangler pages project create sipsop-docs-internal

Luego agregar dominios personalizados en el dashboard de CF Pages:

  • sipsop-docs-publicdocs.sipsop.net
  • sipsop-docs-partnerspartners.sipsop.net
  • sipsop-docs-internalinternal.sipsop.net

2. Agregar registros DNS CNAME

En Cloudflare DNS para la zona sipsop.net, agregar un CNAME para cada subdominio apuntando al URL *.pages.dev del proyecto de Pages. El setup de dominio personalizado de CF Pages maneja esto automáticamente cuando agregás el dominio en el dashboard.

3. Secrets de GitHub

Generar un token de API de CF con permiso Cloudflare Pages — Edit. Agregar a los secrets del repositorio de GitHub:

  • CLOUDFLARE_API_TOKEN — token con permiso de edición en Pages
  • CLOUDFLARE_ACCOUNT_ID — tu account ID de CF (visible en el sidebar del dashboard de CF)

4. Políticas de Cloudflare Access

Configurar en el dashboard de Cloudflare Zero Trust (one.dash.cloudflare.com):

Para partners.sipsop.net:

  • Tipo de aplicación: Self-hosted
  • Dominio de la aplicación: partners.sipsop.net
  • Identity provider: One-time PIN (OTP por email)
  • Regla de acceso: Email está en lista → allowlist de emails de vendedores aprobados (extraída de commission_reps.email)
  • Duración de sesión: 24h

Para internal.sipsop.net:

  • Tipo de aplicación: Self-hosted
  • Dominio de la aplicación: internal.sipsop.net
  • Identity provider: Google Workspace (o email termina en @sopinf.com con One-time PIN)
  • Regla de acceso: Email termina en @sopinf.com
  • Duración de sesión: 24h

Para docs.sipsop.net:

  • Sin política de CF Access — completamente público.

Operaciones del día a día

Dev local — preview completo:

bash
pnpm docs:dev
# → http://localhost:5180 con las 3 secciones (product, commissions, internal)

Build de un target específico localmente:

bash
pnpm docs:build:public    # → docs/site/.vitepress/dist-public/
pnpm docs:build:partners  # → docs/site/.vitepress/dist-partners/
pnpm docs:build:internal  # → docs/site/.vitepress/dist-internal/
pnpm docs:build:all       # → construye los 3 en serie

Cómo funciona el swap de build:

Antes de cada build de target, prebuild.mjs hace backup de index.md y es/index.md y copia la homepage específica del target (homepages/<target>.en.md) en su lugar. Después del build, postbuild.mjs restaura los originales. El working tree queda limpio después de cada build.

Si un build crashea entre prebuild y postbuild, restaurar manualmente:

bash
node docs/site/.vitepress/scripts/postbuild.mjs

CI/CD — push a main:

El action .github/workflows/docs-deploy.yml corre en cada push a main que toca docs/site/**. Construye los 3 targets en paralelo (matrix) y despliega cada uno a su proyecto de CF Pages via wrangler pages deploy.

Deploy manual (emergencia):

bash
# Build y deploy del target public
pnpm docs:build:public
wrangler pages deploy docs/site/.vitepress/dist-public --project-name=sipsop-docs-public

# O los 3 a la vez
pnpm docs:build:all
wrangler pages deploy docs/site/.vitepress/dist-public   --project-name=sipsop-docs-public
wrangler pages deploy docs/site/.vitepress/dist-partners --project-name=sipsop-docs-partners
wrangler pages deploy docs/site/.vitepress/dist-internal --project-name=sipsop-docs-internal

Agregar un nuevo vendedor al allowlist de partners

  1. Crear la fila commission_rep en la DB (setear status a active).
  2. Agregar el email del rep a la política de CF Access para partners.sipsop.net en el dashboard de Zero Trust (Application → Edit → Policies → agregar email al allowlist).
  3. Enviarle al rep el link partners.sipsop.net — CF Access le pedirá OTP por email en la primera visita.

Automatización futura: Un job de BullMQ puede llamar a la API de CF Access para agregar/remover emails a medida que las filas commission_rep se activan/desactivan, eliminando el paso manual.

Resumen de disaster recovery

EscenarioRTO objetivoPath de recuperación
Crash del container API< 2 minContainer Apps auto-restart (min-replicas = 1)
Crash del worker< 5 minAuto-restart. Los jobs en la cola BullMQ son durables (persistencia Redis).
Fallo de Redis< 15 minLos jobs BullMQ re-encolan al reconectar Redis. Pérdida de sesiones (usuarios deben re-loguearse).
Fallo de PostgresDependeEl servidor managed de Azure maneja failover. Ver Runbooks.
Pérdida completa del entorno< 4 horasRe-crear Container Apps desde imágenes + restaurar DB desde backup de Azure.

Documentación de SipSop. Producto operado por Sopinf Tech LLC.