Skip to content

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/:

ImageDockerfileDescription
API serverDockerfile.apiHono HTTP server on port 3002
BullMQ workerDockerfile.workerJob processor (no HTTP port)
PortalDockerfile.portalReact PWA + nginx SPA server
LandingDockerfile.landingMarketing React app + nginx

Base image

All images use node:22-alpine (multi-stage):

dockerfile
# 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=production

The 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

bash
# 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:

nginx
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

bash
# 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 worker

The local docker-compose.yml includes:

  • postgres:16-alpine on port 5434 (db: sipsop, user: sipsop, password: sipsop_dev)
  • redis:7-alpine on port 6380
  • mariadb:11 on port 3307 (simulates FreePBX asteriskcdrdb, seeds from seed-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.

VariableRequiredDefaultDescription
DATABASE_URLYesPostgreSQL connection string: postgresql://user:password@host:port/dbname
REDIS_URLNoredis://localhost:6379Redis connection: redis://host:port
BETTER_AUTH_SECRETYesMin 32 characters. Signs session tokens.
BETTER_AUTH_URLYesPublic URL of the API: https://api.sipsop.net
BETTER_AUTH_TRUSTED_ORIGINSYesPortal URL for CORS: https://app.sipsop.net
CORS_ORIGINYesPortal origin: https://app.sipsop.net
API_PORTNo3002HTTP port for the API server
STRIPE_SECRET_KEYNo''Stripe API key (sk_live_...)
STRIPE_WEBHOOK_SECRETNo''Stripe webhook signing secret (whsec_...)
FREEPBX_DB_HOSTNolocalhostFreePBX MariaDB host
FREEPBX_DB_PORTNo3306FreePBX MariaDB port
FREEPBX_DB_USERNoFreePBX MariaDB read-only user
FREEPBX_DB_PASSWORDNoFreePBX MariaDB password
FREEPBX_DB_NAMENoasteriskcdrdbFreePBX CDR database name

Dev .env file (project root)

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

Loaded 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.

bash
# 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:

json
{
  "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

Container AppIngressMin replicasMax replicas
sipsop-apiExternal HTTPS, port 300215
sipsop-workerNone (no ingress)13
sipsop-portalExternal HTTPS, port 8013
sipsop-landingExternal HTTPS, port 8012

The worker does NOT need external ingress — it only connects outbound to Postgres, Redis, and FreePBX.

Create the environment and apps

bash
# 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 3

Custom domain

Map api.sipsop.netsipsop-api Container App, app.sipsop.netsipsop-portal, etc. via Azure Container Apps custom domain + Managed Certificate.

BullMQ workers

Four job queues defined in apps/api/src/jobs/worker.ts:

QueueScheduleWhat it does
cdr-syncEvery 15 minutesPolls FreePBX MariaDB, normalizes CDRs, inserts into cdr_records with ON CONFLICT DO NOTHING
billing-calcDaily at 2am (cron)Recalculates totals for open billing_periods
invoice-gen1st of every month at 3amCloses open periods, generates invoices rows, creates Stripe invoice
stripe-syncEvery 6 hoursSyncs 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:

bash
# 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)"
done

FreePBX 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 asteriskcdrdb MariaDB
  • 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 SELECT privilege only on the cdr table

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

  1. pnpm install --frozen-lockfile
  2. Run API tests: pnpm --filter @sipsop/api test
  3. Run portal tests: pnpm --filter @sipsop/portal test
  4. Build portal: pnpm --filter @sipsop/portal build
  5. 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:

bash
# 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_SHA

Database 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:

bash
# 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:migrate

RLS — 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:

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

Never 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:

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

This creates Stripe products for the Starter/Business/Enterprise plans and saves the Stripe price IDs back to the plans table.

Ports reference

ServiceLocal portContainer port
API30023002
Portal517580
Landing517680
Docs5180
PostgreSQL54345432
Redis63806379
FreePBX mock (dev)33073306

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

TargetSubdomainContentAccess
publicdocs.sipsop.net/product/Public — no auth
partnerspartners.sipsop.net/commissions/Cloudflare Access (allowlisted seller emails)
internalinternal.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:

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

Then add custom domains in the CF Pages dashboard:

  • sipsop-docs-publicdocs.sipsop.net
  • sipsop-docs-partnerspartners.sipsop.net
  • sipsop-docs-internalinternal.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 permission
  • CLOUDFLARE_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.com with 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:

bash
pnpm docs:dev
# → http://localhost:5180 with all 3 sections (product, commissions, internal)

Build a specific target locally:

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       # → builds all 3 in series

How 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:

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

CI/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):

bash
# 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-internal

Adding a new seller to the partners allowlist

  1. Create the commission_rep row in the DB (sets status to active).
  2. Add the rep's email to the CF Access policy for partners.sipsop.net in the Zero Trust dashboard (Application → Edit → Policies → add email to the allowlist).
  3. Send the rep their partners.sipsop.net link — 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

ScenarioRTO targetRecovery path
API container crash< 2 minContainer Apps auto-restart (min-replicas = 1)
Worker crash< 5 minAuto-restart. Jobs in BullMQ queue are durable (Redis persistence).
Redis failure< 15 minBullMQ jobs requeue on Redis reconnect. Session loss (users must re-login).
Postgres failureDependsAzure managed server handles failover. See Runbooks.
Full environment loss< 4 hoursRe-create Container Apps from images + restore DB from Azure backup.

SipSop documentation. Product operated by Sopinf Tech LLC.