DevOpsil

Azure DevOps Pipelines: YAML Templates and Best Practices

Sarah ChenSarah Chen6 min read

Azure DevOps Pipelines: YAML Templates and Best Practices

Classic GUI pipelines in Azure DevOps were fine for simple builds. But the moment your team grows, or you need to enforce consistency across a dozen repos, you hit the ceiling fast. YAML pipelines with reusable templates are how mature teams do it — your pipeline is code, reviewed like code, versioned like code.

This guide walks through a production-grade YAML pipeline setup that your whole organization can share.

The Problem with Copy-Paste Pipelines

Most teams start by copying azure-pipelines.yml from one repo to another. Three months later you have 40 slightly-different pipelines, some of them broken, none of them consistent. When you need to update a shared step — rotating a service connection, changing a security scanner — you're editing 40 files.

Templates solve this. One central repo holds the reusable steps; every project repo just references them.

Repository Structure

pipelines-library/           # Central template repo
├── templates/
│   ├── steps/
│   │   ├── build-docker.yml
│   │   ├── run-tests.yml
│   │   └── trivy-scan.yml
│   ├── jobs/
│   │   ├── build-job.yml
│   │   └── deploy-job.yml
│   └── stages/
│       ├── ci-stage.yml
│       └── cd-stage.yml
└── azure-pipelines.yml      # Example pipeline for the library itself

your-app/                    # Your application repo
└── azure-pipelines.yml      # References the central library

Step 1: Define a Reusable Step Template

# pipelines-library/templates/steps/build-docker.yml
parameters:
  - name: imageName
    type: string
  - name: dockerfilePath
    type: string
    default: Dockerfile
  - name: buildContext
    type: string
    default: .
  - name: pushToRegistry
    type: boolean
    default: true

steps:
  - task: Docker@2
    displayName: Build Docker image
    inputs:
      command: build
      repository: ${{ parameters.imageName }}
      dockerfile: ${{ parameters.dockerfilePath }}
      buildContext: ${{ parameters.buildContext }}
      tags: |
        $(Build.BuildId)
        latest

  - ${{ if eq(parameters.pushToRegistry, true) }}:
    - task: Docker@2
      displayName: Push to registry
      inputs:
        command: push
        repository: ${{ parameters.imageName }}
        tags: |
          $(Build.BuildId)
          latest
# pipelines-library/templates/steps/trivy-scan.yml
parameters:
  - name: imageName
    type: string
  - name: severity
    type: string
    default: HIGH,CRITICAL
  - name: exitCode
    type: number
    default: 1

steps:
  - script: |
      docker run --rm \
        -v /var/run/docker.sock:/var/run/docker.sock \
        aquasec/trivy:latest image \
        --exit-code ${{ parameters.exitCode }} \
        --severity ${{ parameters.severity }} \
        --no-progress \
        ${{ parameters.imageName }}:$(Build.BuildId)
    displayName: Trivy vulnerability scan
    continueOnError: false

Step 2: Build a Reusable Job Template

# pipelines-library/templates/jobs/build-job.yml
parameters:
  - name: imageName
    type: string
  - name: dockerfilePath
    type: string
    default: Dockerfile
  - name: runTests
    type: boolean
    default: true
  - name: testCommand
    type: string
    default: npm test
  - name: nodeVersion
    type: string
    default: '20.x'

jobs:
  - job: Build
    displayName: Build and Test
    pool:
      vmImage: ubuntu-latest
    steps:
      - ${{ if eq(parameters.runTests, true) }}:
        - task: NodeTool@0
          inputs:
            versionSpec: ${{ parameters.nodeVersion }}
          displayName: Install Node.js

        - script: npm ci
          displayName: Install dependencies

        - script: ${{ parameters.testCommand }}
          displayName: Run tests

      - template: ../steps/build-docker.yml
        parameters:
          imageName: ${{ parameters.imageName }}
          dockerfilePath: ${{ parameters.dockerfilePath }}

      - template: ../steps/trivy-scan.yml
        parameters:
          imageName: ${{ parameters.imageName }}

Step 3: Multi-Stage Pipeline in Your App Repo

