Chapter 9: DevOps & CI/CD

The path from a git push to a running, observable production service. Azure has its own DevOps suite, ships native GitHub Actions integration, and supports both Bicep and Terraform for infrastructure as code. Pick a path here and stick with it.

Azure Container Registry (ACR)

ACR is a managed, private Docker container registry. Push your images here, then pull them into AKS, App Service, Container Instances, or Azure Functions.

# Create a container registry
az acr create \
  --resource-group myapp-rg \
  --name myappregistry \
  --sku Basic \
  --admin-enabled false    # Use managed identity, not admin account

# Log in to the registry
az acr login --name myappregistry

# Build and push an image
az acr build \
  --registry myappregistry \
  --image myapp:v1.0.0 \
  .

# Or use Docker directly
docker build -t myapp:v1.0.0 .
docker tag myapp:v1.0.0 myappregistry.azurecr.io/myapp:v1.0.0
docker push myappregistry.azurecr.io/myapp:v1.0.0

# List images in the registry
az acr repository list --name myappregistry --output table

# List tags for an image
az acr repository show-tags \
  --name myappregistry \
  --repository myapp \
  --output table

# Grant AKS pull access (using managed identity)
az aks update \
  --resource-group myapp-rg \
  --name myapp-aks \
  --attach-acr myappregistry

ACR Tasks (CI in the registry)

Run builds automatically when base images change or on a schedule:

# Build on every git commit (requires a PAT)
az acr task create \
  --registry myappregistry \
  --name build-on-commit \
  --image myapp:{{.Run.ID}} \
  --context https://github.com/myorg/myrepo.git#main \
  --file Dockerfile \
  --git-access-token <github-pat>

# Trigger a manual run
az acr task run --registry myappregistry --name build-on-commit

Azure DevOps

Azure DevOps is a suite of services for planning, coding, building, testing, and deploying software.

ServiceDescription
Azure BoardsKanban boards, backlogs, sprints, work items
Azure ReposGit repositories (unlimited private repos)
Azure PipelinesCI/CD pipelines (YAML or classic)
Azure ArtifactsPackage feeds (npm, NuGet, PyPI, Maven)
Azure Test PlansManual and exploratory testing

Azure Pipelines (YAML)

Pipelines are defined in azure-pipelines.yml at the root of your repository.

# azure-pipelines.yml: Python web app to Azure App Service
trigger:
  branches:
    include:
      - main

variables:
  azureSubscription: "MyAzureServiceConnection"
  appName: "myapp-api"
  resourceGroup: "myapp-rg"
  pythonVersion: "3.11"

stages:
  - stage: Build
    displayName: "Build & Test"
    jobs:
      - job: BuildJob
        pool:
          vmImage: ubuntu-latest
        steps:
          - task: UsePythonVersion@0
            inputs:
              versionSpec: "$(pythonVersion)"

          - script: |
              python -m venv venv
              source venv/bin/activate
              pip install -r requirements.txt
            displayName: "Install dependencies"

          - script: |
              source venv/bin/activate
              python -m pytest tests/ --junitxml=test-results.xml
            displayName: "Run tests"

          - task: PublishTestResults@2
            inputs:
              testResultsFiles: "test-results.xml"
            condition: always()

          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: "$(System.DefaultWorkingDirectory)"
              includeRootFolder: false
              archiveFile: "$(Build.ArtifactStagingDirectory)/app.zip"

          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: "$(Build.ArtifactStagingDirectory)"
              artifactName: "drop"

  - stage: DeployStaging
    displayName: "Deploy to Staging"
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: DeployStaging
        environment: staging
        pool:
          vmImage: ubuntu-latest
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: "$(azureSubscription)"
                    appType: webAppLinux
                    appName: "$(appName)"
                    deployToSlotOrASE: true
                    resourceGroupName: "$(resourceGroup)"
                    slotName: staging
                    package: "$(Pipeline.Workspace)/drop/app.zip"

  - stage: DeployProduction
    displayName: "Deploy to Production"
    dependsOn: DeployStaging
    condition: succeeded()
    jobs:
      - deployment: SwapSlots
        environment: production   # Can require manual approval
        pool:
          vmImage: ubuntu-latest
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureAppServiceManage@0
                  inputs:
                    azureSubscription: "$(azureSubscription)"
                    action: Swap Slots
                    webAppName: "$(appName)"
                    resourceGroupName: "$(resourceGroup)"
                    sourceSlot: staging

Service Connections

A Service Connection lets Azure Pipelines authenticate to your Azure subscription:

  1. Azure DevOps > Project Settings > Service Connections > New
  2. Select "Azure Resource Manager"
  3. Choose "Workload Identity Federation" (recommended, no secrets)
  4. Select the subscription and resource group

