Learnixo

.NET & C# Development · Lesson 179 of 229

Deploying .NET to Azure Container Apps — Full Guide

Deploying .NET to Azure Container Apps — Full Guide

Azure Container Apps (ACA) is a managed serverless container platform built on Kubernetes. You get auto-scaling, Dapr integration, traffic splitting, and managed ingress without writing a single Kubernetes manifest.


When to Choose ACA

Azure Container Apps:
  ✓ Managed Kubernetes — no cluster to maintain
  ✓ Built-in KEDA — scale to zero, scale on queue depth, HTTP traffic
  ✓ Dapr sidecar — service discovery, pub/sub, state management
  ✓ Traffic splitting — blue/green, canary with percentage routing
  ✓ Managed ingress with TLS — no nginx config needed
  ✓ Consumption billing — pay per request (can be $0 at idle)
  ✗ Less control than raw AKS
  ✗ Limited egress control (use AKS + Istio for complex network policies)

AKS:
  ✓ Full Kubernetes — any workload, any network policy
  ✗ You manage cluster upgrades, node pools, and security patching

Step 1: Create the Environment

Bash
# Install Azure CLI extension
az extension add --name containerapp --upgrade

# Create resource group
az group create --name rg-orders --location uksouth

# Create Container Apps environment (shared infrastructure for all apps)
az containerapp env create \
  --name aca-env-orders \
  --resource-group rg-orders \
  --location uksouth \
  --logs-destination log-analytics \
  --logs-workspace-id $LOG_ANALYTICS_WORKSPACE_ID \
  --logs-workspace-key $LOG_ANALYTICS_KEY

Step 2: Deploy a .NET App

Bash
# Build and push image
az acr build \
  --registry myregistry \
  --image order-service:latest \
  --file src/OrderService/Dockerfile .

# Create the container app
az containerapp create \
  --name order-service \
  --resource-group rg-orders \
  --environment aca-env-orders \
  --image myregistry.azurecr.io/order-service:latest \
  --target-port 8080 \
  --ingress external \
  --min-replicas 1 \
  --max-replicas 10 \
  --cpu 0.5 \
  --memory 1Gi \
  --env-vars \
    ASPNETCORE_ENVIRONMENT=Production \
    "ConnectionStrings__DefaultConnection=secretref:db-connection-string" \
  --secrets \
    "db-connection-string=Host=postgres.example.com;Database=orders;..."

Step 3: Bicep / Infrastructure as Code

BICEP
// container-apps.bicep — declarative resource definition
param environment string = 'production'
param imageTag string = 'latest'

resource acaEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
  name: 'aca-env-orders'
  location: resourceGroup().location
  properties: {
    daprAIInstrumentationKey: applicationInsights.properties.InstrumentationKey
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
  }
}

