Deploy & Infrastructure
This page is the "deploy from scratch" reference for SipSop. It covers the Docker images, Azure Container Apps target, Key Vault secrets, BullMQ worker setup, environment variables, FreePBX constraints, CI/CD, and database migration strategy.
Architecture overview
┌─────────────────────────────────────────────────────────────────┐
│ Azure Container Apps Environment │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌────────────────────┐ │
│ │ sipsop-api │ │ sipsop-worker │ │ sipsop-landing │ │
│ │ (Hono HTTP) │ │ (BullMQ jobs) │ │ (nginx + React) │ │
│ │ port 3002 │ │ no HTTP port │ │ port 80 │ │
│ └──────────────┘ └───────────────┘ └────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ sipsop-portal (nginx + React PWA) port 80 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
Azure Database for Azure Cache Azure Key Vault
PostgreSQL Flexible for Redis (secrets)
Server (PostgreSQL 16)FreePBX at pbx.sopinf.xyz is an external, pre-existing system. The API worker connects to its MariaDB database read-only for CDR sync.
Docker images
Four Dockerfiles in infrastructure/docker/:
| Image | Dockerfile | Description |
|---|---|---|
| API server | Dockerfile.api | Hono HTTP server on port 3002 |
| BullMQ worker | Dockerfile.worker | Job processor (no HTTP port) |
| Portal | Dockerfile.portal | React PWA + nginx SPA server |
| Landing | Dockerfile.landing | Marketing React app + nginx |
Base image
All images use node:22-alpine (multi-stage):
# Stage 1: Install dependencies
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
# ... copy from deps, set NODE_ENV=productionThe worker image is identical to the API image except the CMD:
- API:
CMD ["npx", "tsx", "apps/api/src/index.ts"] - Worker:
CMD ["npx", "tsx", "apps/api/src/jobs/worker.ts"]
Building images
# From project root
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 .nginx SPA config
Both portal and landing use infrastructure/docker/nginx-spa.conf:
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html; # React Router fallback
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable"; # Long-lived asset cache
}
}Local development
# Start infrastructure (Postgres :5434, Redis :6380, FreePBX mock MariaDB :3307)
docker compose -f infrastructure/docker/docker-compose.yml up -d
# Install dependencies
pnpm install
# Set up DB
pnpm --filter @sipsop/db db:push
pnpm --filter @sipsop/db db:apply-rls
pnpm --filter @sipsop/db db:seed
# Start all apps in parallel (portal :5175, landing :5176, api :3002)
pnpm dev
# Start BullMQ worker (separate terminal)
pnpm --filter @sipsop/api workerThe local docker-compose.yml includes:
postgres:16-alpineon port 5434 (db: sipsop, user: sipsop, password: sipsop_dev)redis:7-alpineon port 6380mariadb:11on port 3307 (simulates FreePBX asteriskcdrdb, seeds fromseed-freepbx-cdr.sql)
Environment variables
All variables validated by Valibot at startup (apps/api/src/lib/env.ts). If validation fails, the process exits with a clear error message.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | Yes | — | PostgreSQL connection string: postgresql://user:password@host:port/dbname |
REDIS_URL | No | redis://localhost:6379 | Redis connection: redis://host:port |
BETTER_AUTH_SECRET | Yes | — | Min 32 characters. Signs session tokens. |
BETTER_AUTH_URL | Yes | — | Public URL of the API: https://api.sipsop.net |
BETTER_AUTH_TRUSTED_ORIGINS | Yes | — | Portal URL for CORS: https://app.sipsop.net |
CORS_ORIGIN | Yes | — | Portal origin: https://app.sipsop.net |
API_PORT | No | 3002 | HTTP port for the API server |
STRIPE_SECRET_KEY | No | '' | Stripe API key (sk_live_...) |
STRIPE_WEBHOOK_SECRET | No | '' | Stripe webhook signing secret (whsec_...) |
FREEPBX_DB_HOST | No | localhost | FreePBX MariaDB host |
FREEPBX_DB_PORT | No | 3306 | FreePBX MariaDB port |
FREEPBX_DB_USER | No | — | FreePBX MariaDB read-only user |
FREEPBX_DB_PASSWORD | No | — | FreePBX MariaDB password |
FREEPBX_DB_NAME | No | asteriskcdrdb | FreePBX CDR database name |
Dev .env file (project root)
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=asteriskcdrdbLoaded via tsx --env-file=../../.env apps/api/src/index.ts.
Azure Key Vault (production)
Zero .env files in production. All secrets are in Azure Key Vault.
# Create Key Vault
az keyvault create --name sipsop-kv --resource-group sipsop-rg --location eastus
# Store secrets
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 "YOUR_MIN_32_CHAR_SECRET"
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 "..."In Azure Container Apps, reference secrets via Key Vault references in the environment variables section:
{
"name": "DATABASE_URL",
"secretRef": "sipsop-database-url",
"keyVaultUrl": "https://sipsop-kv.vault.azure.net/secrets/sipsop-database-url"
}The Container Apps managed identity needs Key Vault Secrets User role on the vault.
Azure Container Apps deployment
Recommended setup
| Container App | Ingress | Min replicas | Max replicas |
|---|---|---|---|
sipsop-api | External HTTPS, port 3002 | 1 | 5 |
sipsop-worker | None (no ingress) | 1 | 3 |
sipsop-portal | External HTTPS, port 80 | 1 | 3 |
sipsop-landing | External HTTPS, port 80 | 1 | 2 |
The worker does NOT need external ingress — it only connects outbound to Postgres, Redis, and FreePBX.
Create the environment and apps
# Create environment
az containerapp env create \
--name sipsop-env \
--resource-group sipsop-rg \
--location eastus
# Create 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
# Create worker (no 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 3Custom domain
Map api.sipsop.net → sipsop-api Container App, app.sipsop.net → sipsop-portal, etc. via Azure Container Apps custom domain + Managed Certificate.
BullMQ workers
Four job queues defined in apps/api/src/jobs/worker.ts:
| Queue | Schedule | What it does |
|---|---|---|
cdr-sync | Every 15 minutes | Polls FreePBX MariaDB, normalizes CDRs, inserts into cdr_records with ON CONFLICT DO NOTHING |
billing-calc | Daily at 2am (cron) | Recalculates totals for open billing_periods |
invoice-gen | 1st of every month at 3am | Closes open periods, generates invoices rows, creates Stripe invoice |
stripe-sync | Every 6 hours | Syncs Stripe subscription status → tenants.stripe_status and tenants.status |
The worker process runs separately from the API (Dockerfile.worker). They share the same codebase but only the worker starts the BullMQ Worker and QueueScheduler instances.
Monitor queues
BullMQ stores queue data in Redis. Check queue health:
# Waiting jobs
redis-cli -p 6380 LLEN bull:cdr-sync:wait
# Active jobs
redis-cli -p 6380 LLEN bull:cdr-sync:active
# Failed jobs
redis-cli -p 6380 LLEN bull:cdr-sync:failed
# All queues at once
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)"
doneFreePBX constraint
READ-ONLY
FreePBX at pbx.sopinf.xyz is a production PBX serving live calls. It must NEVER be modified by SipSop:
- No schema changes to its
asteriskcdrdbMariaDB - No changes to FreePBX configuration, extensions, or dialplan
- No auto-patches or auto-restarts
- The CDR sync job uses a read-only MariaDB user with
SELECTprivilege only on thecdrtable
The dev environment uses a mariadb:11 container (port 3307) with a seeded cdr table that mimics the production schema. Never point dev tooling at the production PBX.
CI/CD — GitHub Actions
Three workflow files in .github/workflows/:
ci.yml — runs on every PR and push to main
pnpm install --frozen-lockfile- Run API tests:
pnpm --filter @sipsop/api test - Run portal tests:
pnpm --filter @sipsop/portal test - Build portal:
pnpm --filter @sipsop/portal build - Build landing:
pnpm --filter @sipsop/landing build
Tests run with mocked DB (no actual Postgres connection in CI). Env vars are hardcoded in the workflow for test mode.
typecheck.yml
Runs tsc --noEmit across all workspaces to catch type errors.
claude-code-review.yml and claude.yml
Claude Code review integration. Runs on PRs tagged for review.
Deployment workflow (not yet automated)
Current deployment is manual. To deploy a new version:
# 1. Build and push to 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. Update Container Apps to new revision
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_SHADatabase migrations
Development: db:push
For dev, use pnpm --filter @sipsop/db db:push — Drizzle introspects the current schema and applies changes directly (no migration file created). Fast but not safe for production.
Production: db:migrate
For staging and production, use pnpm --filter @sipsop/db db:migrate which applies pending migration files from packages/db/src/migrations/.
Generate a migration file before deploying:
# After changing schema files:
pnpm --filter @sipsop/db db:generate
# → creates packages/db/src/migrations/XXXX_description.sql
# Review the generated SQL before applying
cat packages/db/src/migrations/XXXX_description.sql
# Apply in production (run this before deploying new app version)
DATABASE_URL="<prod_url>" pnpm --filter @sipsop/db db:migrateRLS — re-apply after schema changes
deploy-api.yml already runs this after every migration step, so it should rarely be needed by hand. To re-apply manually:
DATABASE_URL="<prod_url>" pnpm --filter @sipsop/db db:apply-rls-policiesNever run db:apply-rls against production
db:apply-rls also runs the role bootstrap, which rotates the sipsop_app password. The real one lives in Key Vault (database-url-app); overwriting it takes the API down. db:apply-rls-policies only applies policies and grants — it never touches credentials.
Stripe product seeding
Stripe products/prices are created once per environment (dev, production). Not a migration — run manually:
STRIPE_SECRET_KEY="sk_live_..." pnpm --filter @sipsop/db db:seed-stripeThis creates Stripe products for the Starter/Business/Enterprise plans and saves the Stripe price IDs back to the plans table.
Ports reference
| Service | Local port | Container port |
|---|---|---|
| API | 3002 | 3002 |
| Portal | 5175 | 80 |
| Landing | 5176 | 80 |
| Docs | 5180 | — |
| PostgreSQL | 5434 | 5432 |
| Redis | 6380 | 6379 |
| FreePBX mock (dev) | 3307 | 3306 |
Documentation deploys
The SipSop docs site is built from a single source (docs/site/) and deployed to 3 separate Cloudflare Pages projects, each serving a different audience behind its own subdomain.
Architecture
| Target | Subdomain | Content | Access |
|---|---|---|---|
public | docs.sipsop.net | /product/ | Public — no auth |
partners | partners.sipsop.net | /commissions/ | Cloudflare Access (allowlisted seller emails) |
internal | internal.sipsop.net | /internal/ | Cloudflare Access (@sopinf.com Google SSO) |
All 3 builds come from the same docs/site/ source. A BUILD_TARGET env var controls which sections are included and which homepage is shown. VitePress srcExclude drops the unwanted sections at source level so they never appear in the output.
Local dev (pnpm docs:dev) runs with BUILD_TARGET=full — all 3 sections visible together. Production deploys are always one of the 3 targets.
One-time setup (Marco)
1. Create 3 Cloudflare Pages projects
Via the Cloudflare dashboard or wrangler CLI:
wrangler pages project create sipsop-docs-public
wrangler pages project create sipsop-docs-partners
wrangler pages project create sipsop-docs-internalThen add custom domains in the CF Pages dashboard:
sipsop-docs-public→docs.sipsop.netsipsop-docs-partners→partners.sipsop.netsipsop-docs-internal→internal.sipsop.net
2. Add DNS CNAME records
In Cloudflare DNS for the sipsop.net zone, add a CNAME for each subdomain pointing to the Pages project's *.pages.dev URL. CF Pages custom domain setup handles this automatically when you add the domain in the dashboard.
3. GitHub secrets
Generate a CF API token with Cloudflare Pages — Edit permission. Add to the GitHub repo secrets:
CLOUDFLARE_API_TOKEN— token with Pages edit permissionCLOUDFLARE_ACCOUNT_ID— your CF account ID (visible in the CF dashboard sidebar)
4. Cloudflare Access policies
Configure in the Cloudflare Zero Trust dashboard (one.dash.cloudflare.com):
For partners.sipsop.net:
- Application type: Self-hosted
- Application domain:
partners.sipsop.net - Identity provider: One-time PIN (email OTP)
- Access rule: Email is in list → allowlist of approved seller emails (pulled from
commission_reps.email) - Session duration: 24h
For internal.sipsop.net:
- Application type: Self-hosted
- Application domain:
internal.sipsop.net - Identity provider: Google Workspace (or email ends in
@sopinf.comwith One-time PIN) - Access rule: Email ends in
@sopinf.com - Session duration: 24h
For docs.sipsop.net:
- No CF Access policy — fully public.
Day-to-day operations
Local dev — full preview:
pnpm docs:dev
# → http://localhost:5180 with all 3 sections (product, commissions, internal)Build a specific target locally:
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 # → builds all 3 in seriesHow the build swap works:
Before each target build, prebuild.mjs backs up index.md and es/index.md and copies the target-specific homepage (homepages/<target>.en.md) into place. After the build, postbuild.mjs restores the originals. The working tree is clean after every build.
If a build crashes between prebuild and postbuild, restore manually:
node docs/site/.vitepress/scripts/postbuild.mjsCI/CD — push to main:
The .github/workflows/docs-deploy.yml action runs on every push to main that touches docs/site/**. It builds all 3 targets in parallel (matrix) and deploys each to its CF Pages project via wrangler pages deploy.
Manual deploy (emergency):
# Build and deploy public target
pnpm docs:build:public
wrangler pages deploy docs/site/.vitepress/dist-public --project-name=sipsop-docs-public
# Or all 3 at once
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-internalAdding a new seller to the partners allowlist
- Create the
commission_reprow in the DB (sets status toactive). - Add the rep's email to the CF Access policy for
partners.sipsop.netin the Zero Trust dashboard (Application → Edit → Policies → add email to the allowlist). - Send the rep their
partners.sipsop.netlink — CF Access will challenge them for email OTP on first visit.
Future automation: A BullMQ job can call the CF Access API to add/remove emails as commission_rep rows are activated/deactivated, removing the manual step.
Disaster recovery summary
| Scenario | RTO target | Recovery path |
|---|---|---|
| API container crash | < 2 min | Container Apps auto-restart (min-replicas = 1) |
| Worker crash | < 5 min | Auto-restart. Jobs in BullMQ queue are durable (Redis persistence). |
| Redis failure | < 15 min | BullMQ jobs requeue on Redis reconnect. Session loss (users must re-login). |
| Postgres failure | Depends | Azure managed server handles failover. See Runbooks. |
| Full environment loss | < 4 hours | Re-create Container Apps from images + restore DB from Azure backup. |