Pipeline Environments with Approvals

# In the Environments UI, add approval gates so a human must approve
# before the production deployment runs
environment: production   # Create in Azure DevOps > Pipelines > Environments

GitHub Actions with Azure

GitHub Actions is deeply integrated with Azure. Use it if your code is on GitHub.

Deploy to App Service

# .github/workflows/deploy.yml
name: Deploy to Azure App Service

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    permissions:
      id-token: write  # Required for OIDC auth
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies and test
        run: |
          pip install -r requirements.txt
          pytest tests/

      - name: Azure login (OIDC, no stored secrets)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: myapp-api
          slot-name: staging
          package: .

      - name: Swap staging to production
        run: |
          az webapp deployment slot swap \
            --resource-group myapp-rg \
            --name myapp-api \
            --slot staging \
            --target-slot production

Set Up OIDC Authentication (No Secrets)

# Create a service principal with federated credentials
az ad app create --display-name "github-actions-myrepo"
APP_ID=$(az ad app list --display-name "github-actions-myrepo" --query "[0].appId" -o tsv)

az ad sp create --id $APP_ID
SP_OBJECT_ID=$(az ad sp show --id $APP_ID --query id -o tsv)

# Create federated credential for the main branch
az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/myrepo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# Grant access to your subscription
az role assignment create \
  --assignee $SP_OBJECT_ID \
  --role Contributor \
  --scope /subscriptions/<subscription-id>/resourceGroups/myapp-rg

Then add AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID to GitHub repository secrets.

Build and Push to ACR

# .github/workflows/build-image.yml
name: Build and Push Container Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Azure login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Build and push to ACR
        run: |
          az acr build \
            --registry myappregistry \
            --image myapp:${{ github.sha }} \
            --image myapp:latest \
            .

      - name: Deploy to AKS
        run: |
          az aks get-credentials --resource-group myapp-rg --name myapp-aks
          kubectl set image deployment/myapp myapp=myappregistry.azurecr.io/myapp:${{ github.sha }}
          kubectl rollout status deployment/myapp

Infrastructure as Code

Azure Bicep

Bicep is the native Azure IaC language, compiled to ARM templates:

// infra/main.bicep
param location string = resourceGroup().location
param appName string
param environment string = 'prod'

var sqlServerName = '${appName}-${environment}-sql'
var appServicePlanName = '${appName}-${environment}-plan'

resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: appServicePlanName
  location: location
  sku: {
    name: 'S1'
    tier: 'Standard'
  }
  kind: 'linux'
  properties: {
    reserved: true
  }
}

resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: '${appName}-${environment}'
  location: location
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      linuxFxVersion: 'PYTHON|3.11'
      appSettings: [
        {
          name: 'ENVIRONMENT'
          value: environment
        }
      ]
    }
  }
}

output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
# Deploy Bicep in a pipeline
az deployment group create \
  --resource-group myapp-rg \
  --template-file infra/main.bicep \
  --parameters appName=myapp environment=prod

Terraform

Terraform is the cloud-agnostic alternative (same config works across Azure, AWS, GCP):

# main.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
  backend "azurerm" {
    resource_group_name  = "terraform-state-rg"
    storage_account_name = "tfstatemyapp"
    container_name       = "tfstate"
    key                  = "myapp.tfstate"
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "main" {
  name     = "myapp-prod-rg"
  location = "East US"
}

resource "azurerm_service_plan" "main" {
  name                = "myapp-plan"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  os_type             = "Linux"
  sku_name            = "S1"
}

resource "azurerm_linux_web_app" "main" {
  name                = "myapp-api"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  service_plan_id     = azurerm_service_plan.main.id

  site_config {
    application_stack {
      python_version = "3.11"
    }
  }
}
terraform init      # Download providers + configure backend
terraform plan      # Preview changes
terraform apply     # Apply changes
terraform destroy   # Destroy all resources

GitOps with Flux (AKS)

For Kubernetes workloads, GitOps treats your Git repository as the single source of truth for cluster state:

# Install Flux on AKS
az k8s-configuration flux create \
  --resource-group myapp-rg \
  --cluster-name myapp-aks \
  --cluster-type managedClusters \
  --name myapp-flux \
  --namespace flux-system \
  --url https://github.com/myorg/myapp-gitops \
  --branch main \
  --kustomization name=apps path=./apps

Flux watches the Git repo and automatically reconciles the cluster state. No kubectl apply in pipelines.

Next Steps

Continue to 10-monitoring.md to learn how to monitor, observe, and alert on your Azure workloads.