# your-app/azure-pipelines.yml
trigger:
  branches:
    include:
      - main
      - release/*
  paths:
    exclude:
      - docs/**
      - '*.md'

pr:
  branches:
    include:
      - main

resources:
  repositories:
    - repository: pipelineTemplates
      type: git
      name: YourOrg/pipelines-library
      ref: refs/heads/main

variables:
  - group: prod-secrets           # Variable group from Azure DevOps library
  - name: imageName
    value: yourregistry.azurecr.io/your-app
  - name: containerRegistry
    value: your-acr-service-connection

stages:
  - stage: CI
    displayName: Build and Test
    jobs:
      - template: templates/jobs/build-job.yml@pipelineTemplates
        parameters:
          imageName: $(imageName)
          runTests: true
          testCommand: npm run test:ci

  - stage: DeployStaging
    displayName: Deploy to Staging
    dependsOn: CI
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: DeployStaging
        displayName: Deploy to staging environment
        environment: staging
        pool:
          vmImage: ubuntu-latest
        strategy:
          runOnce:
            deploy:
              steps:
                - template: templates/steps/deploy-aks.yml@pipelineTemplates
                  parameters:
                    environment: staging
                    imageName: $(imageName)
                    imageTag: $(Build.BuildId)

  - stage: DeployProduction
    displayName: Deploy to Production
    dependsOn: DeployStaging
    condition: succeeded()
    jobs:
      - deployment: DeployProduction
        displayName: Deploy to production environment
        environment: production        # Has approval gate configured
        pool:
          vmImage: ubuntu-latest
        strategy:
          runOnce:
            deploy:
              steps:
                - template: templates/steps/deploy-aks.yml@pipelineTemplates
                  parameters:
                    environment: production
                    imageName: $(imageName)
                    imageTag: $(Build.BuildId)

Step 4: Environment Approval Gates

In Azure DevOps, go to Pipelines > Environments > production and add an approval. This blocks the DeployProduction stage until a designated approver manually confirms.

You can also add automated checks:

# In the environment settings via API or UI:
# - Required reviewers (specific users or groups)
# - Branch control (only allow deploys from main or release/*)
# - Business hours check (no deploys on weekends)
# - Azure Monitor alert check (block if active incidents)

Step 5: Variable Groups and Secrets

Never hardcode secrets in YAML. Use variable groups linked to Azure Key Vault:

# Link a variable group to Key Vault via CLI
az pipelines variable-group create \
  --name prod-secrets \
  --authorize true \
  --variables placeholder=placeholder \
  --org https://dev.azure.com/YourOrg \
  --project YourProject

# Then in Azure DevOps UI, link the group to your Key Vault
# Pipeline Library > Variable Groups > Link secrets from Azure Key Vault

Reference in pipelines:

variables:
  - group: prod-secrets   # All Key Vault secrets become pipeline variables

steps:
  - script: echo "Deploying with ACR password from KV"
    env:
      ACR_PASSWORD: $(acr-admin-password)   # Key Vault secret name

Template Versioning

Pin template references to a specific tag, not main, in production pipelines:

resources:
  repositories:
    - repository: pipelineTemplates
      type: git
      name: YourOrg/pipelines-library
      ref: refs/tags/v2.1.0   # Pinned — no surprise breakage

Use refs/heads/main only in your staging pipelines where you want to test template changes before promoting the tag.

Common Patterns and Anti-Patterns

PatternRecommendation
Secrets in YAMLNever — use variable groups or Key Vault
Copy-paste pipelinesReplace with templates
Manual approval for prodAlways configure via Environments
Pinning template versionsYes for prod, loose for dev
continueOnError: true on security scansOnly if you alert on failures elsewhere
Parallel jobs for speedUse dependsOn to define correct DAG
always() conditionOnly for cleanup/notification steps

Enforcing Pipeline Templates Across the Org

In Azure DevOps, you can require pipelines to extend a specific template:

# Required template policy (set in Project Settings > Pipelines > Settings)
# Require pipelines to use templates from: YourOrg/pipelines-library@main

This means every pipeline in your organization must start with your approved template — you can enforce security scanning, credential rotation, and compliance checks across hundreds of repos from one place.

Putting It Together

A mature Azure DevOps setup looks like this: a central pipelines-library repo with versioned templates, project repos that reference them, variable groups backed by Key Vault, and environment gates with required approvers for production. Your security team owns the templates; your developers own the business logic. Everyone ships faster because they're not reinventing the pipeline wheel for every project.

Share:

Was this article helpful?

Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles

More in Azure

View all →

Discussion