Deployment¶
Branch flow¶
All feature and bug work branches off development — never off main.
gitGraph
commit id: "development"
branch feat/your-feature
checkout feat/your-feature
commit id: "work"
checkout development
merge feat/your-feature id: "merge to dev"
branch release/staging
checkout release/staging
commit id: "CI: migrate + roll + smoke"
checkout development
branch release/prod
checkout release/prod
commit id: "CI: migrate + approval + roll prod"
| Branch | Convention | Notes |
|---|---|---|
feat/<slug> |
New features | Example: feat/trade-window-gating |
bug/<slug> |
Bug fixes | Example: bug/squad-refresh-loss |
release/staging |
CI integration target | Triggers migrate job + roll + smoke /health |
release/prod |
Production | Pauses at GitHub Environment approval gate |
Branch rules:
- Cut from
origin/developmentbefore any edits:git checkout -b feat/<name> origin/development. - One branch, one scope. If scope expands, branch again from
development. - Push to
originimmediately after the first meaningful commit. - Never force-push to
mainor any shared feature branch without explicit confirmation. - Every PR description must include
Author: @<github-handle>— name the human operator, not Claude.
Services¶
Three microservices run on Azure Container Apps in centralindia.
| Service | Container App name | Production replicas |
|---|---|---|
ws-server |
ftl-prd-ws |
min=3 |
api-server |
ftl-prd-api |
min=2 |
flusher |
ftl-prd-flusher |
min=1 |
Decision: Custom Go WebSocket server on Container Apps rather than Azure Web PubSub — 15× cheaper at 50K concurrent connections.
Decision: Redis Lua atomic scripts over a message queue — single-threaded execution, zero lock contention, no Service Bus cost.
Container naming convention¶
Prefix every container with the branch slug — the part after feat/ or bug/.
On branch feat/trade-window-gating:
This keeps concurrent-task containers isolated and makes ownership obvious in docker ps.
Single-revision mode requirement¶
Warning
Always confirm activeRevisionsMode == Single before running any scale operation. Running scale-down.sh or scale-up.sh against an app in Multiple mode creates a new revision on every update call. Old revisions keep their original minReplicas and bill forever.
Decision: Single-revision mode required on all Container Apps. Multiple mode caused a ₹1,234/day actual burn versus the ₹150/day plan — ftl-stg-api accumulated 9 revisions (10 always-warm replicas) after az containerapp update was called repeatedly. See ftl-docs/reports/azure-cost-audit-2026-05-02.md for the full incident.
Check before any scale operation:
AZURE_CONFIG_DIR=~/.azure-ftl az containerapp show \
-g <resource-group> -n <app-name> \
--query 'properties.configuration.activeRevisionsMode' -o tsv
# Must print: Single
If it prints Multiple, fix it first:
AZURE_CONFIG_DIR=~/.azure-ftl az containerapp revision set-mode \
--resource-group <resource-group> --name <app-name> --mode Single
Then deactivate all non-traffic revisions:
# List active revisions
AZURE_CONFIG_DIR=~/.azure-ftl az containerapp revision list \
-g <resource-group> -n <app-name> \
--query "[?properties.active].name" -o tsv
# Deactivate each non-live one
AZURE_CONFIG_DIR=~/.azure-ftl az containerapp revision deactivate \
--resource-group <resource-group> --app <app-name> \
--revision <revision-name>
Scale-to-zero overnight¶
Container Apps bills at the active rate ($0.000024/vCPU-s) even with zero users because:
flusher's 100 ms loop has no ingress — always billed as active.ws-server's persistent Redis pub/sub connections keep replicas "active".- Sportmonks poller fires every 2s inside
api-server.
Scale to zero overnight to stay within the $200 free credit before tournament kickoff.
Daily commands¶
# Night — before bed (scales all apps to 0, stops Postgres)
AZURE_CONFIG_DIR=~/.azure-ftl bash ftl-infra/scripts/scale-down.sh
# Morning — before dev session (starts Postgres, waits 60s, brings apps to min=1)
AZURE_CONFIG_DIR=~/.azure-ftl bash ftl-infra/scripts/scale-up.sh
scale-down.sh stops the Postgres Flex server and sets min-replicas=0 on all Container Apps.
scale-up.sh starts Postgres, waits 60 s for it to accept connections, then sets min-replicas=1 on ftl-stg-api, ftl-stg-ws, ftl-stg-flusher, and ftl-stg-frontend.
Note
Azure auto-restarts a stopped Flex server after 7 days. The daily scale-down.sh call stops it fresh each night, so this limit is never reached during active development.
Staging cost reference¶
| Resource | Daily cost | Notes |
|---|---|---|
| Redis Basic C0 | ~₹50/day | Always-on — no scale-to-zero |
| ACR Basic | ~₹5/day | Always-on |
| Postgres Flex (stopped) | ₹0 | Stopped by scale-down.sh |
| Config | Daily burn | Credit runway |
|---|---|---|
| Original (ws min=3, api min=2) | ~₹800/day | 19 days |
| Reduced (all min=1) | ~₹400/day | 39 days |
| Scale-to-zero overnight (target) | ~₹150/day | ~100 days |
CI pipeline summary¶
flowchart LR
A[push to release/staging] --> B[migrate job]
B --> C[roll containers]
C --> D[smoke /health]
D --> E{green?}
E -- yes --> F[staging live]
E -- no --> G[pipeline fails]
H[push to release/prod] --> I[migrate job]
I --> J[GitHub Environment approval]
J --> K[roll containers]
K --> L[smoke /health]
L --> M[prod live]
The deploy-prod.yml workflow pauses on the GitHub production environment approval gate. Approve ftl-backend first — CI runs migrations then rolls ws-server, api-server, and flusher in parallel. Wait for /health to go green, then approve ftl-frontend.