Chapter 9: DevOps & CI/CD

Azure provides a set of DevOps tools covering source control, pipelines, container registries, and infrastructure as code. This chapter covers the tools that turn code into running, monitored, production software.

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.