In a previous post I looked at multistage YAML pipelines. In this post I am going to look at dynamic multistage YAML pipelines.
What do I mean by dynamic multistage? What I mean is running multiple stages but all of the configuration is loaded dynamically from one or more sources e.g. parameters, variable templates, variable groups, etc..
Why?
What problem am I trying to solve with this? Firstly, reduce duplication, in a lot of cases the difference between dev and prod is just the configuration. Secondly, provide the ground work to get a base setup so that I can concentrate on what steps are needed in the pipeline and not worry about the environments.
Anything else? Well, I often have multiple projects that all need to deploy to the same set of environments, it would be good to share the configuration for that as well between projects.
Next Steps
Ok, I need a pipeline, lets start with something simple, a pipeline with an initial build stage and then multiple deployment stages defined by a parameter:
trigger: none
pr: none
pool:
vmImage: 'ubuntu-latest'
parameters:
- name: stages
type: object
default:
- 'dev'
- 'prod'
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
steps:
# Steps to perform the build and/or package of code or IaC
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps:
# Steps to perform the deployment
This very small example achieves configuring multiple deployment stages, adding another stage to this would be very easy to do, just update the parameter to include a new stage name.
Now we have the basic configuration lets add loading of a variable group. This could be done by using dynamic naming or by changing the stages parameter.
I have a variable group for each environment, groupvars_dev
, groupvars_prod
with a single variable mygroupvar
.



Dynamic Naming
I’ll add the variable group to the variables at the Stage level (this could also be done at the job level) and include the stage name dynamically.
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
variables:
- group: groupvars_${{ stage }}
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps:
- bash: |
echo '$(mygroupvar)'
displayName: 'Deploy Steps'


Parameter Change
Another way to define the dynamic group is to update the parameter object to provide additional configuration e.g.
parameters:
- name: stages
type: object
default:
- name: 'dev'
group: 'groupvars_dev'
- name: 'prod'
group: 'groupvars_prod'
...
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage.name }}
displayName: 'Deploy to ${{ stage.name }}'
variables:
- group: ${{ stage.group }}
jobs:
- deployment: deploy_${{ stage.name }}
displayName: 'Deploy app to ${{ stage.name }}'
environment: ${{ stage.name }}
strategy:
runOnce:
deploy:
steps:
- bash: |
echo '$(mygroupvar)'
displayName: 'Deploy Steps'
Both ways of adding the variable group dynamically achieved the same goal and loaded in the expected group when each stage ran.
Variable Templates
Variable groups are not the only way to dynamically load variables, you could also use variable templates, lets say I have variable templates for each environment, vars_dev.yml
and vars_prod.yml
Using dynamic naming you can load the variables like this:
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
variables:
- template: vars_${{ stage }}.yml
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps:
- bash: |
echo '$(myfilevar)'
displayName: 'Deploy Steps'

