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