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/:
| Imagen | Dockerfile | Descripción |
|---|---|---|
| API server | Dockerfile.api | Servidor HTTP Hono en puerto 3002 |
| BullMQ worker | Dockerfile.worker | Procesador de jobs (sin puerto HTTP) |
| Portal | Dockerfile.portal | React PWA + servidor SPA nginx |
| Landing | Dockerfile.landing | App React de marketing + nginx |
Imagen base
Todas las imágenes usan node:22-alpine (multi-stage):
# 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=productionLa 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
# 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:
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
# 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 workerEl docker-compose.yml local incluye:
postgres:16-alpineen puerto 5434 (db: sipsop, user: sipsop, password: sipsop_dev)redis:7-alpineen puerto 6380mariadb:11en puerto 3307 (simula FreePBX asteriskcdrdb, con seed desdeseed-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.
| Variable | Requerida | Default | Descripción |
|---|---|---|---|
DATABASE_URL | Sí | — | Connection string de PostgreSQL: postgresql://user:password@host:port/dbname |
REDIS_URL | No | redis://localhost:6379 | Conexión Redis: redis://host:port |
BETTER_AUTH_SECRET | Sí | — | Mínimo 32 caracteres. Firma session tokens. |
BETTER_AUTH_URL | Sí | — | URL pública de la API: https://api.sipsop.net |
BETTER_AUTH_TRUSTED_ORIGINS | Sí | — | URL del portal para CORS: https://app.sipsop.net |
CORS_ORIGIN | Sí | — | Origen del portal: https://app.sipsop.net |
API_PORT | No | 3002 | Puerto HTTP del servidor API |
STRIPE_SECRET_KEY | No | '' | API key de Stripe (sk_live_...) |
STRIPE_WEBHOOK_SECRET | No | '' | Signing secret del webhook de Stripe (whsec_...) |
FREEPBX_DB_HOST | No | localhost | Host MariaDB de FreePBX |
FREEPBX_DB_PORT | No | 3306 | Puerto MariaDB de FreePBX |
FREEPBX_DB_USER | No | — | Usuario MariaDB de FreePBX (solo lectura) |
FREEPBX_DB_PASSWORD | No | — | Contraseña MariaDB de FreePBX |
FREEPBX_DB_NAME | No | asteriskcdrdb | Nombre de la base de datos CDR de FreePBX |
Archivo .env de dev (raíz del proyecto)
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=asteriskcdrdbCargado 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.
# 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:
{
"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 App | Ingress | Min replicas | Max replicas |
|---|---|---|---|
sipsop-api | HTTPS externo, puerto 3002 | 1 | 5 |
sipsop-worker | Ninguno (sin ingress) | 1 | 3 |
sipsop-portal | HTTPS externo, puerto 80 | 1 | 3 |
sipsop-landing | HTTPS externo, puerto 80 | 1 | 2 |
El worker NO necesita ingress externo — solo se conecta de salida a Postgres, Redis y FreePBX.
Crear environment y apps
# 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 3Dominio personalizado
Mapear api.sipsop.net → Container App sipsop-api, app.sipsop.net → sipsop-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:
| Cola | Schedule | Qué hace |
|---|---|---|
cdr-sync | Cada 15 minutos | Hace polling de MariaDB de FreePBX, normaliza CDRs, inserta en cdr_records con ON CONFLICT DO NOTHING |
billing-calc | Diariamente a las 2am (cron) | Recalcula totales para billing_periods abiertos |
invoice-gen | 1ro de cada mes a las 3am | Cierra períodos abiertos, genera filas de invoices, crea factura en Stripe |
stripe-sync | Cada 6 horas | Sincroniza 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:
# 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)"
doneRestricció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 tablacdr
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
pnpm install --frozen-lockfile- Ejecutar tests de API:
pnpm --filter @sipsop/api test - Ejecutar tests del portal:
pnpm --filter @sipsop/portal test - Build del portal:
pnpm --filter @sipsop/portal build - 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:
# 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_SHAMigraciones 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:
# 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:migrateRLS — 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:
DATABASE_URL="<url_prod>" pnpm --filter @sipsop/db db:apply-rls-policiesNunca 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:
STRIPE_SECRET_KEY="sk_live_..." pnpm --filter @sipsop/db db:seed-stripeEsto 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
| Servicio | Puerto local | Puerto container |
|---|---|---|
| API | 3002 | 3002 |
| Portal | 5175 | 80 |
| Landing | 5176 | 80 |
| Docs | 5180 | — |
| PostgreSQL | 5434 | 5432 |
| Redis | 6380 | 6379 |
| Mock FreePBX (dev) | 3307 | 3306 |
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
| Target | Subdominio | Contenido | Acceso |
|---|---|---|---|
public | docs.sipsop.net | /product/ | Público — sin auth |
partners | partners.sipsop.net | /commissions/ | Cloudflare Access (emails de vendedores aprobados) |
internal | internal.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:
wrangler pages project create sipsop-docs-public
wrangler pages project create sipsop-docs-partners
wrangler pages project create sipsop-docs-internalLuego agregar dominios personalizados en el dashboard de CF Pages:
sipsop-docs-public→docs.sipsop.netsipsop-docs-partners→partners.sipsop.netsipsop-docs-internal→internal.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 PagesCLOUDFLARE_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.comcon 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:
pnpm docs:dev
# → http://localhost:5180 con las 3 secciones (product, commissions, internal)Build de un target específico localmente:
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 serieCó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:
node docs/site/.vitepress/scripts/postbuild.mjsCI/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):
# 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-internalAgregar un nuevo vendedor al allowlist de partners
- Crear la fila
commission_repen la DB (setear status aactive). - Agregar el email del rep a la política de CF Access para
partners.sipsop.neten el dashboard de Zero Trust (Application → Edit → Policies → agregar email al allowlist). - 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
| Escenario | RTO objetivo | Path de recuperación |
|---|---|---|
| Crash del container API | < 2 min | Container Apps auto-restart (min-replicas = 1) |
| Crash del worker | < 5 min | Auto-restart. Los jobs en la cola BullMQ son durables (persistencia Redis). |
| Fallo de Redis | < 15 min | Los jobs BullMQ re-encolan al reconectar Redis. Pérdida de sesiones (usuarios deben re-loguearse). |
| Fallo de Postgres | Depende | El servidor managed de Azure maneja failover. Ver Runbooks. |
| Pérdida completa del entorno | < 4 horas | Re-crear Container Apps desde imágenes + restaurar DB desde backup de Azure. |