Azure DevOps CI/CD Pipelines: A Complete Guide
Azure Pipelines automates the build, test, and deploy lifecycle for any language on any platform. This guide takes you from a simple YAML pipeline to production-grade multi-stage deployments with quality gates and rollback strategies.
Prerequisites
| Requirement | Details |
|---|---|
| Azure DevOps org | Free at dev.azure.com |
| Azure subscription | For deploying resources |
| Source repo | Azure Repos, GitHub, or Bitbucket |
| YAML basics | Understanding of YAML indentation and syntax |
Recommended tools: VS Code + Azure Pipelines extension, Git, Docker Desktop (for container workloads)
Pipeline Architecture
flowchart TD
subgraph Repo["Source Repository"]
CODE[Code Commit / PR]
TRIGGER[Branch Trigger / PR Policy]
end
subgraph CI["CI Stage — Build"]
RESTORE[Restore Dependencies]
BUILD[Compile / Build]
TEST[Unit & Integration Tests]
SCAN[Security & SAST Scan]
ARTIFACT[Publish Artifact]
end
subgraph CD_DEV["CD Stage — Dev"]
DEPLOY_DEV[Deploy to Dev\nAuto-approve]
SMOKE_DEV[Smoke Tests]
end
subgraph CD_TEST["CD Stage — Test"]
APPROVE_T[Manual Approval]
DEPLOY_TEST[Deploy to Test]
E2E[E2E / Load Tests]
end
subgraph CD_PROD["CD Stage — Production"]
APPROVE_P[Manual Approval\n+ Change Advisory]
DEPLOY_PROD[Blue-Green Deploy\nor Canary]
MONITOR[Monitor Metrics\n15 min window]
ROLLBACK[Auto-rollback\non failure]
end
CODE --> TRIGGER --> CI
CI --> CD_DEV --> CD_TEST --> CD_PROD
style CI fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
style CD_DEV fill:#d1fae5,stroke:#059669,color:#065f46
style CD_TEST fill:#fef3c7,stroke:#f59e0b,color:#78350f
style CD_PROD fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
Core Concepts
| Concept | Description |
|---|---|
| Stage | Logical boundary — Build, Test, Deploy |
| Job | Collection of steps running on one agent |
| Step | Individual task or script |
| Agent | Compute executing the job (Microsoft-hosted or self-hosted) |
| Artifact | Build output stored and promoted through stages |
| Environment | Deployment target with approval gates and history |
Basic Pipeline: Build and Test (.NET)
Create azure-pipelines.yml in your repository root:
trigger:
branches:
include:
- main
- develop
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
dotnetVersion: '8.x'
stages:
- stage: Build
displayName: 'Build & Test'
jobs:
- job: BuildJob
displayName: 'Build, Test, Publish'
steps:
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
version: $(dotnetVersion)
packageType: sdk
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
projects: '**/*.csproj'
- task: DotNetCoreCLI@2
displayName: 'Build'
inputs:
command: 'build'
projects: '**/*.csproj'
arguments: '--configuration $(buildConfiguration) --no-restore'
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: 'test'
projects: '**/*Tests.csproj'
arguments: >
--configuration $(buildConfiguration)
--collect:"XPlat Code Coverage"
--logger trx
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: succeededOrFailed()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
mergeTestResults: true
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage'
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
- task: DotNetCoreCLI@2
displayName: 'Publish artifact'
inputs:
command: 'publish'
publishWebProjects: true
arguments: >
--configuration $(buildConfiguration)
--output $(Build.ArtifactStagingDirectory)
zipAfterPublish: true
- task: PublishBuildArtifacts@1
displayName: 'Upload artifact'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'
Multi-Stage Pipeline: Dev → Test → Production
trigger:
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
variables:
buildConfiguration: 'Release'
azureSubscription: 'MyAzureSubscription'
webAppName: 'my-webapp-prod'
stages:
- stage: Build
displayName: 'Build'
jobs:
- job: BuildJob
steps:
- task: UseDotNet@2
inputs:
version: '8.x'
- script: |
dotnet restore
dotnet build --configuration $(buildConfiguration)
dotnet test --configuration $(buildConfiguration) --logger trx
displayName: 'Build and Test'
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
publishWebProjects: true
arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
- publish: $(Build.ArtifactStagingDirectory)
artifact: drop
- stage: DeployDev
displayName: 'Deploy — Dev'
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployWeb
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: $(azureSubscription)
appType: 'webApp'
appName: 'my-webapp-dev'
package: '$(Pipeline.Workspace)/drop/*.zip'
- stage: DeployProd
displayName: 'Deploy — Production'
dependsOn: DeployDev
condition: succeeded()
jobs:
- deployment: DeployWeb
environment: 'production' # ← Add manual approval gate here in Azure DevOps UI
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: $(azureSubscription)
appType: 'webApp'
appName: $(webAppName)
package: '$(Pipeline.Workspace)/drop/*.zip'
deploymentMethod: 'zipDeploy'
Advanced Deployment Strategies
Blue-Green Deployment
sequenceDiagram
participant Pipeline as CI/CD Pipeline
participant Staging as Staging Slot (Green)
participant Prod as Production Slot (Blue)
participant Traffic as Traffic Manager
Pipeline->>Staging: Deploy new version
Pipeline->>Staging: Run smoke tests
alt Tests pass
Pipeline->>Traffic: Swap slots (instant cutover)
Traffic-->>Prod: Now serving new version
Pipeline->>Staging: Keep old version for rollback
else Tests fail
Pipeline->>Staging: Discard — no swap
Prod-->>Traffic: Continues serving old version
end
- stage: DeployProduction
jobs:
- deployment: BlueGreenDeploy
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
displayName: 'Deploy to staging slot'
inputs:
azureSubscription: $(azureSubscription)
appName: $(webAppName)
slotName: 'staging'
package: '$(Pipeline.Workspace)/drop/*.zip'
- task: PowerShell@2
displayName: 'Smoke test staging'
inputs:
targetType: inline
script: |
$response = Invoke-WebRequest `
-Uri "https://$(webAppName)-staging.azurewebsites.net/health" `
-UseBasicParsing
if ($response.StatusCode -ne 200) { throw "Health check failed" }
- task: AzureAppServiceManage@0
displayName: 'Swap slots (blue-green)'
inputs:
azureSubscription: $(azureSubscription)
action: 'Swap Slots'
webAppName: $(webAppName)
resourceGroupName: 'my-resource-group'
sourceSlot: 'staging'
Canary Deployment
- stage: CanaryDeploy
jobs:
- deployment: CanaryRelease
environment: 'production'
strategy:
canary:
increments: [10, 25, 50, 100]
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: $(azureSubscription)
appName: $(webAppName)
package: '$(Pipeline.Workspace)/drop/*.zip'
- task: AzureCLI@2
displayName: 'Set traffic split to $(strategy.increment)%'
inputs:
azureSubscription: $(azureSubscription)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp traffic-routing set \
--resource-group my-rg \
--name $(webAppName) \
--distribution staging=$(strategy.increment)
postRouteTraffic:
steps:
- script: sleep 300
displayName: 'Monitor $(strategy.increment)% window'
on:
failure:
steps:
- task: AzureCLI@2
displayName: 'Rollback — clear traffic routing'
inputs:
azureSubscription: $(azureSubscription)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp traffic-routing clear \
--resource-group my-rg \
--name $(webAppName)
Testing Integration
Test and Coverage Quality Gate
- stage: Test
jobs:
- job: TestJob
steps:
- task: DotNetCoreCLI@2
displayName: 'Unit tests'
inputs:
command: 'test'
projects: '**/*UnitTests.csproj'
arguments: >
--configuration $(buildConfiguration)
--collect:"XPlat Code Coverage"
--logger trx
- task: DotNetCoreCLI@2
displayName: 'Integration tests'
inputs:
command: 'test'
projects: '**/*IntegrationTests.csproj'
arguments: '--configuration $(buildConfiguration) --logger trx'
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFormat: 'VSTest'
testResultsFiles: '**/*.trx'
mergeTestResults: true
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: 'Cobertura'
summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
Security Scanning
- job: SecurityScan
displayName: 'SAST + Dependency Scan'
steps:
- task: DotNetCoreCLI@2
inputs:
command: 'restore'
- script: |
dotnet list package --vulnerable --include-transitive 2>&1 \
| tee vulnerability-report.txt
if grep -q "has the following vulnerable packages" vulnerability-report.txt; then
echo "##vso[task.logissue type=error]Vulnerable packages found"
exit 1
fi
displayName: 'Check for vulnerable dependencies'
- task: SonarQubePrepare@5
inputs:
SonarQube: 'SonarQube'
scannerMode: 'MSBuild'
projectKey: 'my-project'
extraProperties: |
sonar.cs.opencover.reportsPaths=$(Agent.TempDirectory)/**/coverage.opencover.xml
- task: DotNetCoreCLI@2
inputs:
command: 'build'
- task: SonarQubeAnalyze@5
- task: SonarQubePublish@5
inputs:
pollingTimeoutSec: '300'
Deployment Strategy Comparison
flowchart LR
subgraph Rolling["Rolling Update"]
R1[Instance 1\nOld] --> R2[Instance 1\nNew]
R3[Instance 2\nOld] --> R4[Instance 2\nNew]
end
subgraph BlueGreen["Blue-Green"]
BG1[Blue\nCurrent]
BG2[Green\nNew Version]
SW[Instant\nSlot Swap]
BG2 --> SW --> BG1
end
subgraph Canary["Canary"]
C1[100% Old]
C2[10% New\n90% Old]
C3[50% New\n50% Old]
C4[100% New]
C1 --> C2 --> C3 --> C4
end
style Rolling fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
style BlueGreen fill:#d1fae5,stroke:#059669,color:#065f46
style Canary fill:#fef3c7,stroke:#f59e0b,color:#78350f
| Strategy | Downtime | Rollback Speed | Risk | Best For |
|---|---|---|---|---|
| Rolling | Zero (if configured) | Slow | Medium | Stateless apps |
| Blue-Green | Zero | Instant (swap) | Low | Web apps with slot support |
| Canary | Zero | Automatic | Lowest | High-traffic production services |
| Recreate | Yes | N/A | High | Dev/test environments only |
Enterprise Controls
| Concern | Pattern | Implementation |
|---|---|---|
| Secrets | Key Vault references | AzureKeyVault@2 task — inject at runtime |
| Supply chain | SBOM generation | CycloneDX task in build stage |
| Quality gates | Coverage thresholds | Block merge if coverage < 80% |
| Governance | Mandatory approvals | Environment approval on production environment |
| Rollback | Versioned immutable artifacts | Retain last 5 successful drops |
| Observability | Release annotations | Tag releases in Application Insights |
| Multi-region | Parallel deploy jobs | Sequential or parallel with traffic manager |
Pipeline Variables and Secrets
variables:
# Plain variables
buildConfiguration: 'Release'
dotnetVersion: '8.x'
# Reference a variable group (linked to Key Vault)
- group: 'production-secrets'
# Runtime-computed variable
- name: imageTag
value: $[format('{0}.{1}', variables['Build.BuildId'], variables['Build.SourceVersion'])]
# Secure file from Library
- task: DownloadSecureFile@1
name: appSettings
inputs:
secureFile: 'appsettings.production.json'
Best Practices
Pin task versions. Use
AzureWebApp@1notAzureWebApp@latest— breaking changes in task updates can silently break pipelines.
Keep pipelines DRY with templates. Extract common job steps into
templates/YAML files and reference them across repos.
Never store secrets in YAML. Use variable groups linked to Azure Key Vault. The pipeline never sees plaintext — only resolved values at runtime.
Add approval gates to production. An environment with a manual approval prevents accidental production deployments from automated merges.
Fail fast on tests. Run unit tests before integration tests, and security scans before deployment — expensive stages should never run on a broken build.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Stage skipped unexpectedly | condition expression evaluates false |
Add condition: succeededOrFailed() or check prior stage names |
| Variables not resolving | Wrong variable group scope | Verify group linked to pipeline in Library |
| Deployment hangs | Agent waiting for approval | Check Environment → Approvals and checks in Azure DevOps |
Artifact not found |
Wrong artifact name | Match publish artifact name to download step |
| 403 on Azure deployment | Service connection lacks permission | Assign Contributor role on target resource group to the service principal |
Key Takeaways
- ✅ Multi-stage YAML pipelines enforce quality gates automatically at every promotion
- ✅ Blue-green and canary strategies eliminate deployment downtime and reduce blast radius
- ✅ Environment approval gates provide governance without slowing down development
- ✅ Variable groups + Key Vault references keep secrets out of pipeline YAML entirely
- ✅ Security scanning in CI prevents vulnerable dependencies from reaching production
Additional Resources
- Azure Pipelines documentation
- YAML schema reference
- Deployment strategies
- Environment approvals and checks
- Azure Pipelines samples (GitHub)
What deployment strategy are you using in production? Any tips on managing multi-environment pipelines at scale?
Discussion