CatalyzeUp
sondely/migration

Infrastructure and Deploy

What changes in CatalyzeUp/catalyzeup-devops (Terraform), in the Docker image and in the GitHub workflows when Sondely moves to the TourCockpit stack. Every Azure change goes through Terraform in catalyzeup-devops/azure/, read with terraform plan before apply. The data move these pieces serve is on the Cutover Runbook; the plan is on the overview.

Sources: catalyzeup-devops/azure/main.tf, outputs.tf, providers.tf and modules/container-app/; sondely/Dockerfile and .github/workflows/; TourCockpit's Dockerfile, docker-entrypoint.sh, src/lib/db.ts, .github/workflows/ci.yml, deploy-dev.yml, deploy.yml; the IdeaPlaces precedent in ideaplaces-devops/infrastructure/terraform/azure/environments/shared/ (main.tf, tour-platform.tf, outbox.tf). Live values were read from Azure and GitHub, read-only, on 5 November 2026. What was built on 7 and 8 November 2026 was read from catalyzeup-devops/azure/sondely-platform.tf and from the CatalyzeUp/sondely-platform repository (Dockerfile, docker-entrypoint.sh, .github/workflows/).

Today: the Python app

Piece Name Notes
Resource group rg-catalyzeup, Canada Central Subscription "Microsoft Azure Sponsorship" (the only one on the account)
Registry acrcatalyzeup (Basic) image impactpulse, tags <sha>, latest, prod, buildcache
Key Vault kv-catalyzeup, RBAC mode Sondely secrets keep the impactpulse suffix (secret-key-impactpulse, google-client-id-impactpulse, google-client-secret-impactpulse, acs-connection-string-impactpulse)
Database cosmos-catalyzeup, Mongo API 4.2, serverless, periodic backup impactpulse (dev), impactpulse_prod (prod)
Sessions redis-catalyzeup, Basic C0 DB 0 dev, DB 1 prod
Model openai-catalyzeup, deployment gpt-5.4
Environment cae-catalyzeup with law-catalyzeup
Dev app container app impactpulse develop; dev.sondely.com and impactpulse.catalyzeupdev.com; ENVIRONMENT=development
Prod app container app impactpulse-prod main; sondely.com, www.sondely.com, hit.wehappers.org, impactpulse.catalyzeup.ai; image impactpulse:84be2d177b4939042a79b5d053216f5ca2c21240
Both apps modules/container-app port 8000, 0.5 CPU, 1 Gi, 1 to 3 replicas, single revision mode, no health probes, image ignored by Terraform (lifecycle.ignore_changes), custom domains bound outside Terraform (only their DNS records are in main.tf)
State ideaplacestfstate/tfstate/catalyzeup-azure.tfstate in rg-ideaplaces azurerm 3.117.1 locked

The container app names keep impactpulse on purpose (comment in main.tf); renaming them would mean re-binding four custom domains and their certificates, so the migration keeps the names.

As built on 7 and 8 November 2026

The new app is not a new image for the Python container apps, as the first version of this page planned. It runs in two container apps of its own, beside impactpulse and impactpulse-prod, on temporary hosts until the cutover moves sondely.com. All of it is in catalyzeup-devops/azure/sondely-platform.tf.

Piece Name Notes
Postgres, production psql-catalyzeup-sondely, database sondely_prod Version 16, B_Standard_B2s, zone 1, max_connections = 300
Postgres, dev psql-catalyzeup-sondely-dev, database sondely_dev Same size on purpose: B1ms caps connections at 50 and one replica holds 32. max_connections = 150
Firewall AllowAzureServices on each server Admits the container apps and Azure machines, nothing else
Dev app container app sondely-platform-dev develop; platform-dev.sondely.com; 0.5 CPU, 1 Gi, one replica
Production app container app sondely-platform-prod main; platform.sondely.com; 0.5 CPU, 1 Gi, one replica until Sona's voice calls have session affinity: a call is held in the memory of the replica that opened it
DNS Cloudflare platform-dev and platform CNAMEs with their asuid TXT records, DNS only Temporary hosts until the cutover; the hostname bindings are made outside Terraform, as for the other apps
Image sondely-platform in acrcatalyzeup, tagged with the commit The Terraform image is a placeholder the deploy replaces
Sona's text model gpt-5.4 on openai-catalyzeup ASSISTANT_MODEL, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY
Sona's voice speech-catalyzeup-sondely, Speech, Canada Central Azure AI Voice Live (below)
Email Azure Communication Services ACS_CONNECTION_STRING, the same secret the Python app reads (acs-connection-string-impactpulse); sender MAGIC_LINK_FROM = hello@sondely.com

