I’ve been a huge fan of Octopus Deploy and Teamcity. However, the downside of these great tools is the lack of storing the configurations in versioncontrol. Configurations are done in the web interface and requires additional knowledge of the tool itself. Adding new projects resulted in cloning existing projects and adjust it where it was needed.
Last year I have been working with Gitlab which is an awesome tool for CI purposes. We combined Gitlab with Octopus Deploy, although Gitlab is capable of handling CD too. Octopus Deploy is and will always be an awesome workhorse for managing deployments. I really dig this tool.
But the idea of using pipelines as Gitlab has implemented is hard to beat. Using pipelines this way, it is possible to put CI/CD in version control. And this is the place where is should be, not inside a tool itself, but as part of your version-controlled solution.
For some new projects, I had the change to move from Teamcity/Octopus Deploy to Azure Devops. Having some experience in using Gitlab, I was curious about the possibilities of Azure Devops.
Let the madness begin!

Off topic: For the ones who do not know this guitarist: this is Randy Rhoads, I think one the greatest guitar players of all time. Unfortunately he died at a age of 26 in a place crash. He has left us with two amazing albums together with Ozzy who always started his live shows shouting: ‘Let the madness begin!”
In Azure DevOps, I’ve started with a build pipeline to define your build steps. This is great to manage your CI in code but the CD part still required additional steps on the ‘release’ section. This was sort of an Octopus Deploy implementation inside Azure DevOps. Not what I was looking for.
thankfully, the development team of Azure Devops added a preview version of Multistage pipelines:

This article is based upon multistage pipeline and is not using the releases.
Let’s start with a single basic pipeline, from here, we’ll move on to the fun parts:
trigger: - master pool: vmImage: 'ubuntu-latest' steps: - script: echo Hello, world! displayName: 'Run a one-line script' - script: | echo Add other tasks to build, test, and deploy your project. echo See https://aka.ms/yaml displayName: 'Run a multi-line script'
This is just a basic pipeline, let’s transform it to a multistage pipeline:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build - stage: tst_deploy - stage: uat_deploy - stage: prd_deploy
As you can see, there is a stages section added with some defined stages, for example a build stage, and some deployment stages. Let’s add some jobs to the stages:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy - stage: uat_deploy - stage: prd_deploy
Results in Azure DevOps:


Important to understand is the types of jobs, there are 2 different kind of jobs you can use:
- A regular Job type, which says: hey, let’s do some stuff!
- A Deployment type, which says, let’s move some files!
A deployment job has some additional features. The most important feature is the possibility to link a deployment to an ‘environment’.
Environments
Environments can be created in Azure Devops:

An environment is just a label, you can label them whatever you want. For example: Seed/Flower/Tree. Being part of a CI/CD pipeline, naming these like TST/UAT/PRD is more appropriate 😉
Having an environment defined, we can add approvals to these environments:


Here you can add details for who is allowed to approve the deployment job.
So, let’s create a deployment job first and link the deployment to an environment:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - deployment: tst_deployment environment: TST strategy: runOnce: deploy: steps: - script: echo Deploy Artifact from Build to environment displayName: 'Deploy - Deploy Artifact' - stage: uat_deploy jobs: - deployment: uat_deployment environment: UAT strategy: runOnce: deploy: steps: - script: echo Deploy Artifact from Build to environment displayName: 'Deploy - Deploy Artifact' - stage: prd_deploy jobs: - deployment: prd_deployment environment: PRD strategy: runOnce: deploy: steps: - script: echo Deploy Artifact from Build to environment displayName: 'Deploy - Deploy Artifact'
And the result:

As you can see, the pipeline is paused when the UAT stage is reached. This is because I’ve added an approval on the UAT environment. There is no approval set on TST, so the build deploys directly to the TST and stops when UAT is reached:

We can approve the pipeline, then it will continue to UAT and PRD:

Ok, so far so good.
Adding steps
Let’s add some more ‘real world’ steps to the deployment, for example, when we are deploying to an Azure environment, we can design our CD like:
- Create a new Azure Webapp
- Set App settings
- Add some IP whitelisting stuff
- Add hostnames
- Deploy artifact
- Run E2E Tests
- Switch Trafficmanager
(just an example)
I won’t create these actual jobs, but I’ll add placeholders as a replacement to get an idea.
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - deployment: tst_deployment environment: TST strategy: runOnce: deploy: steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager' - stage: uat_deploy jobs: - deployment: uat_deployment environment: UAT strategy: runOnce: deploy: steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager' - stage: prd_deploy jobs: - deployment: prd_deployment environment: PRD strategy: runOnce: deploy: steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager'
And the result:

