Pipelines

Building Reusable YAML Pipeline Templates in Azure DevOps

Comprehensive guide to building complex CI/CD pipelines using YAML templates in Azure DevOps, covering all template types, parameterization, conditional logic, and cross-repository references.

Building Reusable YAML Pipeline Templates in Azure DevOps

Overview

YAML pipeline templates in Azure DevOps let you define reusable pipeline logic once and reference it across dozens or hundreds of pipelines, eliminating the copy-paste sprawl that plagues most CI/CD setups. If you have ever fixed the same build step in fifteen different pipeline files, you already understand the problem templates solve. This article walks through every template type, shows real parameterization patterns, and ends with a production-grade multi-stage pipeline you can adapt immediately.

Prerequisites

  • An Azure DevOps organization and project with Repos and Pipelines enabled
  • Basic familiarity with YAML pipeline syntax (triggers, stages, jobs, steps)
  • A working understanding of Git branching (you will reference templates across repositories)
  • Azure DevOps agent pools configured (Microsoft-hosted or self-hosted)

Why Templates Matter

Most teams start with a single azure-pipelines.yml file in each repository. That works fine for one or two services. By the time you hit ten microservices, you are maintaining ten nearly identical pipeline files. When your security team mandates a new scanning step, you open ten pull requests. When someone forgets to update one of them, you discover the gap three weeks later in production.

Templates solve this by extracting common logic into shared, versioned files. The benefits are concrete:

  • Consistency: Every service builds, tests, and deploys the same way. Drift becomes structurally impossible.
  • Maintainability: Fix a bug in one template, and every consuming pipeline picks it up on the next run.
  • Governance: Security and compliance teams can own templates that enforce scanning, signing, and approval gates. Development teams cannot skip steps they do not control.
  • Velocity: New services get a full CI/CD pipeline by referencing three or four templates instead of writing 200 lines of YAML from scratch.

The tradeoff is indirection. Debugging a pipeline that chains four levels of templates requires understanding the resolution order. That is a real cost, and I will address how to manage it. But for any organization running more than a handful of services, templates are not optional. They are infrastructure.


Template Types

Azure DevOps supports four template types. Each operates at a different scope, and understanding the differences is critical.

Step Templates

Step templates encapsulate one or more steps. They are the most granular template type and the one you will use most frequently.

# templates/steps/dotnet-build.yml
parameters:
  - name: projectPath
    type: string
  - name: configuration
    type: string
    default: 'Release'
  - name: verbosity
    type: string
    default: 'minimal'
    values:
      - quiet
      - minimal
      - normal
      - detailed
      - diagnostic

steps:
  - task: DotNetCoreCLI@2
    displayName: 'Restore NuGet packages'
    inputs:
      command: 'restore'
      projects: '${{ parameters.projectPath }}'
      feedsToUse: 'config'
      nugetConfigPath: 'nuget.config'

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

  - task: DotNetCoreCLI@2
    displayName: 'Publish artifacts'
    inputs:
      command: 'publish'
      projects: '${{ parameters.projectPath }}'
      arguments: '--configuration ${{ parameters.configuration }} --no-build --output $(Build.ArtifactStagingDirectory)'
      publishWebProjects: false
      zipAfterPublish: true

Job Templates

Job templates wrap an entire job, including the pool definition, variables, and steps. Use these when you need to control the agent pool or job-level settings alongside the steps.

# templates/jobs/run-integration-tests.yml
parameters:
  - name: testProjects
    type: string
  - name: environment
    type: string
    default: 'dev'
  - name: serviceConnection
    type: string
  - name: dependsOn
    type: object
    default: []

