Home / Azure / Azure Container Apps: Serverless Containers Made Simple
Azure

Azure Container Apps: Serverless Containers Made Simple

Deploy containerised microservices without managing Kubernetes. Azure Container Apps handles ingress, scaling, service discovery, and Dapr integration — you just ship containers.

What you will learn

Practical execution with concise explanations, real implementation patterns, and production-ready recommendations.

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

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

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: 0 on background workers and queue-triggered apps — you only pay when processing.

Use ACR Managed Identity. Avoid username/password registry auth. Assign AcrPull role to the Container App's managed identity for zero-secret image pulls.

Pin revision mode. Use multiple revision 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


Using Container Apps in production? Which KEDA scalers have been most useful? Share your patterns below.

Discussion