Azure Container Apps (ACA) sits in the gap between “I just want to run a container” and “I’m operating a Kubernetes cluster.” Under the hood it is Kubernetes plus KEDA, Dapr, and Envoy, but you never touch a node, a kubelet, or an ingress controller. You get scale-to-zero, event-driven autoscaling, a service mesh’s worth of Dapr building blocks, and built-in blue-green via revisions — declared in Bicep or set with one az command.
This guide builds a small two-service system — an orders-api (HTTP, externally reachable) and an orders-worker (queue-driven, internal) — and wires up Dapr pub/sub and state, KEDA scaling, immutable revisions, and weighted traffic splitting. Everything here is az containerapp and Bicep; no kubectl.
Versions. Commands target the
containerappAzure CLI extension and theMicrosoft.Appresource provider (API2024-03-01/2025-01-01). Install once withaz extension add --name containerapp --upgradeand registerMicrosoft.AppplusMicrosoft.OperationalInsights.
1. The environment: the boundary that matters
A Container Apps environment is the security and network boundary. Apps in the same environment share a virtual network and a Log Analytics workspace, and can call each other by name. Apps in different environments cannot. This is your first architecture decision: one environment per bounded context, or one per team — not one per app.
RG=rg-aca-orders
LOC=eastus
ENV=cae-orders
az group create -n $RG -l $LOC
# Log Analytics workspace for the environment
az monitor log-analytics workspace create \
-g $RG -n law-aca-orders
LAW_ID=$(az monitor log-analytics workspace show \
-g $RG -n law-aca-orders --query customerId -o tsv)
LAW_KEY=$(az monitor log-analytics workspace get-shared-keys \
-g $RG -n law-aca-orders --query primarySharedKey -o tsv)
az containerapp env create \
-g $RG -n $ENV -l $LOC \
--logs-workspace-id "$LAW_ID" \
--logs-workspace-key "$LAW_KEY"
Internal vs external ingress, and VNet integration
Ingress is per-app and has three states:
| Setting | Reachable from | Use for |
|---|---|---|
--ingress external |
Public internet (and the environment) | Public APIs, frontends |
--ingress internal |
Only apps in the same environment | Backend services, workers exposing HTTP |
| ingress disabled | Nothing — outbound only | Pure workers (queue consumers, cron) |
For real workloads you give the environment its own subnet so the whole thing sits inside your hub-and-spoke. Use a workload profiles environment (which supports both the serverless “Consumption” profile and dedicated profiles) and delegate a subnet sized /23 or larger:
# Subnet must be >= /23 for workload-profile environments
SUBNET_ID=$(az network vnet subnet show \
-g rg-network --vnet-name vnet-spoke-app -n snet-aca \
--query id -o tsv)
az containerapp env create \
-g $RG -n $ENV -l $LOC \
--enable-workload-profiles \
--infrastructure-subnet-resource-id "$SUBNET_ID" \
--internal-only true \
--logs-workspace-id "$LAW_ID" --logs-workspace-key "$LAW_KEY"
--internal-only true means even external apps get a private VIP — the environment’s ingress is reachable only from the VNet, so you front it with Application Gateway or Front Door and keep nothing on the public internet. The subnet cannot be changed after creation, so size it once and correctly.
2. Deploy the first app
Pull-from-registry and identity come later; start with a public image to prove the path.
az containerapp create \
-g $RG -n orders-api \
--environment $ENV \
--image mcr.microsoft.com/k8se/quickstart:latest \
--target-port 8080 \
--ingress external \
--workload-profile-name Consumption \
--min-replicas 1 --max-replicas 5 \
--cpu 0.5 --memory 1.0Gi
az containerapp show -g $RG -n orders-api \
--query properties.configuration.ingress.fqdn -o tsv
--cpu/--memory must follow allowed ratios on the Consumption profile (1 vCPU : 2 GiB), e.g. 0.25/0.5Gi, 0.5/1.0Gi, 1.0/2.0Gi. Dedicated workload profiles relax this.
3. Enable Dapr and wire pub/sub, state, and service invocation
Dapr is enabled per app but its components are scoped at the environment and shared. The critical detail teams miss: an app’s Dapr identity is its --dapr-app-id, and that ID is what other apps use for service invocation and what the sidecar uses for component scoping.
Enable the sidecar on both apps:
az containerapp update -g $RG -n orders-api \
--enable-dapr true \
--dapr-app-id orders-api \
--dapr-app-port 8080 \
--dapr-app-protocol http
az containerapp update -g $RG -n orders-worker \
--enable-dapr true \
--dapr-app-id orders-worker \
--dapr-app-port 8080
A pub/sub component (Azure Service Bus)
Components are declared in YAML and registered against the environment. Scope them to only the apps that need them — an unscoped component is loaded by every Dapr-enabled app in the environment.
# pubsub-servicebus.yaml
componentType: pubsub.azure.servicebus.topics
version: v1
metadata:
- name: namespaceName
value: "sb-orders.servicebus.windows.net"
- name: consumerID
value: "orders-worker"
# Identity-based auth: the app's managed identity must have
# the Azure Service Bus Data Owner/Sender/Receiver role.
scopes:
- orders-api
- orders-worker
az containerapp env dapr-component set \
-g $RG -n $ENV \
--dapr-component-name orderpubsub \
--yaml pubsub-servicebus.yaml
Note there is no apiVersion/kind/metadata.name block here — the ACA YAML schema for dapr-component set is the component spec body only; the component name comes from --dapr-component-name. This trips up everyone copying a raw Dapr component manifest.
The publisher calls its own sidecar; Dapr handles the broker:
# From inside orders-api, the sidecar listens on $DAPR_HTTP_PORT (3500)
curl -X POST "http://localhost:3500/v1.0/publish/orderpubsub/orders.created" \
-H "Content-Type: application/json" \
-d '{"orderId":"A-1001","total":42.50}'
The subscriber declares its subscription (programmatically via /dapr/subscribe or a declarative subscription resource) and Dapr POSTs each message to the app’s route. State and service invocation follow the same pattern: a state.azure.cosmosdb component plus GET/POST http://localhost:3500/v1.0/state/<store>, and service-to-service calls via http://localhost:3500/v1.0/invoke/orders-worker/method/health — no DNS, no client-side load balancing, mTLS between sidecars for free.
Why this over plain HTTP between apps? Dapr service invocation gives you mTLS, retries, and consistent telemetry without an SDK. But it adds a sidecar (latency + memory) to every replica. If two services only ever do simple internal HTTP,
internalingress alone may be enough.
4. KEDA scale rules: HTTP, queue depth, and custom
ACA scaling is KEDA. Every app has a scale rule; the default is HTTP concurrency. The numbers that matter are --min-replicas and --max-replicas, plus the rule that decides where between them you sit.
Scale to zero
Setting --min-replicas 0 lets an idle app cost nothing. The catch: scale-to-zero requires an event source that can wake the app. HTTP and the Dapr/queue scalers can; a plain TCP app with no trigger cannot wake from zero. The worker is the perfect candidate — no traffic, no replicas.
HTTP scaling
az containerapp update -g $RG -n orders-api \
--min-replicas 1 --max-replicas 20 \
--scale-rule-name http-rule \
--scale-rule-type http \
--scale-rule-http-concurrency 50
Each replica handles ~50 concurrent requests before KEDA adds another. Keep min-replicas at 1+ for latency-sensitive public APIs to dodge cold starts.
Queue-depth scaling (the worker)
Scale orders-worker on Service Bus queue length, from zero. Authentication metadata for custom scalers references a secret on the app:
az containerapp update -g $RG -n orders-worker \
--min-replicas 0 --max-replicas 30 \
--secrets "sb-conn=<service-bus-connection-string>" \
--scale-rule-name sb-queue \
--scale-rule-type azure-servicebus \
--scale-rule-metadata "queueName=orders" "messageCount=20" \
--scale-rule-auth "connection=sb-conn"
messageCount=20 is the target backlog per replica: 200 pending messages drives ~10 replicas. This is throughput tuning, not just a threshold — set it from how long one message takes to process.
The same shape covers azure-queue (Storage Queues), kafka, redis, and dozens of other KEDA scalers; --scale-rule-type plus --scale-rule-metadata is the universal lever. Note ACA fixes the KEDA polling/cooldown internally — you tune target metrics, not the controller.
5. Revisions: single vs multiple mode
Every meaningful change to an app’s template (image, env vars, scale, resources) creates a new immutable revision. Changes to configuration (ingress, secrets, registries) do not — that distinction is the whole revision model.
Two modes:
- Single revision mode (default): activating a new revision deactivates the old one. Clean, but no overlap.
- Multiple revision mode: old and new revisions run side by side, and you control how traffic splits. This is what unlocks blue-green and canary.
Switch the API to multiple mode and pin a readable revision suffix:
az containerapp revision set-mode -g $RG -n orders-api --mode multiple
az containerapp update -g $RG -n orders-api \
--image acrorders.azurecr.io/orders-api:1.4.0 \
--revision-suffix v1-4-0
The suffix makes the revision name orders-api--v1-4-0 instead of a random hash — non-negotiable for traffic-splitting commands and runbooks. Suffixes must be unique per app; you cannot reuse v1-4-0 even after deleting it, so encode the build/semver.
6. Weighted traffic splitting: canary and blue-green
In multiple revision mode, ingress traffic is distributed by weight across revisions. Ship 1.5.0 alongside 1.4.0 but send it nothing yet:
az containerapp update -g $RG -n orders-api \
--image acrorders.azurecr.io/orders-api:1.5.0 \
--revision-suffix v1-5-0
# Both revisions exist; keep 100% on the stable one
az containerapp ingress traffic set -g $RG -n orders-api \
--revision-weight orders-api--v1-4-0=100 orders-api--v1-5-0=0
Canary in steps — weights must sum to 100:
# 10% canary
az containerapp ingress traffic set -g $RG -n orders-api \
--revision-weight orders-api--v1-4-0=90 orders-api--v1-5-0=10
# Watch metrics, then 50/50, then cut over
az containerapp ingress traffic set -g $RG -n orders-api \
--revision-weight orders-api--v1-4-0=0 orders-api--v1-5-0=100
Rollback is the same command with the weights reversed — instant, because the old revision is still running. For sticky testing without affecting users, give the new revision a label and hit its stable per-label FQDN directly:
az containerapp revision label add -g $RG -n orders-api \
--revision orders-api--v1-5-0 --label canary
# -> https://orders-api---canary.<env-hash>.<region>.azurecontainerapps.io
You can also pin by weight and use --revision-weight latest=N so new revisions inherit a canary slice automatically — useful in CI/CD where the suffix is generated per build.
7. Secrets, managed identity, and a private registry
Hardcoding a registry password or connection string in the template is the most common ACA mistake. Use a user-assigned managed identity for both registry pull and Key Vault-backed secrets.
# Identity + AcrPull on the registry
UAMI_ID=$(az identity create -g $RG -n id-orders --query id -o tsv)
UAMI_CID=$(az identity show -g $RG -n id-orders --query clientId -o tsv)
ACR_ID=$(az acr show -n acrorders --query id -o tsv)
az role assignment create \
--assignee "$UAMI_CID" --role AcrPull --scope "$ACR_ID"
# Attach identity and configure registry to use it (no password)
az containerapp identity assign -g $RG -n orders-api --user-assigned "$UAMI_ID"
az containerapp registry set -g $RG -n orders-api \
--server acrorders.azurecr.io \
--identity "$UAMI_ID"
Reference a Key Vault secret instead of inlining it. The identity needs Key Vault Secrets User on the vault:
az containerapp secret set -g $RG -n orders-api \
--secrets "sb-conn=keyvaultref:https://kv-orders.vault.azure.net/secrets/sb-conn,identityref:$UAMI_ID"
# Surface the secret to the app as an env var
az containerapp update -g $RG -n orders-api \
--set-env-vars "SB_CONNECTION=secretref:sb-conn"
keyvaultref:...,identityref:... makes ACA resolve the secret at runtime through the managed identity — the value never lives in your IaC or pipeline. secretref: then projects it to an env var without exposing it in the template.
8. Health probes, startup ordering, and graceful shutdown
ACA supports the three Kubernetes probe types, declared in the container template. Bicep is the clean way to express them:
// fragment of the container template
probes: [
{
type: 'Startup'
httpGet: { path: '/healthz/startup', port: 8080 }
periodSeconds: 5
failureThreshold: 30 // up to 150s to become ready
}
{
type: 'Liveness'
httpGet: { path: '/healthz/live', port: 8080 }
periodSeconds: 10
failureThreshold: 3
}
{
type: 'Readiness'
httpGet: { path: '/healthz/ready', port: 8080 }
periodSeconds: 5
failureThreshold: 3
}
]
Startup ordering across services: ACA has no dependsOn between apps at runtime. Don’t assume orders-worker is up when orders-api starts — make readiness probes reflect real dependencies (e.g. /healthz/ready returns 503 until the Service Bus connection is live) and let retries do the rest. Dapr helps here: the sidecar buffers and retries service invocation, so transient unavailability of a callee doesn’t hard-fail the caller.
Graceful shutdown: on scale-in or a new revision, ACA sends SIGTERM, stops routing new requests, and waits out the termination grace period before SIGKILL. Your app must catch SIGTERM, drain in-flight work, and exit. For the queue worker this means: stop pulling new messages, finish the current one, then exit — otherwise scale-in events drop messages mid-process.
Verify
Confirm each layer independently rather than trusting that “it deployed.”
# Revisions and their traffic weights
az containerapp revision list -g $RG -n orders-api \
--query "[].{name:name, active:properties.active, weight:properties.trafficWeight, replicas:properties.replicas}" -o table
# Live replica count (watch it scale)
az containerapp replica list -g $RG -n orders-worker -o table
# Dapr components visible to the environment
az containerapp env dapr-component list -g $RG -n $ENV -o table
# Hit the canary label endpoint directly
curl -s https://orders-api---canary.<env-hash>.<region>.azurecontainerapps.io/healthz/ready -o /dev/null -w "%{http_code}\n"
Then prove KEDA actually scaled from zero. Push messages onto the queue and confirm the worker wakes, processes, and scales back to zero. In Log Analytics, the ContainerAppSystemLogs_CL table records scaling decisions and the ContainerAppConsoleLogs_CL table holds stdout/stderr:
ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "orders-worker"
| where TimeGenerated > ago(15m)
| project TimeGenerated, RevisionName_s, ReplicaName_s, Log_s
| order by TimeGenerated desc
Observability: Dapr dashboard, logs, and App Insights
For Dapr-level visibility — components loaded, sidecar config, service invocation — enable the Dapr dashboard locally against the environment:
az containerapp env dapr-component list -g $RG -n $ENV -o yaml # what's registered
# Dapr telemetry (traces) flows to Application Insights when configured
Wire distributed tracing by attaching an Application Insights connection string to the environment’s Dapr configuration so sidecar-to-sidecar calls produce a real trace graph:
AI_CONN=$(az monitor app-insights component show \
-g $RG -a appi-orders --query connectionString -o tsv)
az containerapp env update -g $RG -n $ENV \
--dapr-instrumentation-key "$AI_CONN"
Now a publish from orders-api through Service Bus to orders-worker shows as a connected end-to-end transaction in the Application Insights application map — the single most useful artifact when a message “disappears” between services.
Enterprise scenario
A payments platform team ran orders-api and three downstream workers on ACA, all scale-to-zero to control cost in non-prod. In production they hit two related problems. First, every Friday-evening deploy caused a brief spike of 502s: single revision mode tore down the old revision the instant the new one activated, and in-flight requests on draining replicas were cut. Second, a bad build once shipped straight to 100% of traffic with no safety net.
The constraint: no Kubernetes team, no service mesh budget, and a hard rule from the platform org that production rollouts must be progressive and instantly reversible without a redeploy.
They solved it entirely within ACA. They put orders-api in multiple revision mode with semver revision suffixes, and changed the pipeline to deploy at 0% weight, attach a canary label, and run smoke tests against the per-label FQDN before any user saw it. Promotion became a weighted ramp (10 -> 50 -> 100) gated on App Insights failure-rate, with rollback as a one-line weight flip to the previous revision — which was still warm. They also fixed graceful shutdown so SIGTERM drained in-flight orders, killing the deploy-time 502s.
# CI step: ship dark, smoke-test the canary label, then ramp
az containerapp update -g $RG -n orders-api \
--image acrorders.azurecr.io/orders-api:$SEMVER --revision-suffix ${SEMVER//./-}
az containerapp ingress traffic set -g $RG -n orders-api \
--revision-weight latest=0
az containerapp revision label add -g $RG -n orders-api \
--revision "orders-api--${SEMVER//./-}" --label canary
# ... run smoke tests against https://orders-api---canary.<env-hash>... ...
az containerapp ingress traffic set -g $RG -n orders-api \
--revision-weight orders-api--${SEMVER//./-}=10 \
--revision-weight "$(az containerapp ingress show -g $RG -n orders-api \
--query 'traffic[?weight>`0`].revisionName | [0]' -o tsv)=90"
The lesson: ACA’s revision + label + weight primitives are a complete progressive-delivery system. No Argo Rollouts, no Flagger, no mesh — just the platform features, used deliberately.