Skip to content
Back to Blog
2 min read

Building Multi-Stage YAML Pipelines in Azure DevOps

Classic CI/CD pipelines as a clickable thing in a UI: I keep migrating clients off them. Half the time the original person who built the pipeline has left, the build “just works,” and nobody knows how. YAML pipelines with their entire definition committed alongside the code solve that, and Azure DevOps multi-stage YAML has matured to the point where I’d recommend it over the Classic editor for any new pipeline. This is the skeleton I start with.

Why YAML Pipelines?

  • Version controlled - Pipeline definition lives with your code
  • Reviewable - Changes go through pull requests
  • Reusable - Create templates for common patterns
  • Portable - Easy to copy between projects

Basic Structure

# azure-pipelines.yml
trigger:
  branches:
    include:
      - main
      - develop

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '3.1.x'

stages:
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        steps:
          - task: UseDotNet@2
            displayName: 'Use .NET Core SDK'
            inputs:
              version: $(dotnetVersion)

          - task: DotNetCoreCLI@2
            displayName: 'Restore packages'
            inputs:
              command: 'restore'
              projects: '**/*.csproj'

          - task: DotNetCoreCLI@2
            displayName: 'Build solution'
            inputs:
              command: 'build'
              projects: '**/*.csproj'
              arguments: '--configuration $(buildConfiguration)'

          - task: DotNetCoreCLI@2
            displayName: 'Run tests'
            inputs:
              command: 'test'
              projects: '**/*Tests.csproj'
              arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage"'

          - task: PublishBuildArtifacts@1
            displayName: 'Publish artifacts'
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'drop'

Adding Deployment Stages

stages:
  - stage: Build
    # ... build jobs from above

  - stage: DeployDev
    displayName: 'Deploy to Development'
    dependsOn: Build
    condition: succeeded()
    jobs:
      - deployment: DeployWebApp
        displayName: 'Deploy Web App'
        environment: 'Development'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to Azure Web App'
                  inputs:
                    azureSubscription: 'Azure-Service-Connection'
                    appType: 'webApp'
                    appName: 'myapp-dev'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'

  - stage: DeployProd
    displayName: 'Deploy to Production'
    dependsOn: DeployDev
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployWebApp
        displayName: 'Deploy Web App'
        environment: 'Production'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to Azure Web App'
                  inputs:
                    azureSubscription: 'Azure-Service-Connection'
                    appType: 'webApp'
                    appName: 'myapp-prod'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'

Using Templates

Create reusable templates for common tasks:

# templates/dotnet-build.yml
parameters:
  - name: buildConfiguration
    default: 'Release'
  - name: projects
    default: '**/*.csproj'

steps:
  - task: DotNetCoreCLI@2
    displayName: 'Restore packages'
    inputs:
      command: 'restore'
      projects: ${{ parameters.projects }}

  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: 'build'
      projects: ${{ parameters.projects }}
      arguments: '--configuration ${{ parameters.buildConfiguration }}'

Use the template:

stages:
  - stage: Build
    jobs:
      - job: BuildJob
        steps:
          - template: templates/dotnet-build.yml
            parameters:
              buildConfiguration: 'Release'

Environment Approvals

Configure approvals in Azure DevOps:

  1. Go to Pipelines > Environments
  2. Select your environment (e.g., Production)
  3. Click the three dots > Approvals and checks
  4. Add approvers

Variable Groups

Store sensitive configuration securely:

variables:
  - group: 'Production-Secrets'
  - name: buildConfiguration
    value: 'Release'

Conditional Execution

- stage: DeployProd
  condition: |
    and(
      succeeded(),
      eq(variables['Build.SourceBranch'], 'refs/heads/main'),
      ne(variables['Build.Reason'], 'PullRequest')
    )

Running Jobs in Parallel

- stage: Test
  jobs:
    - job: UnitTests
      steps:
        - script: dotnet test --filter Category=Unit

    - job: IntegrationTests
      steps:
        - script: dotnet test --filter Category=Integration

    - job: E2ETests
      dependsOn: []  # Run in parallel
      steps:
        - script: npm run e2e

Multi-stage YAML pipelines are not the most exciting thing I work on, but they’re the thing that makes everything else possible. Worth the investment to get right early, because you only build the deployment pipeline once if you do it well — and three times if you don’t.\n\n## Takeaways\n\nAdd a concise, personal takeaway and recommended next steps here.\n

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.