Azure DevOps Pipelines: CI/CD Best Practices for Enterprise Deployments
Azure DevOps Pipelines provides powerful CI/CD capabilities for building, testing, and deploying applications. In this guide, I'll share enterprise-grade patterns for creating robust, secure, and efficient deployment pipelines.
Pipeline Architecture
Multi-Stage YAML Pipeline
# azure-pipelines.yml
trigger:
branches:
include:
- main
- develop
- release/*
paths:
exclude:
- docs/*
- README.md
variables:
- group: production-variables
- name: buildConfiguration
value: 'Release'
- name: vmImageName
value: 'ubuntu-latest'
stages:
- stage: Build
displayName: 'Build and Test'
jobs:
- job: BuildJob
displayName: 'Build Application'
pool:
vmImage: $(vmImageName)
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
packageType: 'sdk'
version: '8.x'
- task: DotNetCoreCLI@2
displayName: 'Restore Dependencies'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build Solution'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Run Unit Tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: '--configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"'
- task: PublishCodeCoverageResults@1
displayName: 'Publish Code Coverage'
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
- task: DotNetCoreCLI@2
displayName: 'Publish Application'
inputs:
command: 'publish'
publishWebProjects: true
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: true
- task: PublishBuildArtifacts@1
displayName: 'Publish Artifacts'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'
publishLocation: 'Container'
- stage: SecurityScan
displayName: 'Security Scanning'
dependsOn: Build
jobs:
- job: SecurityJob
displayName: 'Security Analysis'
pool:
vmImage: $(vmImageName)
steps:
- task: WhiteSource@21
displayName: 'WhiteSource Scan'
inputs:
cwd: '$(System.DefaultWorkingDirectory)'
- task: SonarCloudPrepare@1
displayName: 'Prepare SonarCloud'
inputs:
SonarCloud: 'SonarCloud-Connection'
organization: 'my-org'
scannerMode: 'MSBuild'
projectKey: 'my-project'
- task: SonarCloudAnalyze@1
displayName: 'Run SonarCloud Analysis'
- task: SonarCloudPublish@1
displayName: 'Publish Quality Gate Result'
inputs:
pollingTimeoutSec: '300'
- stage: DeployDev
displayName: 'Deploy to Development'
dependsOn: SecurityScan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
jobs:
- deployment: DeployDev
displayName: 'Deploy to Dev Environment'
pool:
vmImage: $(vmImageName)
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- template: templates/deploy-webapp.yml
parameters:
azureSubscription: 'Dev-Subscription'
webAppName: 'myapp-dev'
resourceGroup: 'rg-dev'
slotName: 'staging'
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: SecurityScan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployStaging
displayName: 'Deploy to Staging Environment'
pool:
vmImage: $(vmImageName)
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- template: templates/deploy-webapp.yml
parameters:
azureSubscription: 'Staging-Subscription'
webAppName: 'myapp-staging'
resourceGroup: 'rg-staging'
slotName: 'staging'
- task: AzureAppServiceManage@0
displayName: 'Run Smoke Tests'
inputs:
azureSubscription: 'Staging-Subscription'
Action: 'Start Azure App Service'
WebAppName: 'myapp-staging'
- script: |
npm install -g newman
newman run tests/postman-collection.json \
--environment tests/staging-environment.json \
--reporters cli,junit \
--reporter-junit-export $(Build.ArtifactStagingDirectory)/newman-results.xml
displayName: 'Run API Tests'
- stage: DeployProduction
displayName: 'Deploy to Production'
dependsOn: DeployStaging
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployProduction
displayName: 'Deploy to Production'
pool:
vmImage: $(vmImageName)
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- template: templates/deploy-webapp.yml
parameters:
azureSubscription: 'Prod-Subscription'
webAppName: 'myapp-prod'
resourceGroup: 'rg-prod'
slotName: 'staging'
- task: AzureAppServiceManage@0
displayName: 'Swap Slots'
inputs:
azureSubscription: 'Prod-Subscription'
Action: 'Swap Slots'
WebAppName: 'myapp-prod'
ResourceGroupName: 'rg-prod'
SourceSlot: 'staging'
SwapWithProduction: true
Deployment Template
# templates/deploy-webapp.yml
parameters:
- name: azureSubscription
type: string
- name: webAppName
type: string
- name: resourceGroup
type: string
- name: slotName
type: string
default: 'production'
steps:
- task: DownloadBuildArtifacts@0
displayName: 'Download Artifacts'
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
downloadPath: '$(System.ArtifactsDirectory)'
- task: AzureRmWebAppDeployment@4
displayName: 'Deploy to Azure App Service'
inputs:
ConnectionType: 'AzureRM'
azureSubscription: '${{ parameters.azureSubscription }}'
appType: 'webApp'
WebAppName: '${{ parameters.webAppName }}'
deployToSlotOrASE: true
ResourceGroupName: '${{ parameters.resourceGroup }}'
SlotName: '${{ parameters.slotName }}'
packageForLinux: '$(System.ArtifactsDirectory)/drop/**/*.zip'
enableCustomDeployment: true
DeploymentType: 'zipDeploy'
RemoveAdditionalFilesFlag: true
- task: AzureAppServiceSettings@1
displayName: 'Configure App Settings'
inputs:
azureSubscription: '${{ parameters.azureSubscription }}'
appName: '${{ parameters.webAppName }}'
resourceGroupName: '${{ parameters.resourceGroup }}'
slotName: '${{ parameters.slotName }}'
appSettings: |
[
{
"name": "ASPNETCORE_ENVIRONMENT",
"value": "$(Environment)",
"slotSetting": false
},
{
"name": "ApplicationInsights__InstrumentationKey",
"value": "$(AppInsights.InstrumentationKey)",
"slotSetting": false
}
]
Container-Based Pipelines
Docker Build and Push
# docker-pipeline.yml
trigger:
branches:
include:
- main
variables:
dockerRegistryServiceConnection: 'ACR-Connection'
imageRepository: 'myapp'
containerRegistry: 'myregistry.azurecr.io'
dockerfilePath: '$(Build.SourcesDirectory)/Dockerfile'
tag: '$(Build.BuildId)'
stages:
- stage: Build
displayName: 'Build and Push Docker Image'
jobs:
- job: Docker
displayName: 'Build Docker Image'
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build Docker Image'
inputs:
command: 'build'
repository: $(imageRepository)
dockerfile: $(dockerfilePath)
containerRegistry: $(dockerRegistryServiceConnection)
tags: |
$(tag)
latest
arguments: '--build-arg BUILD_DATE=$(Build.BuildNumber)'
- task: Docker@2
displayName: 'Push to Container Registry'
inputs:
command: 'push'
repository: $(imageRepository)
containerRegistry: $(dockerRegistryServiceConnection)
tags: |
$(tag)
latest
- task: AzureCLI@2
displayName: 'Scan Image for Vulnerabilities'
inputs:
azureSubscription: 'Azure-Subscription'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az acr task run \
--registry $(containerRegistry) \
--name scan-image \
--set image=$(imageRepository):$(tag)
- stage: DeployAKS
displayName: 'Deploy to AKS'
dependsOn: Build
jobs:
- deployment: DeployAKS
displayName: 'Deploy to Kubernetes'
pool:
vmImage: 'ubuntu-latest'
environment: 'production-aks'
strategy:
runOnce:
deploy:
steps:
- task: KubernetesManifest@0
displayName: 'Create Image Pull Secret'
inputs:
action: 'createSecret'
kubernetesServiceConnection: 'AKS-Connection'
namespace: 'production'
secretType: 'dockerRegistry'
secretName: 'acr-secret'
dockerRegistryEndpoint: $(dockerRegistryServiceConnection)
- task: KubernetesManifest@0
displayName: 'Deploy to Kubernetes'
inputs:
action: 'deploy'
kubernetesServiceConnection: 'AKS-Connection'
namespace: 'production'
manifests: |
$(Pipeline.Workspace)/manifests/deployment.yml
$(Pipeline.Workspace)/manifests/service.yml
containers: |
$(containerRegistry)/$(imageRepository):$(tag)
Infrastructure as Code
Terraform Pipeline
# terraform-pipeline.yml
trigger:
branches:
include:
- main
paths:
include:
- terraform/*
variables:
- group: terraform-variables
- name: terraformVersion
value: '1.6.0'
stages:
- stage: Validate
displayName: 'Terraform Validate'
jobs:
- job: Validate
pool:
vmImage: 'ubuntu-latest'
steps:
- task: TerraformInstaller@0
displayName: 'Install Terraform'
inputs:
terraformVersion: $(terraformVersion)
- task: TerraformTaskV4@4
displayName: 'Terraform Init'
inputs:
provider: 'azurerm'
command: 'init'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
backendServiceArm: 'Azure-Subscription'
backendAzureRmResourceGroupName: 'rg-terraform-state'
backendAzureRmStorageAccountName: 'tfstate$(Environment)'
backendAzureRmContainerName: 'tfstate'
backendAzureRmKey: 'terraform.tfstate'
- task: TerraformTaskV4@4
displayName: 'Terraform Validate'
inputs:
provider: 'azurerm'
command: 'validate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
- task: TerraformTaskV4@4
displayName: 'Terraform Format Check'
inputs:
provider: 'azurerm'
command: 'custom'
customCommand: 'fmt'
commandOptions: '-check -recursive'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
- stage: Plan
displayName: 'Terraform Plan'
dependsOn: Validate
jobs:
- job: Plan
pool:
vmImage: 'ubuntu-latest'
steps:
- task: TerraformTaskV4@4
displayName: 'Terraform Plan'
inputs:
provider: 'azurerm'
command: 'plan'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
environmentServiceNameAzureRM: 'Azure-Subscription'
commandOptions: '-out=tfplan -var-file=environments/$(Environment).tfvars'
- task: PublishPipelineArtifact@1
displayName: 'Publish Plan'
inputs:
targetPath: '$(System.DefaultWorkingDirectory)/terraform/tfplan'
artifact: 'tfplan'
- stage: Apply
displayName: 'Terraform Apply'
dependsOn: Plan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: Apply
displayName: 'Apply Infrastructure Changes'
pool:
vmImage: 'ubuntu-latest'
environment: 'production-infrastructure'
strategy:
runOnce:
deploy:
steps:
- task: DownloadPipelineArtifact@2
displayName: 'Download Plan'
inputs:
artifact: 'tfplan'
path: '$(System.DefaultWorkingDirectory)/terraform'
- task: TerraformTaskV4@4
displayName: 'Terraform Apply'
inputs:
provider: 'azurerm'
command: 'apply'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform'
environmentServiceNameAzureRM: 'Azure-Subscription'
commandOptions: 'tfplan'
Advanced Deployment Strategies
Blue-Green Deployment
# blue-green-deployment.yml
stages:
- stage: DeployGreen
displayName: 'Deploy to Green Slot'
jobs:
- deployment: DeployGreen
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: AzureRmWebAppDeployment@4
displayName: 'Deploy to Green Slot'
inputs:
azureSubscription: 'Prod-Subscription'
WebAppName: 'myapp-prod'
deployToSlotOrASE: true
ResourceGroupName: 'rg-prod'
SlotName: 'green'
packageForLinux: '$(Pipeline.Workspace)/drop/**/*.zip'
- task: AzureAppServiceManage@0
displayName: 'Warm Up Green Slot'
inputs:
azureSubscription: 'Prod-Subscription'
Action: 'Start Azure App Service'
WebAppName: 'myapp-prod(green)'
- script: |
# Run smoke tests against green slot
curl -f https://myapp-prod-green.azurewebsites.net/health || exit 1
displayName: 'Health Check'
- task: AzureAppServiceManage@0
displayName: 'Swap Blue-Green'
inputs:
azureSubscription: 'Prod-Subscription'
Action: 'Swap Slots'
WebAppName: 'myapp-prod'
ResourceGroupName: 'rg-prod'
SourceSlot: 'green'
SwapWithProduction: true
Canary Deployment
# canary-deployment.yml
stages:
- stage: CanaryDeploy
displayName: 'Canary Deployment'
jobs:
- deployment: Canary
environment: 'production'
strategy:
canary:
increments: [10, 25, 50, 100]
preDeploy:
steps:
- script: echo "Pre-deployment validation"
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: 'Prod-Subscription'
appName: 'myapp-prod'
package: '$(Pipeline.Workspace)/drop/**/*.zip'
deploymentMethod: 'runFromPackage'
routeTraffic:
steps:
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'Prod-Subscription'
Action: 'Start Azure App Service'
WebAppName: 'myapp-prod'
SpecifySlotOrASE: true
ResourceGroupName: 'rg-prod'
Slot: 'canary'
TrafficPercentage: '$(strategy.increment)'
postRouteTraffic:
steps:
- script: |
# Monitor metrics for 5 minutes
sleep 300
displayName: 'Monitor Canary'
- task: AzureCLI@2
displayName: 'Check Error Rate'
inputs:
azureSubscription: 'Prod-Subscription'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
error_rate=$(az monitor metrics list \
--resource /subscriptions/.../myapp-prod \
--metric Http5xx \
--aggregation Average \
--interval PT5M \
--query 'value[0].timeseries[0].data[-1].average')
if (( $(echo "$error_rate > 0.01" | bc -l) )); then
echo "Error rate too high: $error_rate"
exit 1
fi
Key Takeaways
- Use YAML Pipelines: Version control your CI/CD configuration
- Template Reusability: Create reusable templates for common tasks
- Security Scanning: Integrate security checks early in the pipeline
- Environment Approvals: Require manual approval for production deployments
- Deployment Slots: Use slots for zero-downtime deployments
- Artifact Management: Properly version and store build artifacts
- Monitoring Integration: Track deployment metrics and health
Conclusion
Azure DevOps Pipelines provides enterprise-grade CI/CD capabilities. By following these best practices, you'll create reliable, secure, and efficient deployment pipelines that accelerate software delivery while maintaining quality and security standards.
Automating infrastructure? Check out my Terraform and Kubernetes posts for infrastructure as code best practices!