As of 8 November 2026 the dev app runs develop and the production app runs main: platform.sondely.com/health names commit a003202 with database: ok.

Postgres: decided

Decision 1 on the overview, taken on 7 November 2026: A, new CatalyzeUp servers, built as listed above. The comparison that decided it:

A. New CatalyzeUp server(s) B. A database on an IdeaPlaces server
Server psql-catalyzeup-sondely (prod) and psql-catalyzeup-sondely-dev (dev), in rg-catalyzeup sondely database on psql-ideaplaces-monday2github (prod) and psql-ideaplaces-monday2github-dev
Terraform All in catalyzeup-devops The database and its secrets in ideaplaces-devops, the app wiring in catalyzeup-devops: two states for one product
Isolation Own connection ceiling, own restarts, own point-in-time restore Shares max_connections = 200 with TourCockpit production and monday2github (the ceiling TourCockpit already hit on 2026-09-07, per the comment in ideaplaces-devops main.tf); a restore is per server, so restoring Sondely would mean restoring TourCockpit's server to a new one
Business boundary Stays inside CatalyzeUp CatalyzeUp data on an IdeaPlaces resource
Cost One or two more servers None added

Terraform in catalyzeup-devops

The database and its secrets

Resource Content
azurerm_postgresql_flexible_server.sondely, .sondely_dev Version 16, location of the CatalyzeUp resources, zone 1, B_Standard_B2s, public_network_access_enabled = true, administrator login sondely_admin with a generated password
azurerm_postgresql_flexible_server_firewall_rule per server AllowAzureServices (0.0.0.0 to 0.0.0.0)
azurerm_postgresql_flexible_server_configuration per server max_connections, explicit: 300 on production (up to three replicas of 32 connections, doubled during a rollout, plus admin sessions), 150 on dev
azurerm_postgresql_flexible_server_database sondely_prod and sondely_dev, UTF8, en_US.utf8
random_password plus azurerm_key_vault_secret in kv-catalyzeup db-password-sondely-admin, db-password-sondely-admin-dev, db-password-sondely-app, db-password-sondely-app-dev, session-secret-sondely, jobs-secret-sondely, and the built connection strings database-url-sondely-{dev,prod} and admin-database-url-sondely-{dev,prod}

Roles

The same three connections TourCockpit uses, built into each app's secrets:

Variable Role Used for
DATABASE_URL sondely_app: NOSUPERUSER NOBYPASSRLS Every request, on dev and production alike, so row level security is exercised before production. Row level security binds it to app.tenant_id. connection_limit=15
ADMIN_DATABASE_URL the server administrator, sondely_admin, the owner role Resolution before a tenant is bound (sessions, API keys, organization by host), jobs, seeding, the migration script. connection_limit=15
MIGRATION_DATABASE_URL the server administrator prisma migrate deploy at container start. connection_limit=2

The app role is a database object, not an Azure resource: Terraform owns only its password, and the role is created once per server by hand with that password. In CI and locally it is created by scripts/db/init-role.sql. Every migration that adds a table grants it to sondely_app.

Container app settings

Set by Terraform on both apps (sondely_common_env, sondely_common_secrets and the database URLs):

Variable Kind Value
DATABASE_URL, ADMIN_DATABASE_URL, MIGRATION_DATABASE_URL secret The three roles above, sslmode=require
SESSION_SECRET secret session-secret-sondely. The image refuses to start without it, DATABASE_URL or APP_BASE_URL
JOBS_SECRET secret jobs-secret-sondely, the shared secret the scheduled reminder job sends
TRUST_PROXY plain true: the Container Apps ingress appends the real client address as the rightmost X-Forwarded-For entry, and the app reads it only when this is set. Under it the app also reads the scheme the browser used from X-Forwarded-Proto (Google sign-in)
ASSISTANT_ENABLED plain true: Sona on for the team while the platform is being proven
ASSISTANT_MODEL, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY plain and secret gpt-5.4 on openai-catalyzeup
ASSISTANT_VOICE_LIVE_ENDPOINT, ASSISTANT_VOICE_LIVE_KEY, ASSISTANT_VOICE_LIVE_MODEL plain and secret speech-catalyzeup-sondely, gpt-realtime-2.1
ACS_CONNECTION_STRING, MAGIC_LINK_FROM secret and plain Email through Azure Communication Services, from hello@sondely.com
ENVIRONMENT, APP_BASE_URL, NEXT_PUBLIC_BASE_URL plain development and https://platform-dev.sondely.com; production and https://platform.sondely.com
NODE_ENV, PORT plain production, 8000

