Skip to content

27 — TestFlight Backend Deploy (Azure Container Apps)

Active runbook. Governed by 00-ROADMAP.md. Created 2026-06-08. Goal: put the existing NestJS API on a public HTTPS URL so TestFlight builds (which point at https://api.sonnance.ff8.dev, not localhost) can reach it. No rearchitecture, no CloudKit — see 21-DATA-ARCHITECTURE-AND-IDENTITY.md for why server-of-record stays.

How deployment happens — manual now, auto on main (read this first)

There are two deploy paths. Knowing which is active removes the "do I push, or run az?" confusion:

PathTriggerWhat it doesWhen it applies
Auto (CI/CD)push to main (or develop)GitHub Action deploy.yml → lint/test/build → ACR image sha-<short> → deploy to the ctrl-audio-backend Container App → /health check → release tagonly when you push main
Manualyou run Step 2 belowaz acr build + az containerapp update with a timestamp tagthe current DEV default

Why deploys have been manual: during this DEV phase, work is stacked on one feature branch (e.g. feat/track-release-approval) per the workspace CLAUDE.mdnot main. The Action only fires on main/develop, so it never runs for the feature branch → the manual Step 2 commands are the correct, expected way to deploy right now. A git push of the feature branch is source-control / Azure-mirror only; it does not deploy anything.

Rule of thumb: on a feature branch → deploy with Step 2 (push is just backup). At release, when the branch merges to main → the Action deploys automatically and you stop running az by hand. Don't do both for the same change (a manual az update and a main push that also deploys = two competing revisions).

Two cleanups before auto-deploy can be trusted (flagged, not blocking): (1) the last main run (2026-05-31) failed — needs a look; (2) duplicate pipelinesctrl-audio-back/.github/workflows/deploy.yml and the meta-repo .github/workflows/backend-deploy.yml both deploy ctrl-audio-back on main; keep one. Until resolved, manual Step 2 is the source of truth.

What's already cloud vs what we deploy

  • MongoDB — Atlas (mongodb+srv, db ctrl-audio). Already public. Nothing to do.
  • File storage — Azure Blob (sonnancewebappstorage00, container uploads), SAS-signed. Already public.
  • Provisioning is scriptedsetup-azure.sh already creates RG Sonnance-WebApp (westeurope), ACR sonnanceacr, Container Apps env sonnance-env, and the ctrl-audio-backend Container App (port 8080, external ingress, min 0 / max 3). The Dockerfile is Container-App-ready (multistage, EXPOSE 8080, CMD node dist/src/main.js).
  • The gap = (1) build & push the real image, (2) set the current env vars/secrets, (3) the ff8.dev custom domain + managed cert, (4) point the iOS release URL at it.

Approach: bash + az CLI (same as the existing scripts). Steps that need your input are marked 🔑 (secret value) or ☁️ (az login session / DNS access).


Prereqs

bash
az login                                   # ☁️ your account
az account set --subscription "2490f938-f286-4d8f-a145-0b33bbd83a6c"   # the one in setup-azure.sh
cd infrastructure

Step 1 — Provision (idempotent; skips what exists)

bash
./setup-azure.sh        # ☁️ creates RG / ACR / env / ctrl-audio-backend (+ dev, + SWAs, + GitHub SP)

If everything already exists it just reports and moves on. (The SWAs + service principal it also makes are harmless for our purpose.)

Step 2 — Build & push the image, point the app at it

Build in ACR (no local Docker needed), then update the Container App to run it:

bash
cd ../ctrl-audio-back
TAG=$(date +%Y%m%d-%H%M%S)
az acr build --registry sonnanceacr --image ctrl-audio-backend:$TAG .       # builds from Dockerfile

az containerapp update \
  --name ctrl-audio-backend --resource-group Sonnance-WebApp \
  --image sonnanceacr.azurecr.io/ctrl-audio-backend:$TAG

Step 3 — Env vars + secrets (the CURRENT list — supersedes configure-env.sh)

configure-env.sh is stale (it uses AZURE_STORAGE_CONNECTION_STRING; the backend actually uses AZURE_STORAGE_ACCOUNT_NAME/KEY/CONTAINER_NAME, and it has no Apple/refresh vars). Use the list below. Source of truth: ctrl-audio-back/.env.example.

3a. Secrets (🔑 = paste your real values; never commit these):

bash
az containerapp secret set --name ctrl-audio-backend -g Sonnance-WebApp --secrets \
  mongodb-uri="<🔑 your Atlas mongodb+srv URI>" \
  jwt-secret="<🔑 openssl rand -base64 32>" \
  jwt-refresh-secret="<🔑 a DIFFERENT openssl rand -base64 32>" \
  storage-key="<🔑 Azure storage account key for sonnancewebappstorage00>" \
  postmark-key="<🔑 Postmark token, or omit if skipping email>" \
  apns-key="<🔑 contents of the APNs .p8, newlines as \n — or omit for now>"

3b. Env vars (non-secret + secretrefs):

bash
az containerapp update --name ctrl-audio-backend -g Sonnance-WebApp --set-env-vars \
  NODE_ENV=production PORT=8080 \
  MONGODB_URI=secretref:mongodb-uri \
  JWT_SECRET=secretref:jwt-secret JWT_EXPIRATION=30d \
  JWT_REFRESH_SECRET=secretref:jwt-refresh-secret JWT_REFRESH_EXPIRATION=30d \
  STORAGE_PROVIDER=azure \
  AZURE_STORAGE_ACCOUNT_NAME=sonnancewebappstorage00 \
  AZURE_STORAGE_ACCOUNT_KEY=secretref:storage-key \
  AZURE_STORAGE_CONTAINER_NAME=uploads \
  APPLE_CLIENT_ID=com.sonnance.app \
  POSTMARK_API_KEY=secretref:postmark-key \
  FRONTEND_URL=https://api.sonnance.ff8.dev \
  ORIGIN=https://api.sonnance.ff8.dev

Notes:

  • Apple Sign In needs only APPLE_CLIENT_ID (native flow verifies against Apple's public keys — no secret). This is the primary TestFlight auth, so it works as soon as this is set.
  • APNs (push) is optional for a first smoke test — set apns-key + add APNS_KEY=secretref:apns-key APNS_KEY_ID=… APNS_TEAM_ID=… APNS_HOST=production when you want the loop's push half live.
  • Stripe not needed (payments out of MVP). CDN_URL stays unset (the old Front Door is dead).
  • CORS/ORIGIN doesn't affect native iOS (no browser) — it only matters for the web app; set it to whatever web origin you use, or the API host as a placeholder.

Step 4 — Custom domain api.sonnance.ff8.dev + free managed cert

Get the app's FQDN and the domain-verification id:

bash
APP_FQDN=$(az containerapp show -n ctrl-audio-backend -g Sonnance-WebApp \
  --query properties.configuration.ingress.fqdn -o tsv)
VERIFY_ID=$(az containerapp show -n ctrl-audio-backend -g Sonnance-WebApp \
  --query properties.customDomainVerificationId -o tsv)
echo "CNAME api.sonnance -> $APP_FQDN" ; echo "TXT asuid.api.sonnance -> $VERIFY_ID"

In the ff8.dev DNS zone (☁️ wherever ff8.dev is hosted) add two records:

  • CNAME api.sonnance$APP_FQDN
  • TXT asuid.api.sonnance$VERIFY_ID

(If ff8.dev is on Azure DNS:)

bash
az network dns record-set cname set-record -g <ff8dev-rg> -z ff8.dev \
  --record-set-name api.sonnance --cname "$APP_FQDN"
az network dns record-set txt add-record -g <ff8dev-rg> -z ff8.dev \
  --record-set-name asuid.api.sonnance --value "$VERIFY_ID"

Then bind the hostname with a free managed certificate (after DNS propagates):

bash
az containerapp hostname add  --hostname api.sonnance.ff8.dev -n ctrl-audio-backend -g Sonnance-WebApp
az containerapp hostname bind --hostname api.sonnance.ff8.dev -n ctrl-audio-backend -g Sonnance-WebApp \
  --environment sonnance-env --validation-method CNAME

Step 5 — Point the iOS release build at it

ctrl-audio-ios/Sonnance/Sonnance/Core/APIConfig.swift already switches to https://api.sonnance.com in release. Change that one constant to https://api.sonnance.ff8.dev (debug stays localhost:8080). (Trivial one-line config edit — Claude Code can do it directly once the domain resolves, or it's a 1-min Xcode change.)

Step 6 — Verify

bash
curl -i https://api.sonnance.ff8.dev/api-docs        # Swagger UI should respond (200/3xx)

Then a TestFlight/release build: Sign in with Apple → browse Artist→Project→Track → play → comment → approve → create a track → upload a version. That's the full loop against the public API.

Notes / gotchas

  • App stuck runningStatus: Stopped (hit on the first live deploy). Symptom: every probe returns a fast edge 404 with 0 replicas and no logs. Start it — but the locally-installed containerapp CLI extension (1.2.0b4 beta) lacks start/stop, so call the ARM action directly:
    bash
    az rest --method post \
      --url "/subscriptions/<sub>/resourceGroups/Sonnance-WebApp/providers/Microsoft.App/containerApps/ctrl-audio-backend/start?api-version=2024-03-01"
  • ImagePullBackOff after starting (also hit live). The stored ACR pull password (admin creds via passwordSecretRef, no managed identity) goes stale. Refresh it:
    bash
    az containerapp registry set --name ctrl-audio-backend -g Sonnance-WebApp \
      --server sonnanceacr.azurecr.io --username sonnanceacr \
      --password "$(az acr credential show -n sonnanceacr --query passwords[0].value -o tsv)"
  • Squarespace DNS (ff8.dev) balks at the 3-label asuid.api.sonnance host in its editor, but it does save — don't be put off by the inline validation warning.
  • Secrets never go in git. They live only in Container App secrets (set via az ... secret set). The local .env stays local. If setup-azure.sh wrote an azure-credentials-*.json, delete it after use.
  • Scale-to-zero (min-replicas 0) means a cold first request after idle — fine for TestFlight; bump min-replicas to 1 if testers complain about first-hit latency.
  • Redeploys = repeat Step 2 with a fresh $TAG (or wire the GitHub Actions in 06-CI-CD-GUIDE.md so a push to main builds + updates automatically — nice-to-have, not required for first TestFlight).
  • dev Container App (ctrl-audio-backend-dev) exists too if you want a separate staging API later.

Cross-references

  • Why not CloudKit / data-of-record: 21-DATA-ARCHITECTURE-AND-IDENTITY.md
  • Existing infra scripts: setup-azure.sh, configure-env.sh (env section superseded here), cleanup-azure.sh
  • CI/CD (auto-deploy on push): 06-CI-CD-GUIDE.md
  • Env var reference: ctrl-audio-back/.env.example

Ctrl-Audio Platform Documentation