Multi-Stage Pipelines: Templates, Variables & Approvals
Build reusable multi-stage pipelines with YAML templates, variable groups, task groups, and environment-based approvals. Design job execution order with parallelism and dependencies.
Why multi-stage pipelines and templates?
Think of a franchise restaurant chain.
Every McDonaldβs follows the same recipe book, the same kitchen layout, the same quality checks. The head office creates templates (standard recipes) that every franchise follows. Individual restaurants can customize their menu slightly (local variables), but the core process β prep, cook, quality check, serve β is enforced by the template.
Multi-stage pipeline templates work the same way. A platform team creates reusable templates that enforce how every team builds, tests, and deploys. Individual teams plug in their specific settings (project name, test framework, deployment target) as variables, but the structure β build, test, approve, deploy β is standardized. And just like a restaurant needs a manager to approve before opening for service, approvals ensure a human signs off before code reaches production.
Job execution order
Parallel vs sequential
By default, jobs within a stage run in parallel and stages run sequentially in Azure Pipelines. You control the order with dependsOn:
| Pattern | Azure Pipelines | GitHub Actions |
|---|---|---|
| Sequential stages | Default behaviour (each stage depends on the previous) | Use needs: between jobs |
| Parallel stages | dependsOn: [] (empty array = no dependencies) | Jobs without needs: run in parallel by default |
| Fan-out | One stage triggers multiple parallel stages | One job triggers multiple parallel jobs via needs: |
| Fan-in | A stage depends on multiple preceding stages | A job has needs: [jobA, jobB, jobC] |
Fan-out / fan-in example
stages:
- stage: Build
jobs:
- job: BuildApp
steps:
- script: dotnet build
- stage: TestUnit
dependsOn: Build
jobs:
- job: UnitTests
steps:
- script: dotnet test --filter Category=Unit
- stage: TestIntegration
dependsOn: Build
jobs:
- job: IntegrationTests
steps:
- script: dotnet test --filter Category=Integration
- stage: Deploy
dependsOn:
- TestUnit
- TestIntegration
jobs:
- deployment: DeployProd
environment: production
Build runs first. TestUnit and TestIntegration run in parallel (both depend only on Build). Deploy waits for both test stages to succeed β the fan-in point.
Conditions
Use condition: to control whether a stage or job runs based on the outcome of previous stages:
succeeded()β previous stage succeeded (default)failed()β previous stage failedalways()β run regardless of outcomeeq(variables['Build.SourceBranch'], 'refs/heads/main')β run only on main branch
YAML templates
Templates are the most powerful reuse mechanism in Azure Pipelines. They let you define pipeline fragments once and include them in multiple pipelines.
Template types
| Template Type | What It Contains | How Itβs Used |
|---|---|---|
| Step template | Reusable step definitions | - template: steps/build.yml inside a jobβs steps |
| Job template | Reusable job definitions | - template: jobs/test.yml inside a stageβs jobs |
| Stage template | Reusable stage definitions | - template: stages/deploy.yml inside a pipelineβs stages |
| Variable template | Reusable variable definitions | - template: vars/common.yml in the variables section |
| Extends template | Wraps the ENTIRE pipeline β enforces mandatory structure | extends: template: pipeline-template.yml at root level |
Template with parameters
A build template that teams can customise:
# templates/dotnet-build.yml
parameters:
- name: projectPath
type: string
- name: dotnetVersion
type: string
default: '8.0'
- name: runTests
type: boolean
default: true
steps:
- task: UseDotNet@2
inputs:
version: ${{ parameters.dotnetVersion }}
- task: DotNetCoreCLI@2
inputs:
command: build
projects: ${{ parameters.projectPath }}
- ${{ if parameters.runTests }}:
- task: DotNetCoreCLI@2
inputs:
command: test
projects: ${{ parameters.projectPath }}
Consuming the template:
steps:
- template: templates/dotnet-build.yml
parameters:
projectPath: 'src/MyApi/MyApi.csproj'
dotnetVersion: '8.0'
runTests: true
Extends templates β enforcing standards
The extends keyword is a game-changer for enterprise governance. It wraps the consuming pipeline inside a template that cannot be overridden:
# enforced-pipeline.yml (owned by platform team)
parameters:
- name: stages
type: stageList
stages:
- stage: SecurityScan
jobs:
- job: CodeQL
steps:
- task: AdvancedSecurity-Codeql-Init@1
- task: AdvancedSecurity-Codeql-Analyze@1
- ${{ each stage in parameters.stages }}:
- ${{ stage }}
The consuming pipeline:
extends:
template: enforced-pipeline.yml
parameters:
stages:
- stage: Build
jobs:
- job: BuildApp
steps:
- script: dotnet build
The security scan always runs first β teams cannot remove it because the extends template controls the outer structure. This is how platform teams enforce mandatory security scanning, compliance checks, or notification steps across hundreds of pipelines.
Scenario: Nadia's enterprise template strategy at Meridian Insurance
π’ Nadia Petrov manages 47 pipelines at Meridian Insurance. Elena (compliance) requires that every pipeline runs security scans and sends deployment notifications. Dmitri (VP Engineering) wants consistency without slowing teams down.
Nadiaβs template architecture:
- Extends template (
enforced-pipeline.yml) β owned by the platform team. Every pipeline must extend this. It enforces: CodeQL security scan, dependency vulnerability check, deployment notification to Teams - Stage templates β
stages/deploy-to-aks.ymlandstages/deploy-to-app-service.ymlβ reusable deployment patterns - Variable templates β
vars/production.ymlandvars/staging.ymlβ environment-specific connection strings, resource group names, subscription IDs - Step templates β
steps/dotnet-build.yml,steps/node-build.ymlβ standardised build patterns
Rashid (platform lead) maintains the template repository. Application teams extend the enforced template and plug in their specific stages. If Rashid adds a new compliance check, it applies to all 47 pipelines on the next run β no team-by-team updates needed.
The result: Elena gets guaranteed compliance. Teams get productivity (less YAML to write). Dmitri gets consistency across 47 pipelines.
Variables and variable groups
Variable types in Azure Pipelines
| Variable Type | Scope | Defined In | Use Case |
|---|---|---|---|
| Pipeline variables | Single pipeline | YAML variables: section | Build configuration, feature flags |
| Variable groups | Shared across pipelines | Azure DevOps Library | Connection strings, API URLs, shared settings |
| Secret variables | Single pipeline or group | Azure DevOps Library (locked) | Passwords, tokens, API keys β masked in logs |
| Key Vault linked | Variable group linked to Azure Key Vault | Azure Key Vault + Library | Secrets managed in Key Vault, consumed in pipelines |
| Runtime variables | Set during pipeline execution | ##vso[task.setvariable] logging command | Dynamic values computed during the build |
Variable syntax β three expression types
This is a critical exam topic. Azure Pipelines has three distinct expression syntaxes:
| Syntax | When Evaluated | Example | Use Case |
|---|---|---|---|
Template expressions ${{ variables.name }} | At compile time (before the pipeline runs) | ${{ parameters.env }} | Template parameter insertion, conditional inclusion |
Macro syntax $(variableName) | At runtime (when the task runs) | $(Build.BuildId) | Most common β used in task inputs and scripts |
Runtime expressions $[variables.name] | At runtime (when the stage/job/step is evaluated) | $[eq(variables.env, 'prod')] | Conditional execution at runtime |
Key distinction: Template expressions are processed when the YAML is compiled β they can generate or remove YAML structure. Macro and runtime expressions are processed during execution β they can only substitute values.
Key Vault integration
Variable groups can be linked to Azure Key Vault:
- Create a variable group in the Azure DevOps Library
- Link it to an Azure Key Vault instance using a service connection
- Select which secrets to map as pipeline variables
- Reference them with
$(secretName)in the pipeline
Secrets from Key Vault are automatically masked in pipeline logs and never written to disk.
Exam tip: expression syntax traps
The exam frequently tests whether you understand which syntax to use:
- Template expression
${{ }}β use when you need to conditionally INSERT or REMOVE YAML blocks at compile time. Example:${{ if eq(parameters.runTests, true) }}:adds or removes a step. - Macro
$()β use in task inputs and script arguments. This is the most common syntax. Example:$(Build.BuildId)in a script command. - Runtime expression
$[ ]β use incondition:fields and when you need dynamic evaluation after the pipeline structure is finalised. Example:condition: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
Common mistake: using macro syntax $(param) for template parameters β this fails because template parameters are only available at compile time with ${{ parameters.param }}.
Environment checks and approvals
Azure DevOps environments are the governance layer for deployments. Each environment can have multiple checks that must pass before a deployment job proceeds.
Check types
| Check | What It Does | When To Use |
|---|---|---|
| Approvals | One or more people must approve before deployment | Production deployments, sensitive environments |
| Branch control | Only specific branches can deploy to the environment | Only main or release/* can reach production |
| Business hours | Deployments only allowed during specified hours | No production deploys on weekends or after 6 PM |
| Exclusive lock | Only one deployment runs at a time per environment | Prevent parallel deployments to the same target |
| Azure Function | Call an Azure Function to make a dynamic approval decision | Custom logic β check deployment freeze calendars, validate external conditions |
| REST API | Call any REST endpoint and check the response | External governance systems, ITSM ticket validation |
| Required template | Pipeline must extend a specific YAML template | Enforce that all pipelines use the approved template |
Approval configuration
Approvals are configured on the environment (not in the YAML):
- Minimum number of approvals β how many approvers must sign off
- Order β sequential (each approver in order) or any (any of the specified approvers)
- Timeout β how long to wait before the approval times out and the deployment fails
- Allow self-approval β whether the person who triggered the pipeline can also approve it (disabled by default for separation of duties)
GitHub Actions equivalent
In GitHub Actions, environment protection rules serve the same purpose:
- Required reviewers map to Azure DevOps approvals
- Wait timer provides a cool-down delay (similar to Azure business hours gate)
- Deployment branches map to branch control
- Custom deployment protection rules map to Azure Function / REST API checks
Knowledge check
Nadia needs to ensure that every pipeline at Meridian Insurance runs a mandatory CodeQL security scan BEFORE any application-defined stages, and application teams cannot skip or remove it. Which template approach achieves this?
Jordan defines a pipeline variable using a template expression: dollar-double-brace with variables.buildConfig. A colleague changes it to macro syntax: dollar-parentheses with buildConfig. Both reference the same variable. When does each syntax get evaluated?
Meridian Insurance requires that production deployments only happen on weekdays between 8 AM and 6 PM, only from the main branch, and only with approval from both Elena (compliance) and Dmitri (VP Eng). Which Azure DevOps features should Nadia configure on the production environment?
π¬ Video coming soon
Next up: Deployment Strategies β design blue-green, canary, rolling, and feature flag deployments that minimise risk and enable safe production releases.