Suyog Maid
Suyog Maid
📄
Article2026-01-16

Azure DevOps Pipelines: CI/CD Best Practices for Enterprise Deployments

#azure#devops#cicd#pipelines#yaml#deployment#automation

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

  1. Use YAML Pipelines: Version control your CI/CD configuration
  2. Template Reusability: Create reusable templates for common tasks
  3. Security Scanning: Integrate security checks early in the pipeline
  4. Environment Approvals: Require manual approval for production deployments
  5. Deployment Slots: Use slots for zero-downtime deployments
  6. Artifact Management: Properly version and store build artifacts
  7. 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!

Share this insight