resource orderService 'Microsoft.App/containerApps@2023-05-01' = {
  name: 'order-service'
  location: resourceGroup().location
  properties: {
    managedEnvironmentId: acaEnv.id
    configuration: {
      ingress: {
        external: true
        targetPort: 8080
        transport: 'http2'
        corsPolicy: {
          allowedOrigins: ['https://app.example.com']
          allowedMethods: ['GET', 'POST', 'PUT', 'DELETE']
        }
      }
      dapr: {
        enabled: true
        appId: 'order-service'
        appPort: 8080
        httpReadBufferSize: 64
      }
      secrets: [
        {
          name: 'db-connection-string'
          keyVaultUrl: 'https://kv-orders.vault.azure.net/secrets/db-connection-string'
          identity: '/subscriptions/.../resourcegroups/.../providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity'
        }
      ]
    }
    template: {
      containers: [
        {
          name: 'order-service'
          image: 'myregistry.azurecr.io/order-service:${imageTag}'
          resources: { cpu: json('0.5'), memory: '1Gi' }
          env: [
            { name: 'ASPNETCORE_ENVIRONMENT', value: environment }
            { name: 'ConnectionStrings__DefaultConnection', secretRef: 'db-connection-string' }
          ]
          probes: [
            {
              type: 'Liveness'
              httpGet: { path: '/health/live', port: 8080 }
              initialDelaySeconds: 10
              periodSeconds: 10
            }
            {
              type: 'Readiness'
              httpGet: { path: '/health/ready', port: 8080 }
              initialDelaySeconds: 5
              periodSeconds: 5
            }
          ]
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 20
        rules: [
          {
            name: 'http-scaling'
            http: { metadata: { concurrentRequests: '100' } }
          }
        ]
      }
    }
  }
}

Step 4: KEDA Scaling Rules

BICEP
// Scale on Azure Service Bus queue depth
scale: {
  minReplicas: 0   // scale to zero when idle (save cost)
  maxReplicas: 30
  rules: [
    {
      name: 'service-bus-scaling'
      custom: {
        type: 'azure-servicebus'
        metadata: {
          queueName: 'order-processing'
          messageCount: '10'   // one replica per 10 messages
          namespace: 'sb-orders.servicebus.windows.net'
        }
        auth: [
          {
            secretRef: 'service-bus-connection'
            triggerParameter: 'connection'
          }
        ]
      }
    }
  ]
}

Step 5: Traffic Splitting (Blue/Green and Canary)

Bash
# Deploy new version as a revision
az containerapp update \
  --name order-service \
  --resource-group rg-orders \
  --image myregistry.azurecr.io/order-service:v2.0.0 \
  --revision-suffix v2

# Canary: send 10% of traffic to new revision
az containerapp ingress traffic set \
  --name order-service \
  --resource-group rg-orders \
  --revision-weight \
    order-service--v1=90 \
    order-service--v2=10

# Promote to 100% after validation
az containerapp ingress traffic set \
  --name order-service \
  --resource-group rg-orders \
  --revision-weight order-service--v2=100

# Deactivate old revision
az containerapp revision deactivate \
  --name order-service \
  --resource-group rg-orders \
  --revision order-service--v1

Step 6: Dapr Integration in .NET

C#
// Program.cs — Dapr client for service invocation and pub/sub
builder.Services.AddDaprClient();

// Service-to-service calls (service discovery built in)
public class OrderNotificationService(DaprClient dapr)
{
    public async Task NotifyShippedAsync(int orderId, CancellationToken ct)
    {
        // Calls notification-service via Dapr — no hard-coded URLs
        await dapr.InvokeMethodAsync(
            HttpMethod.Post,
            "notification-service",   // Dapr app ID
            "api/notify/shipped",
            new { OrderId = orderId },
            ct);
    }
}

// Pub/sub via Dapr
public class OrderEventPublisher(DaprClient dapr)
{
    public async Task PublishOrderPlacedAsync(int orderId, CancellationToken ct)
        => await dapr.PublishEventAsync("pubsub", "order-placed",
            new OrderPlacedEvent(orderId, DateTime.UtcNow), ct);
}

// Subscribe to events
[ApiController]
[Route("api/dapr")]
public class DaprSubscriptionController : ControllerBase
{
    [Topic("pubsub", "order-placed")]
    [HttpPost("order-placed")]
    public async Task<IActionResult> HandleOrderPlaced(
        OrderPlacedEvent @event,
        [FromServices] IInventoryService inventory,
        CancellationToken ct)
    {
        await inventory.ReserveAsync(@event.OrderId, ct);
        return Ok();   // Dapr marks message as processed
    }
}

Step 7: GitHub Actions Deployment

YAML
# .github/workflows/deploy-aca.yml
- name: Deploy to Azure Container Apps
  uses: azure/container-apps-deploy-action@v1
  with:
    appSourcePath: ${{ github.workspace }}
    acrName: myregistry
    containerAppName: order-service
    resourceGroup: rg-orders
    imageToDeploy: myregistry.azurecr.io/order-service:sha-${{ github.sha }}
    targetPort: 8080
    ingress: external
    environmentVariables: |
      ASPNETCORE_ENVIRONMENT=Production
      OpenTelemetry__Endpoint=https://ingest.example.com

Interview Answer

"Azure Container Apps is managed Kubernetes with a serverless pricing model — you deploy containers without writing K8s manifests. The environment is the shared infrastructure (networking, monitoring, Dapr), and each container app is a versioned, scalable unit. KEDA scaling rules let you scale to zero and back up on HTTP concurrency, queue depth, or any custom metric — important for cost control. Traffic splitting is built in: deploy a new revision, canary it at 10%, validate, promote to 100%, deactivate old revision — no nginx config. Dapr sidecar handles service discovery, pub/sub, and state management — your .NET code calls dapr.InvokeMethodAsync('other-service', ...) and Dapr resolves the current instance. Secrets: reference Azure Key Vault secrets directly in the Bicep/ARM config via keyVaultUrl + managed identity — secrets are never in your code or CI variables. For .NET apps: expose health endpoints at /health/live and /health/ready, set probes in Bicep so ACA can route around unhealthy instances during rolling deploys."