jobs:
  - job: IntegrationTests_${{ parameters.environment }}
    displayName: 'Integration Tests (${{ parameters.environment }})'
    dependsOn: ${{ parameters.dependsOn }}
    pool:
      vmImage: 'ubuntu-latest'
    variables:
      - name: ASPNETCORE_ENVIRONMENT
        value: ${{ parameters.environment }}
    steps:
      - task: AzureCLI@2
        displayName: 'Fetch test configuration'
        inputs:
          azureSubscription: '${{ parameters.serviceConnection }}'
          scriptType: 'bash'
          scriptLocation: 'inlineScript'
          inlineScript: |
            az appconfig kv list \
              --name appconfig-${{ parameters.environment }} \
              --label test \
              --output json > $(Build.SourcesDirectory)/test-config.json

      - task: DotNetCoreCLI@2
        displayName: 'Run integration tests'
        inputs:
          command: 'test'
          projects: '${{ parameters.testProjects }}'
          arguments: '--configuration Release --logger trx --results-directory $(Common.TestResultsDirectory)'
        env:
          TEST_CONFIG_PATH: '$(Build.SourcesDirectory)/test-config.json'

      - task: PublishTestResults@2
        displayName: 'Publish test results'
        condition: always()
        inputs:
          testResultsFormat: 'VSTest'
          testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
          mergeTestResults: true
          testRunTitle: 'Integration Tests - ${{ parameters.environment }}'

Stage Templates

Stage templates define entire stages. They are the right choice for encapsulating deployment workflows that include approvals, environment targeting, and multi-job orchestration.

# templates/stages/deploy-app-service.yml
parameters:
  - name: environment
    type: string
  - name: azureSubscription
    type: string
  - name: appServiceName
    type: string
  - name: resourceGroup
    type: string
  - name: dependsOn
    type: object
    default: []
  - name: slot
    type: string
    default: 'staging'
  - name: runHealthCheck
    type: boolean
    default: true

stages:
  - stage: Deploy_${{ parameters.environment }}
    displayName: 'Deploy to ${{ parameters.environment }}'
    dependsOn: ${{ parameters.dependsOn }}
    variables:
      - group: '${{ parameters.environment }}-variables'
    jobs:
      - deployment: DeployWebApp
        displayName: 'Deploy to App Service'
        pool:
          vmImage: 'ubuntu-latest'
        environment: '${{ parameters.environment }}'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: drop

                - task: AzureWebApp@1
                  displayName: 'Deploy to ${{ parameters.slot }} slot'
                  inputs:
                    azureSubscription: '${{ parameters.azureSubscription }}'
                    appType: 'webAppLinux'
                    appName: '${{ parameters.appServiceName }}'
                    deployToSlotOrASE: true
                    resourceGroupName: '${{ parameters.resourceGroup }}'
                    slotName: '${{ parameters.slot }}'
                    package: '$(Pipeline.Workspace)/drop/**/*.zip'

                - ${{ if eq(parameters.runHealthCheck, true) }}:
                  - task: AzureCLI@2
                    displayName: 'Health check on ${{ parameters.slot }}'
                    inputs:
                      azureSubscription: '${{ parameters.azureSubscription }}'
                      scriptType: 'bash'
                      scriptLocation: 'inlineScript'
                      inlineScript: |
                        SLOT_URL="https://${{ parameters.appServiceName }}-${{ parameters.slot }}.azurewebsites.net/health"
                        RETRY_COUNT=0
                        MAX_RETRIES=12

                        while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
                          STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SLOT_URL" || echo "000")
                          if [ "$STATUS" = "200" ]; then
                            echo "Health check passed."
                            exit 0
                          fi
                          RETRY_COUNT=$((RETRY_COUNT + 1))
                          echo "Attempt $RETRY_COUNT/$MAX_RETRIES: Status $STATUS. Retrying in 10 seconds..."
                          sleep 10
                        done
                        echo "Health check failed after $MAX_RETRIES attempts."
                        exit 1

                - task: AzureAppServiceManage@0
                  displayName: 'Swap ${{ parameters.slot }} to production'
                  inputs:
                    azureSubscription: '${{ parameters.azureSubscription }}'
                    action: 'Swap Slots'
                    webAppName: '${{ parameters.appServiceName }}'
                    resourceGroupName: '${{ parameters.resourceGroup }}'
                    sourceSlot: '${{ parameters.slot }}'

Variable Templates

Variable templates define reusable variable sets. They are straightforward but surprisingly useful for managing environment-specific configuration.

# templates/variables/common.yml
variables:
  dotnetVersion: '8.0.x'
  buildConfiguration: 'Release'
  artifactName: 'drop'
  sonarQubeServiceConnection: 'SonarQube-Production'

