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) |
| 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-appdefines none, and the Sondely apps set none. The plan stands: optionalreadiness_probeandliveness_probevariables 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'sCF-IPCountryheader. The image carries no GeoLite2 database,GEOIP_DB_PATHis 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 themaxmind_keysecret. - 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.