Azure DevOps Pipelines: YAML Templates and Best Practices
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
| Pattern | Recommendation |
|---|---|
| Secrets in YAML | Never — use variable groups or Key Vault |
| Copy-paste pipelines | Replace with templates |
| Manual approval for prod | Always configure via Environments |
| Pinning template versions | Yes for prod, loose for dev |
continueOnError: true on security scans | Only if you alert on failures elsewhere |
| Parallel jobs for speed | Use dependsOn to define correct DAG |
always() condition | Only 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.
Was this article helpful?
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
Azure DevOps Pipelines: YAML CI/CD from Build to Production
How to build production Azure DevOps YAML pipelines — multi-stage CI/CD, environments, approvals, variable groups, templates, container jobs, and deployment strategies for Kubernetes and App Service.
Azure AKS: Production Kubernetes Cluster Setup and Configuration
Deploy a production AKS cluster on Azure — node pools, managed identities, Azure CNI, RBAC integration with Entra ID, ACR integration, autoscaling, monitoring with Azure Monitor, and Terraform setup.
Jenkins Declarative Pipelines: From Zero to Production CI/CD
How to write Jenkins declarative pipelines from scratch — stages, agents, environment variables, credentials, parallel execution, post conditions, and shared libraries. Practical Jenkinsfile patterns for real projects.
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
Azure Core Services: The DevOps Engineer's Essential Guide
Understand Azure's essential services — VMs, Storage, VNets, Azure AD (Entra ID), AKS, App Service, and Azure DevOps for infrastructure automation.
Azure Blob Storage 403 AuthorizationPermissionMismatch: Step-by-Step Fix Guide
If you've ever stared at this error message in your logs, you know the frustration: It's one of those errors that looks simple on the surface but has about...
More in Azure
View all →Fix Azure 'SubscriptionNotRegistered' Error
Resolve the Azure 'SubscriptionNotRegistered for resource type' error by registering the required resource provider with step-by-step instructions.
Azure AKS: Production-Ready Cluster Setup in 15 Minutes
Stand up a production-grade AKS cluster with autoscaling, RBAC, monitoring, and private networking in under 15 minutes.