Azure Container Apps: Serverless Containers Made Simple
Azure Container Apps sits between Azure App Service (opinionated) and AKS (full control). It runs any container, auto-scales to zero, and handles HTTPS ingress, service-to-service networking, and Dapr integration — without any Kubernetes expertise required.
Prerequisites
| Requirement | Details |
|---|---|
| Azure subscription | Free trial |
| Azure CLI | With containerapp extension: az extension add --name containerapp |
| Docker | For building images locally |
| Azure Container Registry | For hosting your images |
Architecture Overview
flowchart TB
subgraph Internet["Internet"]
CLIENT[Client / API Consumer]
end
subgraph ACA_ENV["Container Apps Environment\n(Managed Kubernetes boundary)"]
direction TB
INGRESS[Azure-managed\nIngress Controller\nHTTPS + TLS]
subgraph Apps["Container Apps"]
API[api-demo\nNode.js API]
WORKER[background-worker\nQueue processor]
AUTH[auth-service\nJWT validator]
end
DAPR[Dapr Sidecar\nService Bus / State]
LOG[Log Analytics\nStructured logs]
end
subgraph ACR["Azure Container Registry"]
IMG[Container Images]
end
subgraph Scale["KEDA Auto-Scaling"]
HTTP_SCALE[HTTP concurrency]
CPU_SCALE[CPU utilization]
QUEUE_SCALE[Queue length]
end
CLIENT --> INGRESS --> API
API --> AUTH
API --> WORKER
API --> DAPR
Apps --> LOG
Scale --> Apps
ACR --> Apps
style Internet fill:#fee2e2,stroke:#ef4444,color:#7f1d1d
style ACA_ENV fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
style Scale fill:#fef3c7,stroke:#f59e0b,color:#78350f
style ACR fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
Container Apps vs. Alternatives
| Container Apps | App Service | AKS | Azure Functions | |
|---|---|---|---|---|
| Container support | ✅ Native | ✅ Custom containers | ✅ Full K8s | ✅ Custom handlers |
| Scale to zero | ✅ Yes | ❌ No | Manual config | ✅ Yes |
| K8s expertise needed | ❌ No | ❌ No | ✅ Yes | ❌ No |
| Dapr support | ✅ Built-in | ❌ No | Manual | ❌ No |
| Multi-container (pods) | ✅ Yes | ❌ No | ✅ Yes | ❌ No |
| Custom KEDA scalers | ✅ Yes | ❌ No | ✅ Yes | Limited |
| Best for | Microservices | Web/API apps | Full control | Event-driven |
Step 1: Set Up the Environment
# Install the Container Apps CLI extension
az extension add --name containerapp --upgrade
# Variables
RESOURCE_GROUP="rg-containerapp-demo"
LOCATION="eastus"
ENVIRONMENT_NAME="cae-prod-env"
ACR_NAME="acrdemo$(openssl rand -hex 4)"
LOG_ANALYTICS_WORKSPACE="law-containerapp"
# Create resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
# Create Log Analytics workspace
az monitor log-analytics workspace create \
--workspace-name $LOG_ANALYTICS_WORKSPACE \
--resource-group $RESOURCE_GROUP
LOG_WORKSPACE_ID=$(az monitor log-analytics workspace show \
--workspace-name $LOG_ANALYTICS_WORKSPACE \
--resource-group $RESOURCE_GROUP \
--query customerId --output tsv)
LOG_WORKSPACE_KEY=$(az monitor log-analytics workspace get-shared-keys \
--workspace-name $LOG_ANALYTICS_WORKSPACE \
--resource-group $RESOURCE_GROUP \
--query primarySharedKey --output tsv)
# Create Container Apps Environment
az containerapp env create \
--name $ENVIRONMENT_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--logs-workspace-id $LOG_WORKSPACE_ID \
--logs-workspace-key $LOG_WORKSPACE_KEY
echo "Environment created: $ENVIRONMENT_NAME"
Step 2: Build and Push the Container Image
Sample Node.js API
// server.js
const express = require('express');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
app.get('/api/hello', (req, res) => {
const name = req.query.name || 'World';
res.json({
message: `Hello, ${name}!`,
version: '1.0.0',
hostname: os.hostname() // Shows which replica handled the request
});
});
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Build and push to ACR
# Create ACR
az acr create \
--name $ACR_NAME \
--resource-group $RESOURCE_GROUP \
--sku Basic \
--admin-enabled true
# ACR credentials
ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --query loginServer --output tsv)
ACR_USERNAME=$(az acr credential show --name $ACR_NAME --query username --output tsv)
ACR_PASSWORD=$(az acr credential show --name $ACR_NAME --query passwords[0].value --output tsv)
# Build directly in ACR (no local Docker required)
az acr build \
--registry $ACR_NAME \
--image demo-api:v1 \
--file Dockerfile \
.
echo "Image pushed: $ACR_LOGIN_SERVER/demo-api:v1"
Step 3: Deploy the Container App
CONTAINER_APP_NAME="api-demo"
az containerapp create \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--environment $ENVIRONMENT_NAME \
--image "$ACR_LOGIN_SERVER/demo-api:v1" \
--registry-server $ACR_LOGIN_SERVER \
--registry-username $ACR_USERNAME \
--registry-password $ACR_PASSWORD \
--target-port 3000 \
--ingress external \
--min-replicas 1 \
--max-replicas 10 \
--cpu 0.5 \
--memory 1.0Gi \
--env-vars "PORT=3000" "ENVIRONMENT=production"
# Get the public URL
APP_URL=$(az containerapp show \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--query properties.configuration.ingress.fqdn \
--output tsv)
echo "Live at: https://$APP_URL"
# Test
curl -s "https://$APP_URL/health" | jq
curl -s "https://$APP_URL/api/hello?name=Azure" | jq
Deploy with Bicep (production-grade)
// main.bicep
param location string = resourceGroup().location
param environmentName string = 'cae-prod-env'
param containerAppName string = 'api-demo'
param containerImage string
param containerPort int = 3000
param minReplicas int = 1
param maxReplicas int = 10
resource environment 'Microsoft.App/managedEnvironments@2023-05-01' existing = {
name: environmentName
}
resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
name: containerAppName
location: location
properties: {
managedEnvironmentId: environment.id
configuration: {
ingress: {
external: true
targetPort: containerPort
allowInsecure: false
}
}
template: {
containers: [
{
name: 'api'
image: containerImage
resources: {
cpu: json('0.5')
memory: '1.0Gi'
}
env: [
{ name: 'PORT', value: string(containerPort) }
{ name: 'ENVIRONMENT', value: 'production' }
]
}
]
scale: {
minReplicas: minReplicas
maxReplicas: maxReplicas
rules: [
{
name: 'http-rule'
http: { metadata: { concurrentRequests: '50' } }
}
]
}
}
}
}
output fqdn string = containerApp.properties.configuration.ingress.fqdn
output revisionName string = containerApp.properties.latestRevisionName
Step 4: Auto-Scaling with KEDA
flowchart LR
subgraph Scalers["KEDA Scalers"]
HTTP[HTTP concurrency\n100 req / replica]
CPU[CPU utilization\n70% threshold]
QUEUE[Azure Queue\n10 messages / replica]
CRON[CRON schedule\nBusiness hours only]
end
subgraph Replicas["Container App Replicas"]
R0[0 replicas\nscaled to zero]
R1[1 replica]
R5[5 replicas]
R20[20 replicas\nmax]
end
HTTP --> Replicas
CPU --> Replicas
QUEUE --> Replicas
CRON --> Replicas
R0 -.->|First request\nwarm-up ~1s| R1
R1 --> R5
R5 --> R20
style Scalers fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
style Replicas fill:#d1fae5,stroke:#059669,color:#065f46
# HTTP scaling — scale out at 100 concurrent requests per replica
az containerapp update \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--min-replicas 2 \
--max-replicas 20 \
--scale-rule-name http-scale \
--scale-rule-type http \
--scale-rule-http-concurrency 100
# CPU scaling — scale out at 70% CPU
az containerapp update \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--scale-rule-name cpu-scale \
--scale-rule-type cpu \
--scale-rule-metadata "type=Utilization" "value=70"
# Queue-based scaling (for workers)
az containerapp update \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--scale-rule-name queue-scale \
--scale-rule-type azure-queue \
--scale-rule-metadata "queueName=work-items" "queueLength=10" \
--scale-rule-auth "connection=queue-connection-string"
KEDA scaling in Bicep
scale: {
minReplicas: 0 // Scale to zero when idle
maxReplicas: 20
rules: [
{
name: 'http-rule'
http: { metadata: { concurrentRequests: '50' } }
}
{
name: 'queue-rule'
custom: {
type: 'azure-queue'
metadata: { queueName: 'work-items', queueLength: '10' }
auth: [ { secretRef: 'queue-conn', triggerParameter: 'connection' } ]
}
}
]
}
Step 5: Traffic Splitting and Revisions
flowchart LR
TRAFFIC[Incoming Traffic\n100%]
subgraph Revisions["Active Revisions (Traffic Split)"]
REV1["revision-abc123\nv1 (stable)\n90%"]
REV2["revision-xyz789\nv2 (canary)\n10%"]
end
TRAFFIC --> REV1
TRAFFIC --> REV2
REV1 --> POD1[Container\nInstances]
REV2 --> POD2[Container\nInstances]
style REV1 fill:#d1fae5,stroke:#059669,color:#065f46
style REV2 fill:#fef3c7,stroke:#f59e0b,color:#78350f
# Update app to new image version (creates new revision)
az containerapp update \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--image "$ACR_LOGIN_SERVER/demo-api:v2"
# Switch to multi-revision mode for traffic splitting
az containerapp revision set-mode \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--mode multiple
# Get revision names
az containerapp revision list \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--output table
# Split traffic: 90% stable, 10% canary
az containerapp ingress traffic set \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--revision-weight \
"$STABLE_REVISION=90" \
"$CANARY_REVISION=10"
# After validating canary — promote to 100%
az containerapp ingress traffic set \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--revision-weight "$CANARY_REVISION=100"
Step 6: Service-to-Service Communication with Dapr
# Enable Dapr on the Container App
az containerapp dapr enable \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--dapr-app-id "api-demo" \
--dapr-app-port 3000 \
--dapr-app-protocol http
# Call another service via Dapr sidecar
# In your app code: http://localhost:3500/v1.0/invoke/other-service/method/endpoint
Internal service-to-service call via Dapr:
// Call 'inventory-service' from within Container Apps environment
const response = await fetch(
'http://localhost:3500/v1.0/invoke/inventory-service/method/items',
{ headers: { 'dapr-app-id': 'inventory-service' } }
);
Monitoring
# Stream live logs
az containerapp logs show \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--follow
# Query logs in Log Analytics
az monitor log-analytics query \
--workspace $LOG_WORKSPACE_ID \
--analytics-query "ContainerAppConsoleLogs_CL | where ContainerAppName_s == '$CONTAINER_APP_NAME' | order by TimeGenerated desc | take 50"
KQL — error rate by revision:
ContainerAppSystemLogs_CL
| where ContainerAppName_s == "api-demo"
| summarize
total = count(),
errors = countif(Level_s == "Error")
by RevisionName_s, bin(TimeGenerated, 5m)
| extend errorRate = round(errors * 100.0 / total, 2)
| order by TimeGenerated desc
Best Practices
Scale to zero for workers. Set
minReplicas: 0on background workers and queue-triggered apps — you only pay when processing.
Use ACR Managed Identity. Avoid username/password registry auth. Assign
AcrPullrole to the Container App's managed identity for zero-secret image pulls.
Pin revision mode. Use
multiplerevision mode in production to enable safe traffic splitting and instant rollback without redeployment.
Keep container images small. Alpine-based images start faster and have smaller attack surfaces. Avoid installing dev dependencies in production images.
Use Dapr for service communication. Don't hardcode service URLs — Dapr provides resilient, load-balanced service invocation within the environment.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| App never starts | Wrong target port | Verify --target-port matches EXPOSE in Dockerfile |
Unauthorized pulling image |
No ACR access | Assign AcrPull role to Container App's managed identity |
| App scales to zero and stays there | No minimum replicas | Set --min-replicas 1 for always-on apps |
| Requests failing between services | Internal FQDN wrong | Use <app-name> as hostname within the same environment |
| Logs missing | No Log Analytics attached | Attach workspace to environment at creation time |
| Canary not receiving traffic | Revision mode is single | Switch to multiple revision mode first |
Key Takeaways
- ✅ Container Apps eliminates Kubernetes management while retaining container flexibility
- ✅ KEDA-based auto-scaling supports HTTP, CPU, queue, and custom metrics out of the box
- ✅ Multi-revision traffic splitting enables zero-downtime canary deployments
- ✅ Dapr integration provides resilient service-to-service calls without custom code
- ✅ Scale-to-zero on workers means you only pay for actual processing time
Additional Resources
- Azure Container Apps documentation
- KEDA scalers reference
- Dapr integration guide
- Bicep AVM module for Container Apps
- Azure Container Apps samples (GitHub)
Using Container Apps in production? Which KEDA scalers have been most useful? Share your patterns below.
Discussion