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.
| Service | Description |
|---|---|
| Azure Boards | Kanban boards, backlogs, sprints, work items |
| Azure Repos | Git repositories (unlimited private repos) |
| Azure Pipelines | CI/CD pipelines (YAML or classic) |
| Azure Artifacts | Package feeds (npm, NuGet, PyPI, Maven) |
| Azure Test Plans | Manual 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:
- Azure DevOps > Project Settings > Service Connections > New
- Select "Azure Resource Manager"
- Choose "Workload Identity Federation" (recommended, no secrets)
- 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.