Deploying FastAPI to Azure Container Apps with scale-to-zero
How Med-Core's FastAPI API ships to Azure Container Apps: a Terraform module with min-replicas zero, KEDA-driven scale-up, and the cold-start budget that makes scale-to-zero the default deployment posture.
- fastapi
- azure-container-apps
- terraform
- scale-to-zero
- postgresql
Why Azure Container Apps for Med-Core
Med-Core is a FastAPI service handling PHI intake for a hospital queue. The traffic profile is hostile to a fixed cluster: bursts during clinic hours, near-zero overnight, and a compliance posture that punishes paying for idle compute. The architecture decision for the platform (ADR-003) landed on Azure Container Apps after rejecting AKS for exactly this reason — a managed PaaS that owns the Kubernetes under the hood, but exposes only what the workload needs: revisions, ingress, and a scale rule.
The scale rule is the whole point. Container Apps wraps KEDA, so a service
can declare its own drivers: HTTP traffic, queue depth, cron. Med-Core's
intake worker scales on the queue; the API scales on HTTP. And both ship
with min_replicas = 0, which is the setting that turns "serverless" from a
marketing term into a cost line that reads zero overnight.
The Terraform module
The deployment is a shared module used by every Med-Core service, so the scale posture lives in one place instead of drifting across resource groups:
resource "azurerm_container_app" "service" {
name = var.service_name
container_app_environment_id = var.environment_id
resource_group_name = var.resource_group
revision_mode = "Single"
template {
min_replicas = 0
max_replicas = var.max_replicas
container {
name = var.service_name
image = var.image
cpu = var.cpu
memory = var.memory
env {
name = "DATABASE_URL"
value = "@Microsoft.KeyVault(VaultName=${var.key_vault_name};SecretName=db-url)"
}
readiness_probe {
path = "/health"
port = 8000
transport = "HTTP"
interval_seconds = 10
failure_threshold = 5
}
}
scale {
min_replicas = 0
max_replicas = var.max_replicas
rule {
name = "http-scale"
custom {
type = "http"
metadata = {
concurrentRequests = "50"
}
}
}
}
}
}
Two details are deliberate. First, the readiness probe — it is the mechanism
that lets a scale-to-zero revision come back cleanly, and it doubles as the
warm-up trigger for anything that needs priming at startup. Second, the
secrets reference keeps DATABASE_URL out of the image and out of the
resource definition, which matters when the deployment itself is Git-backed.
Building and pushing the image
Images flow through a GitHub Actions workflow that builds once and pushes to the service's ACR registry; the Container Apps revision redeploys from the tag:
name: build-and-deploy
on:
push:
branches: [main]
env:
REGISTRY: medcore.azurecr.io
IMAGE: med-core-api
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ACR_CLIENT_ID }}
password: ${{ secrets.ACR_CLIENT_SECRET }}
- run: docker build -t $REGISTRY/$IMAGE:${{ github.sha }} .
- run: docker push $REGISTRY/$IMAGE:${{ github.sha }}
- run: >
az containerapp update --name med-core-api
--resource-group med-core-rg --image $REGISTRY/$IMAGE:${{ github.sha }}
The short SHA is the revision tag, so every commit maps to a reproducible image and the container app's revision history doubles as a deploy ledger.
Database migrations at startup
The API owns its schema. Running migrations as part of the startup path removes a whole class of "migrated in prod, not in the deploy" drift, but it has to be safe against the scale-to-zero lifecycle: a cold start can hit a revision that has been sleeping since before the last migration shipped.
import asyncio
import asyncpg
async def wait_for_database(url: str, attempts: int = 10, delay: float = 2.0) -> None:
"""Block startup until Postgres is reachable, then let Alembic catch up."""
for attempt in range(attempts):
try:
conn = await asyncpg.connect(url, timeout=5)
await conn.close()
break
except (OSError, asyncpg.PostgresError):
if attempt == attempts - 1:
raise
await asyncio.sleep(delay)
The retry loop exists because on a cold start the database container can
still be coming up — PostgreSQL on the same Container Apps environment does
not inherit the API's zero-replica sleep schedule. After the loop succeeds,
alembic upgrade head runs synchronously before uvicorn binds the port, so
the readiness probe only ever sees a migrated service.
Verifying scale-to-zero
Scale-to-zero is only real when it is observed. The deployment log records the first verified run: zero replicas through the overnight window, KEDA scaling up within one polling interval of the first morning request, and a cost graph with zero compute hours for the idle period.
az containerapp show \
--name med-core-api \
--resource-group med-core-rg \
--query "properties.template.scale.minReplicas"
# 0
The az query is now part of the deploy checklist — a revision is not
considered shipped until the console confirms the posture stuck.
The cold-start budget
The fix is not to abandon scale-to-zero — it is to budget for it. The cold start is tracked as a first-class deploy metric, and the warm-up strategy changed so health probes prime the JWT cache instead of waiting for request traffic. An 8.4s first request is acceptable for an intake API behind a queue; it would not be for a user-facing page, and the budget should be set per workload, not globally.
What to watch next
The full timeline — the deploy, the 02:00 zero-replica confirmation, and the 09:12 KEDA warm-up — is in the scale-to-zero deployment log in the journal, and the platform trade-off analysis lives in the architecture vault's Azure Container Apps decision record. The next revision's open question is whether the worker's scale-in delay should linger longer than the API's, so the cold-start budget stays a queue-latency concern and never becomes an API-latency one.
Comments are currently disabled. Coming soon!