Creating Your First Template

The simplest way to start is to extract steps from an existing pipeline. Take a working pipeline, identify the repeated blocks, and pull them into separate files.

A practical directory structure looks like this:

repo-root/
├── pipelines/
│   ├── azure-pipelines.yml
│   └── templates/
│       ├── steps/
│       │   ├── dotnet-build.yml
│       │   ├── docker-build-push.yml
│       │   └── run-tests.yml
│       ├── jobs/
│       │   └── run-integration-tests.yml
│       ├── stages/
│       │   └── deploy-app-service.yml
│       └── variables/
│           ├── common.yml
│           ├── environment-dev.yml
│           └── environment-prod.yml

Reference a step template from your main pipeline:

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

pool:
  vmImage: 'ubuntu-latest'

variables:
  - template: templates/variables/common.yml

steps:
  - task: UseDotNet@2
    displayName: 'Install .NET SDK'
    inputs:
      packageType: 'sdk'
      version: '$(dotnetVersion)'

  - template: templates/steps/dotnet-build.yml
    parameters:
      projectPath: 'src/MyApi/MyApi.csproj'
      configuration: '$(buildConfiguration)'

That is it. The template file is resolved at pipeline compile time, and the steps are inlined as if you had written them directly.


Parameterizing Templates

Parameters are where templates become genuinely powerful. Azure DevOps supports typed parameters with defaults, allowed values, and even complex object types.

# templates/steps/docker-build-push.yml
parameters:
  - name: dockerfilePath
    type: string
    default: 'Dockerfile'
  - name: buildContext
    type: string
    default: '.'
  - name: repository
    type: string
  - name: registry
    type: string
  - name: tags
    type: object
    default:
      - '$(Build.BuildId)'
      - 'latest'
  - name: pushImage
    type: boolean
    default: true

steps:
  - task: Docker@2
    displayName: 'Build Docker image'
    inputs:
      containerRegistry: '${{ parameters.registry }}'
      repository: '${{ parameters.repository }}'
      command: 'build'
      Dockerfile: '${{ parameters.dockerfilePath }}'
      buildContext: '${{ parameters.buildContext }}'
      tags: |
        ${{ each tag in parameters.tags }}:
        ${{ tag }}

  - ${{ if eq(parameters.pushImage, true) }}:
    - task: Docker@2
      displayName: 'Push Docker image'
      inputs:
        containerRegistry: '${{ parameters.registry }}'
        repository: '${{ parameters.repository }}'
        command: 'push'
        tags: |
          ${{ each tag in parameters.tags }}:
          ${{ tag }}

Conditional Logic in Templates

Template expressions use the ${{ }} syntax and are evaluated at compile time, before the pipeline runs. This is fundamentally different from runtime expressions ($[ ]) and macro syntax ($()).

# templates/steps/security-scan.yml
parameters:
  - name: scanType
    type: string
    default: 'standard'
    values:
      - standard
      - thorough
      - quick
  - name: failOnHighSeverity
    type: boolean
    default: true

steps:
  - ${{ if eq(parameters.scanType, 'thorough') }}:
    - task: CredScan@3
      displayName: 'Credential scanning (thorough)'
      inputs:
        toolVersion: 'Latest'
        scanFolder: '$(Build.SourcesDirectory)'

  - ${{ if ne(parameters.scanType, 'quick') }}:
    - task: SdtReport@2
      displayName: 'Security analysis report'
      inputs:
        AllTools: false
        CredScan: ${{ eq(parameters.scanType, 'thorough') }}
        PoliCheck: true

The ${{ if }} blocks are resolved before the pipeline agent ever sees the YAML. Steps inside a false condition do not exist in the compiled pipeline.


Template Expressions

Expressions let you compute values, iterate over collections, and make compile-time decisions.

# templates/stages/deploy-multi-region.yml
parameters:
  - name: regions
    type: object
    default:
      - name: 'eastus'
        displayName: 'East US'
        isPrimary: true
      - name: 'westeurope'
        displayName: 'West Europe'
        isPrimary: false