Now with variable files and groups being added, updating to add a new stage becomes a little more complex as I would need to add those as well.
Shared Template
Now I have a dynamic multistage pipeline, how can I create a template to share with other projects?
Before I answer that I should say that I usually use a separate repository for shared templates that way I can version them. I covered this is a previous post if you want some more information.
Ok, on to the how, based on the above scenario wouldn’t it be great to have a really simple pipeline that concentrated on just the steps, like this?
trigger: none
pr: none
pool:
vmImage: 'ubuntu-latest'
resources:
repositories:
- repository: templates
type: git
name: shared-templates
ref: main
extends:
template: environments.yml@templates
parameters:
variableFilePrefix: 'vars'
buildSteps:
# Steps to perform the build and/or package of code or IaC
releaseSteps:
# Steps to perform the deployment
This could be your boilerplate code for multiple projects extending from a base template. You might be asking but how do I create such a template?
Lets convert what we started with into a template a bit at a time.
Firstly create a new file e.g. environments.yml
to be the base template and add the parameters that make up the stage configuration
parameters:
- name: stages
type: object
default:
- 'dev'
- 'prod'
Next, add the build stage up to the steps
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
steps:
At this point we need to be able to pass in the build steps, using the Azure Pipeline built-in type stepList
we can add a parameter ‘buildSteps’:
parameters:
- name: stages
type: object
default:
- 'dev'
- 'prod'
- name: buildSteps
type: stepList
default: []
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
steps: ${{ parameters.buildSteps }}
Next, add the dynamic stages up to the steps
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps:
And then as before, add a stepList
for the release steps
parameters:
- name: stages
type: object
default:
- 'dev'
- 'prod'
- name: buildSteps
type: stepList
default: []
- name: releaseSteps
type: stepList
default: []
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
# Steps to perform the build and/or package of code or IaC
steps: ${{ parameters.buildSteps }}
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
variables:
- template: vars_${{ stage }}.yml
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps: ${{ parameters.releaseSteps }}
The next part is adding support for variable groups and/or templates. This can be achieved by the addition of 2 parameters for the name prefixes e.g.
- name: variableGroupPrefix
type: string
default: ''
- name: variableFilePrefix
type: string
default: ''
There will also need to a be check to only load the group and/or file if the parameter is not empty ”.
parameters:
- name: stages
type: object
default:
- 'dev'
- 'prod'
- name: buildSteps
type: stepList
default: []
- name: releaseSteps
type: stepList
default: []
- name: variableGroupPrefix
type: string
default: ''
- name: variableFilePrefix
type: string
default: ''
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
# Steps to perform the build and/or package of code or IaC
steps: ${{ parameters.buildSteps }}
- ${{ each stage in parameters.stages }}:
- stage: ${{ stage }}
displayName: 'Deploy to ${{ stage }}'
variables:
- ${{ if ne(parameters.variableGroupPrefix, '') }}:
- group: ${{ parameters.variableGroupPrefix }}_${{ stage }}
- ${{ if ne(parameters.variableFilePrefix, '') }}:
- template: ${{ parameters.variableFilePrefix }}_${{ stage }}.yml
jobs:
- deployment: deploy_${{ stage }}
displayName: 'Deploy app to ${{ stage }}'
environment: ${{ stage }}
strategy:
runOnce:
deploy:
steps: ${{ parameters.releaseSteps }}
Note: If I was running this template from the same repository, loading of the variable file would be fine but when it’s in a separate repository there needs to be a slight adjustment to add @self
on the end so it will load from the calling repository instead of the remote repository.
- template: ${{ parameters.variableFilePrefix }}_${{ stage }}.yml@self
And that is it, one base template that handles the desired configuration and ready for reuse.
Expanding the Concept
Lets say you had a requirement to deploy multiple projects IaC (Infrastructure as Code) and applications to multiple subscriptions and multiple regions in your Azure Estate. How nice would it be to be able to define that in a central configuration. Here is one possible configuration for such a requirement
parameters:
- name: environments
type: object
default:
- name: 'dev'
subscriptions:
- subscription: 'Dev Subscription'
regions:
- location: 'westus'
locationShort: 'wus'
- name: 'prod'
subscriptions:
- subscription: 'Prod Subscription'
regions:
- location: 'eastus'
locationShort: 'eus'
- location: 'westus'
locationShort: 'wus'
- name: buildSteps
type: stepList
default: []
- name: releaseSteps
type: stepList
default: []
- name: customReleaseTemplate
type: string
default: ''
- name: variableGroupPrefix
type: string
default: ''
- name: variableFilePrefix
type: string
default: ''
stages:
- stage: build
displayName: 'Build/Package Code or IaC'
jobs:
- job: build
displayName: 'Build/Package Code'
steps: ${{ parameters.buildSteps }}
- ${{ each env in parameters.environments }}:
- stage: ${{ env.name }}
displayName: 'Deploy to ${{ env.name }}'
condition: succeeded()
variables:
- ${{ if ne(parameters.variableFilePrefix, '') }}:
- template: ${{ parameters.variableFilePrefix }}_${{ env.name }}.yml@self
- ${{ if ne(parameters.variableGroupPrefix, '') }}:
- group: ${{ parameters.variableGroupPrefix }}_${{ env.name }}
jobs:
- ${{ each sub in env.subscriptions }}:
- ${{ each region in sub.regions }}:
- ${{ if ne(parameters.customReleaseTemplate, '') }}:
- template: ${{ parameters.customReleaseTemplate }}
parameters:
env: ${{ env.name }}
location: ${{ region.location }}
locationShort: ${{ region.locationShort }}
subscription: ${{ sub.subscription }}
- ${{ else }}:
- deployment: deploy_${{ region.locationShort }}
displayName: 'Deploy app to ${{ env.name }} in ${{ region.location }}'
environment: ${{ env.name }}_${{ region.locationShort }}
strategy:
runOnce:
deploy:
steps:
- ${{ parameters.releaseSteps }}
You may notice with this configuration there is an option for a custom release template where you could override the job(s) required, you would just need to make sure the template included the parameters supplied from the base template:
parameters:
- name: env
type: string
- name: location
type: string
- name: locationShort
type: string
- name: subscription
type: string
Then you can add the custom jobs for a given project.
Final Thoughts
Shared templates are so powerful to use and combined with the often forgotten about built-in types step
, stepList
, job
, jobList
, deployment
, deploymentList
, stage
and stageList
, really allows for some interesting templates to be created.
For additional information see the Azure Pipelines Parameters docs.
You are no doubt thinking, this all sounds very good but what about real application of such a template? In the next post I will use this last template to deploy some Infrastructure as Code to Azure and then deploy an application into that infrastructure to show real usage.