swbanga.com
By Super Washington Banga8 min readCloud Infra

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:

hcl
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:

yaml
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.

python
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.

bash
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!