stages:
  - ${{ each region in parameters.regions }}:
    - stage: Deploy_${{ region.name }}
      displayName: 'Deploy to ${{ region.displayName }}'
      ${{ if eq(region.isPrimary, true) }}:
        dependsOn: Build
      ${{ else }}:
        dependsOn: Deploy_${{ parameters.regions[0].name }}
      jobs:
        - deployment: Deploy
          pool:
            vmImage: 'ubuntu-latest'
          environment: 'production-${{ region.name }}'
          strategy:
            runOnce:
              deploy:
                steps:
                  - download: current
                    artifact: drop
                  - task: AzureWebApp@1
                    inputs:
                      azureSubscription: '${{ parameters.azureSubscription }}'
                      appName: '${{ parameters.appNamePrefix }}-${{ region.name }}'
                      package: '$(Pipeline.Workspace)/drop/**/*.zip'

The ${{ each }} expression generates a stage per region at compile time. You define the structure once, and the regions list drives how many stages get created.


Extending from Templates

The extends keyword lets you define a base template that consuming pipelines must use. This is the governance mechanism.

# templates/pipeline-base.yml
parameters:
  - name: buildSteps
    type: stepList
    default: []
  - name: testSteps
    type: stepList
    default: []

stages:
  - stage: Build
    displayName: 'Build & Unit Test'
    pool:
      vmImage: 'ubuntu-latest'
    jobs:
      - job: Build
        steps:
          - checkout: self
            fetchDepth: 0
          - ${{ each step in parameters.buildSteps }}:
            - ${{ step }}
          - ${{ each step in parameters.testSteps }}:
            - ${{ step }}
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: 'drop'

  # Security scanning is mandatory and cannot be skipped
  - stage: SecurityScan
    displayName: 'Security Scanning'
    dependsOn: Build
    jobs:
      - job: Scan
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - template: steps/security-scan.yml
            parameters:
              scanType: 'thorough'
              failOnHighSeverity: true

A consuming pipeline then extends this base:

trigger:
  branches:
    include:
      - main

extends:
  template: templates/pipeline-base.yml
  parameters:
    buildSteps:
      - task: DotNetCoreCLI@2
        displayName: 'Build solution'
        inputs:
          command: 'build'
          projects: '**/*.sln'
          arguments: '--configuration Release'
    testSteps:
      - task: DotNetCoreCLI@2
        displayName: 'Run unit tests'
        inputs:
          command: 'test'
          projects: 'tests/**/*.csproj'

The security scan stage is baked into the base template. Development teams cannot remove it.


Template Repository References

In real organizations, templates live in a dedicated repository. Azure DevOps supports this through repository resources.

resources:
  repositories:
    - repository: templates
      type: git
      name: MyProject/pipeline-templates
      ref: refs/tags/v2.3.1

trigger:
  branches:
    include:
      - main

extends:
  template: pipeline-base.yml@templates
  parameters:
    buildSteps:
      - template: steps/dotnet-build.yml@templates
        parameters:
          projectPath: 'src/MyApi/MyApi.csproj'

Use tags for template references, not branches. This gives consuming teams control over when they adopt template changes.


Complete Working Example

Here is a production-grade multi-stage pipeline for a .NET API deployed to Azure App Service across dev, staging, and production environments.

trigger:
  branches:
    include:
      - main
      - release/*
  paths:
    exclude:
      - docs/**
      - '*.md'

pr:
  branches:
    include:
      - main

resources:
  repositories:
    - repository: templates
      type: git
      name: Platform/pipeline-templates
      ref: refs/tags/v3.0.0

variables:
  - template: variables/common.yml@templates
  - name: apiProject
    value: 'src/MyApi/MyApi.csproj'
  - name: testProjects
    value: 'tests/**/*.csproj'

