Skip to content

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/development before any edits: git checkout -b feat/<name> origin/development.
  • One branch, one scope. If scope expands, branch again from development.
  • Push to origin immediately after the first meaningful commit.
  • Never force-push to main or 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:

docker run --name trade-window-gating-api ...

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.