GIT_COMMIT_SHA is set by the deploy workflow, not by Terraform, so /health can name the commit. WRITE_FREEZE is not set on either app and nothing in the new app reads it yet; the freeze the runbook needs is still to build.

Sona's voice

Sona's call runs on Azure AI Voice Live through the CatalyzeUp Speech resource speech-catalyzeup-sondely in Canada Central (kind = SpeechServices, S0). Voice Live is limited and billed on the Speech resource. The other path, a gpt-realtime-2.1 deployment on Azure OpenAI, needs GlobalStandard quota, which is counted per subscription across every region; on 7 November 2026 it was at 10 of 10, all used by TourCockpit's Faro (openai-tourcockpit-voice). This is the same path Faro's native-voice calls take.

A Canada Central Azure OpenAI account, openai-catalyzeup-voice, exists with a gpt-4o-mini-transcribe deployment, for transcription. Its realtime and voice note deployments, and the settings that would point the app at that account, are behind sondely_voice_quota_available = false in Terraform and are not created; the app does not use the account today. During a call, what the caller says is transcribed by Voice Live itself.

The reminder job

The app serves POST /api/v1/jobs/reminders, guarded by JOBS_SECRET, and claims each reminder before sending it. A scheduled Container Apps job, azurerm_container_app_job.sondely_reminders in azure/sondely-platform.tf, calls it every 15 minutes with a curl container: sondely-reminders-dev against platform-dev.sondely.com runs today. The production job is created only when local.sondely_prod_reminders_enabled is true; it is false until the cutover, because the Python app still sends its own reminders to the same people and both together would send twice (Switchover checklist, step 5).

Not built yet

  • Health probes. modules/container-app defines none, and the Sondely apps set none. The plan stands: optional readiness_probe and liveness_probe variables on the module, readiness on /health, so a revision that cannot reach Postgres never takes traffic.
  • Geo detection in the deployed apps. The app reads a MaxMind GeoLite2 database from GEOIP_DB_PATH, then falls back to Cloudflare's CF-IPCountry header. The image carries no GeoLite2 database, GEOIP_DB_PATH is not set, and the platform hosts are DNS only, so Cloudflare adds no header: a response's country is unknown on both apps today. The Python image fetches the database at build time with the maxmind_key secret.
  • Session affinity for calls, which is what lets production run more than one replica.

After the rollback window

Listed in the runbook, Decommissioning Cosmos and Redis: remove module.impactpulse and module.impactpulse_prod or their Mongo and Redis variables, remove azurerm_redis_cache.main, archive both databases, remove azurerm_cosmosdb_account.main and the cosmosdb_connection_string and redis_hostname outputs.

The Docker image

As built: the Dockerfile at the root of sondely-platform, copied and stripped from TourCockpit's. It serves the Next.js app only; there is no Vite stage, because every screen is in Next.js (Frontend Screens).

Stage Content
dependencies node:22-alpine, openssl, npm ci
build npm run build (next build, standalone output)
prod-deps npm ci --omit=dev, prisma generate, the @next/swc-* binaries dropped. The Prisma CLI is a production dependency, because migrations run at startup
runtime node:22-alpine, non-root user nextjs, PORT=8000; production node_modules, .next/standalone, .next/static, public/, prisma/ and the entrypoint
entrypoint docker-entrypoint.sh: fail fast when DATABASE_URL, SESSION_SECRET or APP_BASE_URL is missing; DATABASE_URL="${MIGRATION_DATABASE_URL:-$DATABASE_URL}" npx prisma migrate deploy, as the owner role; exec node server.js, which then connects as sondely_app

Because the entrypoint migrates while the previous revision is still serving (single revision mode starts the new one before the old one stops), every migration must be backward compatible with the running code: add, backfill, then remove in a later release. TourCockpit's Session.activeCompanyId comment in prisma/schema.prisma is the worked example.

CI/CD

The Python app today

Workflow Runner Does
ci.yml ubuntu-latest Frontend lint, typecheck, unit tests, build; backend pytest on the in-memory DB; backend integration on mongo:7 with the Cosmos sort guard
deploy.yml ubuntu-latest On develop: pytest again, build, push impactpulse:<sha> and latest, az containerapp update on impactpulse, poll /health 30 times every 5 seconds, Discord on success and failure
deploy-prod.yml ubuntu-latest On main: same, to impactpulse-prod, tag prod. No Discord notification