stages:
  - stage: Build
    displayName: 'Build & Test'
    jobs:
      - job: BuildJob
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - checkout: self
            fetchDepth: 0

          - task: UseDotNet@2
            displayName: 'Install .NET SDK'
            inputs:
              packageType: 'sdk'
              version: '$(dotnetVersion)'

          - template: steps/dotnet-build.yml@templates
            parameters:
              projectPath: '$(apiProject)'
              configuration: '$(buildConfiguration)'

          - task: DotNetCoreCLI@2
            displayName: 'Run unit tests'
            inputs:
              command: 'test'
              projects: '$(testProjects)'
              arguments: '--configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage" --logger trx --results-directory $(Common.TestResultsDirectory)'

          - task: PublishTestResults@2
            condition: always()
            inputs:
              testResultsFormat: 'VSTest'
              testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'

          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: '$(artifactName)'

  - template: stages/deploy-app-service.yml@templates
    parameters:
      environment: 'dev'
      azureSubscription: 'Azure-Dev-ServiceConnection'
      appServiceName: 'app-myapi-dev'
      resourceGroup: 'rg-myapi-dev'
      dependsOn:
        - Build

  - stage: IntegrationTests
    displayName: 'Integration Tests'
    dependsOn: Deploy_dev
    jobs:
      - template: jobs/run-integration-tests.yml@templates
        parameters:
          testProjects: 'tests/MyApi.IntegrationTests/*.csproj'
          environment: 'dev'
          serviceConnection: 'Azure-Dev-ServiceConnection'

  - template: stages/deploy-app-service.yml@templates
    parameters:
      environment: 'staging'
      azureSubscription: 'Azure-Staging-ServiceConnection'
      appServiceName: 'app-myapi-staging'
      resourceGroup: 'rg-myapi-staging'
      dependsOn:
        - IntegrationTests

  - template: stages/deploy-app-service.yml@templates
    parameters:
      environment: 'production'
      azureSubscription: 'Azure-Prod-ServiceConnection'
      appServiceName: 'app-myapi-prod'
      resourceGroup: 'rg-myapi-prod'
      dependsOn:
        - Deploy_staging

This pipeline builds once, runs unit tests, deploys to dev with a health check, runs integration tests, then promotes through staging and production. The deployment logic is identical across all three environments because it comes from the same template.


Common Issues and Troubleshooting

1. "Template reference is not found"

This usually means the path is wrong relative to the repository root, or you forgot the @repoAlias suffix when referencing a template from another repository.

Fix: Use repository-root-relative paths. If your template is at pipelines/templates/steps/build.yml in the templates repo, reference it as pipelines/templates/steps/build.yml@templates.

2. "Unexpected value 'object'" or parameter type errors

Object parameters must be passed as YAML structures, not strings. You cannot pass $(variable) as a template parameter and expect it to resolve before the template is compiled.

Fix: Use ${{ variables.myVar }} for compile-time variable access, or restructure so the value is a literal.

3. Conditional steps produce invalid YAML

The ${{ if }} syntax is whitespace-sensitive. If your conditional block introduces indentation errors, the pipeline will fail to compile with cryptic messages.

# WRONG - indentation mismatch
steps:
  - ${{ if eq(parameters.runScan, true) }}:
      - script: echo "scanning"

# CORRECT
steps:
  - ${{ if eq(parameters.runScan, true) }}:
    - script: echo "scanning"

4. Template changes not picked up

When referencing templates from another repository using a branch ref, Azure DevOps caches the template content.

Fix: Use tags for production template references. For development, use ref: refs/heads/feature-branch to test changes.


Best Practices

  • Version your templates with Git tags. Treat your template repository like a library with semantic versioning. Consuming teams pin to a specific tag and upgrade on their own schedule.
  • Keep templates focused and single-purpose. A step template should do one thing: build, test, scan, or deploy.
  • Use extends for organizational standards. If every pipeline must run security scanning, put those steps in a base template and require all pipelines to extend it.
  • Document parameters with comments. Add comments above each parameter explaining what it controls and what the default does.
  • Test templates in isolation. Create a test pipeline in your template repository that exercises each template with various parameter combinations.
  • Avoid deeply nested template hierarchies. Three levels is the practical maximum for maintainability.
  • Prefer compile-time expressions over runtime conditions when possible. Steps excluded by ${{ if }} do not appear in the compiled pipeline at all.
  • Pin agent image versions for production pipelines. Use vmImage: 'ubuntu-22.04' instead of vmImage: 'ubuntu-latest'.

References

Powered by Contentful