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 athttps://api.sonnance.ff8.dev, notlocalhost) can reach it. No rearchitecture, no CloudKit — see21-DATA-ARCHITECTURE-AND-IDENTITY.mdfor 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:
| Path | Trigger | What it does | When 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 tag | only when you push main |
| Manual | you run Step 2 below | az acr build + az containerapp update with a timestamp tag | the 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.md — not 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
mainrun (2026-05-31) failed — needs a look; (2) duplicate pipelines —ctrl-audio-back/.github/workflows/deploy.ymland the meta-repo.github/workflows/backend-deploy.ymlboth deployctrl-audio-backonmain; keep one. Until resolved, manual Step 2 is the source of truth.
What's already cloud vs what we deploy
- ✅ MongoDB — Atlas (
mongodb+srv, dbctrl-audio). Already public. Nothing to do. - ✅ File storage — Azure Blob (
sonnancewebappstorage00, containeruploads), SAS-signed. Already public. - ✅ Provisioning is scripted —
setup-azure.shalready creates RGSonnance-WebApp(westeurope), ACRsonnanceacr, Container Apps envsonnance-env, and thectrl-audio-backendContainer App (port 8080, external ingress, min 0 / max 3). TheDockerfileis 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.devcustom 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
az login # ☁️ your account
az account set --subscription "2490f938-f286-4d8f-a145-0b33bbd83a6c" # the one in setup-azure.sh
cd infrastructureStep 1 — Provision (idempotent; skips what exists)
./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:
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:$TAGStep 3 — Env vars + secrets (the CURRENT list — supersedes configure-env.sh)
configure-env.shis stale (it usesAZURE_STORAGE_CONNECTION_STRING; the backend actually usesAZURE_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):
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):
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.devNotes:
- 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+ addAPNS_KEY=secretref:apns-key APNS_KEY_ID=… APNS_TEAM_ID=… APNS_HOST=productionwhen 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:
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:
CNAMEapi.sonnance→$APP_FQDNTXTasuid.api.sonnance→$VERIFY_ID
(If ff8.dev is on Azure DNS:)
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):
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 CNAMEStep 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
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 edge404with 0 replicas and no logs. Start it — but the locally-installedcontainerappCLI extension (1.2.0b4beta) lacksstart/stop, so call the ARM action directly:bashaz rest --method post \ --url "/subscriptions/<sub>/resourceGroups/Sonnance-WebApp/providers/Microsoft.App/containerApps/ctrl-audio-backend/start?api-version=2024-03-01" ImagePullBackOffafter starting (also hit live). The stored ACR pull password (admin creds viapasswordSecretRef, no managed identity) goes stale. Refresh it:bashaz 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.sonnancehost 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.envstays local. Ifsetup-azure.shwrote anazure-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 in06-CI-CD-GUIDE.mdso a push tomainbuilds + updates automatically — nice-to-have, not required for first TestFlight). devContainer 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