Yes, that’s more like it! However, our pipeline is becoming very large if we are adding more steps. Thankfully we can move parts into a template. Using templates keeps our pipeline clean and is preventing code duplication.
Templates
More information regarding templates can be found here:
https://docs.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops
First, let’s move the steps to a template file, called steps.yaml:
steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager'
now we have moved our steps to a template, we need to call the template to the pipeline:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - deployment: tst_deployment environment: TST strategy: runOnce: deploy: steps: - template: steps.yaml - stage: uat_deploy jobs: - deployment: uat_deployment environment: UAT strategy: runOnce: deploy: steps: - template: steps.yaml - stage: prd_deploy jobs: - deployment: prd_deployment environment: PRD strategy: runOnce: deploy: steps: - template: steps.yaml
The result will be the same as my previous example, but my pipeline is way cleaner. Let’s move more to the template, the full jobs part:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - template: jobs.yaml - stage: uat_deploy jobs: - template: jobs.yaml - stage: prd_deploy jobs: - template: jobs.yaml
and our jobs template:
jobs: - deployment: tst_deployment environment: TST strategy: runOnce: deploy: steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager'
Yes, this is going to work:

oopsie… we just deployed our code 3 times to the same environment, although the stage says differently. The job is mentioning the TST environment.
Variables to the rescue!

Okay, back in business, let’s add some variables and use parameters in the template to pass the correct values. Let’s start by adding an environment parameter to the template file and use it in the steps:
parameters: environment: '' jobs: - deployment: ${{ parameters.environment }}_deployment environment: ${{ parameters.environment }} strategy: runOnce: deploy: steps: - script: echo Create an Azure Webapp displayName: 'Azure CLI - Create webapp' - script: echo Add App Settings displayName: 'Azure CLI - Add app settings' - script: echo Add IP Whitelisting displayName: 'Azure CLI - Add IP Whitelisting' - script: echo Add hostnames displayName: 'Azure CLI - Add hostnames' - script: echo Deploy artifact displayName: 'Azure CLI - Deploy artifact' - script: echo Start E2E test displayName: 'Powershell - Start E2E test' - script: echo Switch Trafficmanager displayName: 'Azure CLI - Switch Trafficmanager'
Now, we only need to send the correct value to the template in our pipeline:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - template: jobs.yaml parameters: environment: TST - stage: uat_deploy jobs: - template: jobs.yaml parameters: environment: UAT - stage: prd_deploy jobs: - template: jobs.yaml parameters: environment: PRD
And the result:

And it’s working again!
More fun…
Working for a single project is fun but in the real world, we’re often not creating single apps for a single brand but we’re doing multi brand sites, based upon Sitecore for example. These deployments require more complex build pipelines. Let’s have a look at some of these challenges.
Adding more brands to our pipeline
So, let’s assume, we need to adjust the pipeline to work with multiple brands. As always, we’re keeping the principle: build once, deploy everywhere. Our build is some sort of a ‘white label’ artifact where the app settings will be responsible for rendering the correct application.
Let’s assume we’ll need to deploy multiple webapps for some car brands:
- Toyota
- Volkswagen
- Fiat
- Chrysler
- Ford
This means we need to run all the steps for all these brands, that are a lot of steps! And we do not want to use duplicate code. In this case we can use pipeline loops (each function):
parameters: environment: '' brands: [] jobs: - deployment: ${{ parameters.environment }}_deployment environment: ${{ parameters.environment }} strategy: runOnce: deploy: steps: - ${{ each brand in parameters.brands }}: - script: echo Create an Azure Webapp for ${{brand}} displayName: 'Azure CLI - Create webapp for ${{brand}}' - script: echo Add App Settings for ${{brand}} displayName: 'Azure CLI - Add app settings for ${{brand}}' - script: echo Add IP Whitelisting for ${{brand}} displayName: 'Azure CLI - Add IP Whitelisting for ${{brand}}' - script: echo Add hostnames for ${{brand}} displayName: 'Azure CLI - Add hostnames for ${{brand}}' - script: echo Deploy artifact for ${{brand}} displayName: 'Azure CLI - Deploy artifact for ${{brand}}' - script: echo Start E2E test for ${{brand}} displayName: 'Powershell - Start E2E test for ${{brand}}' - script: echo Switch Trafficmanager for ${{brand}} displayName: 'Azure CLI - Switch Trafficmanager for ${{brand}}'
As you can see, I’ve added an array parameter to the parameters section. Then I’ve added a line which iterates all brands:

