Azure Pipelines Templates: Building Shared Pipelines with Step, Job, Stage and extends Templates
Azure Pipelines templates let you reuse YAML at step, job, stage and variable level; an extends template centralizes the pipeline's skeleton and becomes binding through a required template check. We move Bookstore's .NET and Angular job templates into a separate pipeline-templates repository pinned to a tag, and build a skeleton that uses each and conditional insertion to block inline scripts and makes an audit job mandatory.
Version note: The .NET 10 (SDK 10.0.404) and Angular 22 (Node.js 24) steps in this post were run locally; every pipeline and template file was parsed and checked with yq 4.54. Azure Pipelines behaviour such as template expansion, repository resources and checks is based on the official documentation on Microsoft Learn (November 2026 versions).
Guess First
Bookstore's platform team keeps the shared templates used by every pipeline of Bookstore, the online bookstore used as the shared example across this site's series, in a separate repository called pipeline-templates. Application pipelines reference it like this:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
jobs:
- template: jobs/dotnet-ci.yml@templates
parameters:
solution: Bookstore.slnx
On Tuesday at 09:40 someone from Hakan's team pushes a change to the main branch of pipeline-templates: the solution parameter in dotnet-ci.yml is renamed to solutionPath. Three pipelines are running at that moment, and fourteen more will start before noon. Which ones break?
The answer: the three running runs are not affected, and all fourteen that start before noon break. According to the documentation, repository resources are resolved once when a run starts and the same content is used for the whole run; running runs carry on with the old template. For a template repository without a ref, the default is refs/heads/main; every new run takes the current main, and every pipeline that passes the solution parameter, which no longer exists in that file, fails while the file is processed, before any job starts. The branch the run comes from makes no difference; runs from feature branches see the same main. In this post, while moving templates into a shared place, we will also see how to close that risk by pinning a version.
Kinds of Templates: Step, Job, Stage and Variable
In Azure Pipelines a template is a piece of YAML placed into another YAML file. It can be written at four levels: a step template that provides a list of steps, a job template that provides one or more jobs, a stage template that provides stages and a variable template that provides variables. Each one lands at the same level where it is called: - template: steps/dotnet-setup.yml under steps:, - template: jobs/dotnet-ci.yml under jobs:, and so on. That is the "include" style; there is also extends, which hands the skeleton of the whole pipeline to a template, and we will get to it below.
A template path can be written two ways: if it starts with / it is relative to the repository root, otherwise it is relative to the folder of the file that calls the template. A template in another repository is reached as file.yml@alias. A template called from inside a template is looked up in that template's own repository, which lets the shared repository stay organized with relative paths. The documentation sets three limits to prevent runaway growth: at most 100 separate YAML files can enter a pipeline directly or indirectly, template nesting can be at most 100 levels deep, and at most 20 MB of memory can be used while the file is processed.
The simplest template is a step template that groups a few steps. In Bookstore we pulled out the two steps that start every .NET job:
# Installs the SDK from global.json and restores the solution.
parameters:
- name: solution
type: string
steps:
- task: UseDotNet@2
displayName: Install .NET SDK from global.json
inputs:
packageType: sdk
useGlobalJson: true
- script: dotnet restore ${{ parameters.solution }}
displayName: Restore
A variable template carries shared variables. Since non-secret variables also reach steps as environment variables, defining the settings that turn off the .NET CLI's welcome messages and telemetry once here is enough for every job:
# Variables every Bookstore pipeline gets. Non-secret variables also reach steps as environment variables.
variables:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
Parameter Types, each and Conditional Insertion
Template parameters use the same type system as the runtime parameters from the second post: string, number, boolean, object, and the types that carry YAML pieces, step, stepList, job, jobList, stage, stageList. When you want to hand a piece of code to a template, stepList and jobList are the most useful: the caller passes a list of steps and the template places it where it wants. A value of the wrong type fails before the pipeline starts; the object type accepts anything and so gives no such protection.
There are three directives inside a template. ${{ if }}, ${{ elseif }} and ${{ else }} insert a piece or leave it out based on a condition; ${{ each }} iterates over a list or mapping and produces a piece for each element. All of them are template expressions and all of them run while the file is processed; they cannot see values produced during the run (a job's output, a variable changed by a logging command). dotnet-ci.yml uses both: a loop that places the caller's extra steps between build and test, and a condition that adds the integration tests based on a parameter.
- ${{ each step in parameters.extraSteps }}:
- ${{ step }}
- ${{ if eq(parameters.runIntegrationTests, true) }}:
- script: bash ${{ parameters.testDbScript }}
displayName: Start SQL Server for integration tests
- script: dotnet test --solution ${{ parameters.solution }} --no-build -c ${{ parameters.buildConfiguration }}
displayName: Test
env:
ConnectionStrings__BookstoreDb: $(TestDbConnection)
Notice the two forms side by side in the same file: ${{ parameters.solution }} turns into text while the template is expanded, while $(TestDbConnection) stays as text and is resolved on the agent. If you need a runtime value inside a template, the form is still $( ) or $[ ].
Job Templates for Bookstore
We moved the Version and Api jobs from the second post into templates and added a job for Angular next to them. The Angular job looks like this:
# Installs, tests and builds an Angular application.
parameters:
- name: workingDirectory
type: string
- name: nodeVersion
type: string
default: 24.x
jobs:
- job: Web
displayName: Build and test web
dependsOn: Version
steps:
- checkout: self
- task: UseNode@1
displayName: Install Node.js ${{ parameters.nodeVersion }}
inputs:
version: ${{ parameters.nodeVersion }}
- script: npm ci
displayName: npm ci
workingDirectory: ${{ parameters.workingDirectory }}
- script: npm test -- --watch=false
displayName: Unit tests
workingDirectory: ${{ parameters.workingDirectory }}
- script: npm run build
displayName: Build
workingDirectory: ${{ parameters.workingDirectory }}
We used UseNode@1 for Node.js. NodeTool@0, which shows up a lot in older examples, is deprecated according to its task documentation, which recommends this task instead. The default Node.js on the Ubuntu 24.04 image is 22; Angular 22 works with it too, but this series uses Node 24, so we set the version explicitly. The important part of the output these commands give locally in the same repository (path shortened):
$ npm test -- --watch=false
> bookstore-web@0.0.0 test
> ng test --watch=false
RUN v5.1.0 …/web/bookstore-web
Test Files 1 passed (1)
Tests 1 passed (1)
$ npm run build
> bookstore-web@0.0.0 build
> ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main-BDBHRKZT.js | main | 264.83 kB | 73.11 kB
styles-S5NLN6H7.css | styles | 930 bytes | 930 bytes
| Initial total | 265.76 kB | 74.05 kB
Output location: …/web/bookstore-web/dist/bookstore-web
With the templates in a separate repository, the application pipeline becomes almost nothing but calls. As an intermediate step, calling the job templates directly without extends looks like this:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
ref: refs/tags/v1.0.0
pool:
vmImage: ubuntu-24.04
jobs:
- template: jobs/version.yml@templates
- template: jobs/dotnet-ci.yml@templates
parameters:
solution: Bookstore.slnx
- template: jobs/angular-ci.yml@templates
parameters:
workingDirectory: web/bookstore-web
In this form the .NET and Angular jobs shrank to two-line calls each, parameters included. But something is missing: the application team can change this file as they like, stop calling a template or put any job they want in between. extends exists to close that gap.
A Separate pipeline-templates Repository and Pinning to a Tag
Copying templates into every application repository ends up as fourteen different dotnet-ci.yml files after a while. A shared repository fixes that but brings its own rules. The repository is declared in the application pipeline under resources.repositories with an alias. For Azure Repos that is type: git and name: <project>/<repository>; for GitHub, type: github and name: <organization>/<repository>. If the template repository is in a different Azure DevOps organization, you need to define a service connection for access and pass it with endpoint:.
The ref key says which version to use: refs/heads/<branch>, refs/tags/<tag> or a 40-character commit SHA. Our choice is a tag: the platform team versions templates like a library (v1.0.0, v1.1.0), and application teams move to a new version through a pull request. A breaking change bumps the major version, and since the old tag stays where it is, nobody is affected by accident. Remember that tags can move: Git does not stop a tag from being rewritten to another commit. To prevent that, you can use repository permissions that let only the platform team create tags in the template repository, or a commit SHA for a truly immutable reference.
One more rule surprises many people: script files in the template repository cannot be run in the pipeline. The documentation says it plainly: once templates are fully expanded, the pipeline runs as if it had been defined entirely in the source repository, and only self is checked out on the agent. That is why the bash scripts/set-version.sh line in jobs/version.yml runs the script in the Bookstore repository; we made the script path a parameter so another application can pass its own. If you really want to keep the script in the template repository, you need a second checkout: templates step in the job; the two repositories then land in separate subfolders and the paths change.
A Required Skeleton with an extends Template
With include templates the caller is in control: the application pipeline decides the structure of the file. With extends it is the other way around: the template decides the skeleton of the pipeline, and the application pipeline only passes the parameters the template accepts. The skeleton we wrote for Bookstore's platform team:
# Required skeleton for Bookstore CI pipelines (used with "extends").
# Application pipelines choose values; the structure, the pool and the audit job stay fixed.
parameters:
- name: solution
type: string
- name: webDirectory
type: string
default: ''
- name: buildConfiguration
type: string
default: Release
values:
- Release
- Debug
- name: runIntegrationTests
type: boolean
default: true
- name: extraApiSteps
type: stepList
default: []
stages:
- stage: Build
displayName: Build and test
pool:
vmImage: ubuntu-24.04
variables:
- template: ../variables/common.yml
jobs:
- template: ../jobs/version.yml
- template: ../jobs/dotnet-ci.yml
parameters:
solution: ${{ parameters.solution }}
buildConfiguration: ${{ parameters.buildConfiguration }}
runIntegrationTests: ${{ parameters.runIntegrationTests }}
extraSteps:
# Only tasks are accepted here; inline scripts are replaced by a failing step.
- ${{ each step in parameters.extraApiSteps }}:
- ${{ if or(startsWith(step.task, 'Bash'), startsWith(step.task, 'CmdLine'), startsWith(step.task, 'PowerShell')) }}:
- script: |
echo "##vso[task.logissue type=error]Inline scripts are not allowed in extraApiSteps."
exit 1
displayName: Blocked by template
- ${{ else }}:
- ${{ step }}
- ${{ if ne(parameters.webDirectory, '') }}:
- template: ../jobs/angular-ci.yml
parameters:
workingDirectory: ${{ parameters.webDirectory }}
- job: Audit
displayName: Dependency audit (required)
dependsOn: []
steps:
- checkout: self
- template: ../steps/dotnet-setup.yml
parameters:
solution: ${{ parameters.solution }}
- script: |
dotnet list ${{ parameters.solution }} package --vulnerable --include-transitive | tee audit.txt
if grep -q "has the following vulnerable packages" audit.txt; then
echo "##vso[task.logissue type=error]Vulnerable packages found."
exit 1
fi
displayName: Fail on vulnerable packages
This skeleton fixes three things. The pool: the application pipeline cannot pick an image. The audit job: with dependsOn: [] it runs on every run without depending on the other jobs, and it breaks the run if a NuGet package has a known vulnerability. Real dependency and code scanning is the subject of the DevSecOps series; the point here is to put a check the platform team wants somewhere the application team cannot remove it. Third, the steps the application may add: extraApiSteps is a stepList, and the each loop looks at every step and replaces inline scripts with a failing step. According to the documentation's example, the script:, bash: and powershell: shortcuts appear at this point already in task form (CmdLine@2, Bash@3, PowerShell@2), which is why the check looks at the start of the task name.
The output the audit step sees locally is clean for now (path shortened):
$ dotnet list Bookstore.slnx package --vulnerable --include-transitive
Determining projects to restore...
All projects are up-to-date for restore.
The following sources were used:
https://api.nuget.org/v3/index.json
The given project `Bookstore.Api` has no vulnerable packages given the current sources.
The given project `Bookstore.Api.Tests` has no vulnerable packages given the current sources.
Bookstore's azure-pipelines.yml now consists only of triggers, the template repository and a single extends block:
resources:
repositories:
- repository: templates
type: git
name: Bookstore/pipeline-templates
ref: refs/tags/v1.0.0
extends:
template: pipelines/bookstore-ci.yml@templates
parameters:
solution: Bookstore.slnx
webDirectory: web/bookstore-web
buildConfiguration: ${{ parameters.buildConfiguration }}
runIntegrationTests: ${{ parameters.runIntegrationTests }}
extraApiSteps:
- task: DotNetCoreCLI@2
displayName: Check formatting
inputs:
command: custom
custom: format
arguments: Bookstore.slnx --verify-no-changes --no-restore
The application team added the formatting check as a task; the skeleton accepts it. When a file has two extra spaces, the same check fails locally like this:
$ dotnet format Bookstore.slnx --verify-no-changes --no-restore
src/Bookstore.Api/Catalog/Category.cs(5,5): error WHITESPACE: Fix whitespace formatting. Delete 2 characters. [src/Bookstore.Api/Bookstore.Api.csproj]
The command exits with code 2, the task turns red, and the test steps are skipped because of the default succeeded() condition. Notice that the triggers and the parameters block stay in the application file: schedules must be in the main file, and we defined the runtime parameters in the main file and passed their values on to extends. We removed web from the path filter because the pipeline now builds Angular too.
Making the Rule Binding with a Required Template Check
extends on its own is a suggestion: the application team can delete extends from the file and write its own jobs. What makes the rule binding is a "Required template" check added to a protected resource. According to the documentation, checks can be added to environments, service connections, repositories, variable groups, secure files and agent pools, and they are managed by the owner of the resource, not by the pipeline author. A required template check takes the repository type, the repository, the ref and the template path; a stage that uses the resource fails before it starts if the pipeline does not extend from that template. This check belongs to the "static checks" group and is evaluated before approvals.
There is an important limit: the check only applies to stages that use that resource. Our CI pipeline does not use a protected resource yet, so today nothing stops a CI pipeline that drops extends. The real value of the rule shows on the road to production: once the production environment in the sixth post and the Azure service connection in the seventh post get a required template check, no pipeline that skips the approved skeleton can touch those resources. The documentation says you can define more than one required template for the same resource and use a tag as the ref, which makes it possible to accept both the old and the new tag for a while during a template upgrade.
What We Can Check Locally
Seeing the expanded templates takes Azure Pipelines itself: the "Download full YAML" option in the YAML editor downloads the pipeline as a single file with every template expanded, without running it. What we can do without an account is confirm that each file is valid YAML on its own:
$ for f in azure-pipelines.yml pipeline-templates/*/*.yml; do yq 'true' "$f" > /dev/null && echo "OK $f"; done
OK azure-pipelines.yml
OK pipeline-templates/jobs/angular-ci.yml
OK pipeline-templates/jobs/dotnet-ci.yml
OK pipeline-templates/jobs/version.yml
OK pipeline-templates/pipelines/bookstore-ci.yml
OK pipeline-templates/steps/dotnet-setup.yml
OK pipeline-templates/variables/common.yml
This loop catches YAML structure; it does not tell you whether template expressions expand correctly or whether parameter names match. The real safety net for a template repository is running the new version against a sample application in a test pipeline in that repository before tagging it.
In Production
Treat the template repository as a product. Without a changelog, a version number and a list of which application uses which tag, "we fixed a security issue in the template, everyone please move" takes weeks. The ref lines in application repositories are searchable, so finding which pipelines are stuck on an old version is easy; a small report that does it regularly is enough.
Keep parameters narrow. Every new parameter is one more combination the template has to support. The object type looks flexible but removes type checking; prefer string with values: or stepList where you can. Opening an "everything is overridable" escape hatch in the skeleton throws away the guarantee extends gives you.
Measure the performance impact of shared templates as well. The audit job starts its own VM and does a restore on every run; in an organization with a single parallel job, that adds directly to run time. Whether the check is worth that cost, or whether it should become a step inside the API job, is the platform team's call.
Common Pitfalls
Pinning to main or giving no ref at all. The scenario from the opening: the default is refs/heads/main, and every push to the template repository changes the next run of every pipeline.
Expecting a runtime value in a template expression. A condition such as ${{ if eq(variables['appVersion'], '') }} is always true, because appVersion is produced after the file is processed. To decide inside a template you only have parameters, variables fixed in the file, and the predefined variables the documentation marks as available in templates.
Calling a script from the template repository. A bash scripts/x.sh line is always looked up in the repository checked out on the agent; unless the template repository is checked out, the file is not found.
Clashing job names. Call the same job template twice in the same stage and both jobs carry the same name, and the pipeline fails before it starts. Job templates meant to be used more than once need the job name as a parameter.
Putting schedules in a template. schedules can only be defined in the main pipeline file; a schedule in a template is ignored.
A Small Experiment
★☆☆ Add a runLint boolean parameter to jobs/angular-ci.yml and insert an npm run lint step with conditional insertion when it is true.
★★☆ Add a - script: echo hello step to extraApiSteps and write out on paper what the expanded YAML will look like. Then add an "allow list" rule to the skeleton that accepts only the DotNetCoreCLI@2 and PublishTestResults@2 tasks.
★★★ Design a pipeline for the template repository itself: on every pull request it should run the skeleton against a sample application, and after a merge to main it should suggest the next tag. Write a script that lists application repositories still on an old tag.
Our skeleton is ready, but its jobs still download packages from scratch on every run, leave test results only in the log and produce no output. In the next post we add caching, published test results and coverage to the .NET and Angular jobs and combine the output of both jobs into a single release package.