These keep deploying the Python app from CatalyzeUp/sondely until the cutover and the rollback window are over.

The new app, as built

Workflow Does
ci.yml On every push and pull request to develop and main, one job on ubuntu-latest with a postgres:16-alpine service: create sondely_app and the separate sondely_e2e database, prisma migrate deploy, lint, typecheck, unit, component and integration tests, build, the Playwright suite in Chromium against the production build, then Sona's voice call test against a fake Voice Live server
deploy.yml After CI completes green on a push (never a pull request): develop deploys to sondely-platform-dev, main to sondely-platform-prod. Only the current tip of the branch deploys: an older green run stops, because a newer run deploys the tip. A production deploy is refused from any branch but main, including by hand. It builds the image tagged with the commit, runs az containerapp update with GIT_COMMIT_SHA, then waits until /health answers with that commit, up to 40 tries ten seconds apart. One queue per environment, never cancelled midway

There is no migration step in the workflows: the container migrates itself at start, as TourCockpit does, which keeps the database firewall closed to CI runners. There is no runtime switch variable either, because the new app does not replace the Python image in the same container app.

Runners

Decision 4 on the overview, taken on 7 November 2026: GitHub-hosted runners (ubuntu-latest) for CI and deploy. The Nous pool is not registered for the CatalyzeUp organization. The options weighed:

Option For Against
ubuntu-latest, chosen Nothing to set up; parallel Billed minutes on a private repo; cannot reach Postgres (not an Azure address)
catalyzeup-linux (self-hosted, one runner, dev-workstation-canada-catalyzeup on chipdev, already used by the docs site) Free, in Azure, reaches Postgres One runner serialises CI
The Nous pool registered in the CatalyzeUp organization The engineering standard's runner pool for private repos, parallel A change in ideaplaces-devops to register the pool for a second organization

What must reach Azure Postgres (the migration script, its verifier) runs from an Azure address, which the firewall admits; no CI job needs it.

Discord

Neither workflow of the new app notifies Discord yet. The target stays: Ideaplaces/discord-ci-status@v1 (public, usable from the CatalyzeUp organization) posts on failure and deletes the post when the job goes green, never posts on success. It needs a webhook and DISCORD_BOT_TOKEN as repository secrets. CatalyzeUp's channels and webhooks are created by catalyzeup-devops/discord/setup-cicd-notifications.sh and stored in kv-catalyzeup.

Health checks

Endpoint Behaviour
/health Runs SELECT 1 through the app's own constrained role; 200 with status: ok, database: ok, the environment and the commit the deploy set; 503 with database: unreachable when the database does not answer. The deploy workflow polls it for the commit. Python serves /health too, so the rollback check and the cutover check are one path

There is no /api/health or /api/version: /health carries the commit.

Environments

Branch Container app Hosts today Hosts after the cutover, as planned Database
develop sondely-platform-dev platform-dev.sondely.com dev.sondely.com, impactpulse.catalyzeupdev.com (to be moved from impactpulse) sondely_dev on psql-catalyzeup-sondely-dev
main sondely-platform-prod platform.sondely.com sondely.com, www.sondely.com, hit.wehappers.org, impactpulse.catalyzeup.ai (to be moved from impactpulse-prod) sondely_prod on psql-catalyzeup-sondely

Branches follow the workspace rule: feature branches from develop, pull requests to develop, production from main only. When sondely.com moves is Chip's call (Runbook). Organization custom domains (today hit.wehappers.org) are bound by hand, as today, and resolved by the app's organization-by-host lookup.

Cost

Azure first-party spend is paid from sponsorship credits until 28 July 2027, and Sondely runs in that same subscription (the account shows only "Microsoft Azure Sponsorship", and rg-catalyzeup is in it). So the migration's Azure cost is credits until then.

Change When
Plus two burstable Postgres servers (B_Standard_B2s) and two container apps From 7 November 2026
Plus Voice Live use on speech-catalyzeup-sondely, per call From 8 November 2026
Plus the reminder job ticks (a curl container every 15 minutes per environment) Dev from 8 November 2026, production at the cutover
Cosmos serverless, Redis Basic C0, Postgres and both pairs of container apps running Until the end of the rollback window
Minus Redis, Cosmos and the Python container apps After the window

After July 2027 the Postgres servers are the recurring item. GitHub-hosted runner minutes are not Azure spend and are not covered by the credits; that was accepted with the runner decision.