And our pipeline:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - template: jobs.yaml parameters: environment: TST brands: ["Toyota", "Volkswagen", "Fiat", "Chrysler", "Ford"] - stage: uat_deploy jobs: - template: jobs.yaml parameters: environment: UAT brands: ["Toyota", "Volkswagen", "Fiat", "Chrysler", "Ford"] - stage: prd_deploy jobs: - template: jobs.yaml parameters: environment: PRD brands: ["Toyota", "Volkswagen", "Fiat", "Chrysler", "Ford"]
Yes, there is some duplication. I can’t set the brands as a variable at the moment due to a bug in Azure Devops.
At this moment we’re able to use multiple brands in our pipeline with a minimum amount of code!
But…. we can have even more fun!
Looking at our CD pipeline, we need to create an Azure WebApp. So all brands will create an Azure webapp when the pipeline is triggered. Looking at the command of creating an Azure webapp:
az group create ` --location "West Europe" ` --name "rg-pipeline-demo-tst" az appservice plan create ` --name "rg-pipeline-demo-tst-sp" ` --resource-group "rg-pipeline-demo-tst" az webapp create ` --name "rg-pipeline-demo-tst-[brand]-app" ` --plan "rg-pipeline-demo-tst-sp" ` --resource-group "rg-pipeline-demo-tst" ` --sku B1
These lines will create a new resourcegroup, an app serviceplan and a webapp for each brand, using the B1 sku. Where [brand] will be the actual car brand.
But what if the ‘sku’ is different between the brands, let’s say: Toyota requires a way heavier resources then Ford. Then Toyota requires a P2V2 while Ford requires an S1. To accomplish this in our template, we need some sort of a multidimensional array to pass in to our template:
Our pipeline:
trigger: - master pool: vmImage: 'ubuntu-latest' stages: - stage: Build jobs: - job: Build pool: vmImage: 'Ubuntu-16.04' continueOnError: true steps: - script: echo Restore NuGet Packages displayName: 'NuGet - Restore Packages' - script: echo Build Solution displayName: 'DotNet - Build Solution' - script: echo Publish Solution displayName: 'DotNet - Publish Solution' - script: echo Publish Build Artifacts displayName: 'Publish - Artifcts' - stage: tst_deploy jobs: - template: jobs.yaml parameters: environment: TST brands: - brand: name: 'Toyota' sku: 'P2V2' - brand: name: 'Volkswagen' sku: 'S1' - brand: name: 'Fiat' sku: 'S3' - brand: name: 'Chrysler' sku: 'B1' - brand: name: 'Ford' sku: 'P1V2' - stage: uat_deploy jobs: - template: jobs.yaml parameters: environment: UAT brands: - brand: name: 'Toyota' sku: 'P2V2' - brand: name: 'Volkswagen' sku: 'S1' - brand: name: 'Fiat' sku: 'S3' - brand: name: 'Chrysler' sku: 'B1' - brand: name: 'Ford' sku: 'P1V2' - stage: prd_deploy jobs: - template: jobs.yaml parameters: environment: PRD brands: - brand: name: 'Toyota' sku: 'P2V2' - brand: name: 'Volkswagen' sku: 'S1' - brand: name: 'Fiat' sku: 'S3' - brand: name: 'Chrysler' sku: 'B1' - brand: name: 'Ford' sku: 'P1V2'
And the template:
parameters: environment: '' brands: [] jobs: - deployment: ${{ parameters.environment }}_deployment environment: ${{ parameters.environment }} strategy: runOnce: deploy: steps: - ${{ each brand in parameters.brands }}: - script: echo Create an Azure Webapp for ${{brand.name}} displayName: 'Azure CLI - Create webapp for ${{brand.name}} using sku: ${{brand.sku}}' - script: echo Add App Settings for ${{brand.name}} displayName: 'Azure CLI - Add app settings for ${{brand.name}}' - script: echo Add IP Whitelisting for ${{brand.name}} displayName: 'Azure CLI - Add IP Whitelisting for ${{brand.name}}' - script: echo Add hostnames for ${{brand.name}} displayName: 'Azure CLI - Add hostnames for ${{brand.name}}' - script: echo Deploy artifact for ${{brand.name}} displayName: 'Azure CLI - Deploy artifact for ${{brand.name}}' - script: echo Start E2E test for ${{brand.name}} displayName: 'Powershell - Start E2E test for ${{brand.name}}' - script: echo Switch Trafficmanager for ${{brand.name}} displayName: 'Azure CLI - Switch Trafficmanager for ${{brand.name}}'
The result:

Wow! really impressive!
For now, I think this post is long enough but there is so much more to discover. I’ll write new posts when new cool stuff is there!