Home / Azure / Azure DevOps CI/CD Pipelines: A Complete Guide
Azure

Azure DevOps CI/CD Pipelines: A Complete Guide

Master Azure DevOps YAML pipelines end to end: multi-stage CI/CD, blue-green and canary deployments, security scanning, quality gates, and production-grade automation.

What you will learn

Practical execution with concise explanations, real implementation patterns, and production-ready recommendations.

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

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)

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

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@1 not AzureWebApp@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


What deployment strategy are you using in production? Any tips on managing multi-environment pipelines at scale?

Discussion