In this blog post, I am going to be diving further into deploying Azure Resources with Terraform using Azure DevOps with a CI/CD perspective in mind. I am going to show how you can deploy a develop & production terraform environment consecutively using Azure DevOps pipelines and showing how this is done by using pipeline triggers to allow you to create these environments consecutively starting from a pull request into develop .
Recommended reads prior to this blog post:-
Deploy Terraform Using Azure DevOps
Validating Terraform code during a Pull Request in Azure DevOps
Inspec Azure in Azure DevOps Pipeline
What is CI/CD?

CI stands for Continuous Integration and this part if CI/CD produces the life cycle from designing the infrastructure and coding it locally to building artefacts in a seamless and consistent manner.
CD stands for Continuous Delivery and this is the final part of the CI/CD process; it does what it says, its a continous delivery! It delivers the artifact created during the CI stage and delivers it to the environment; keeping it a consistent process! CD can also be used to execute additional tests to ensure the infrastructure is in its desired state!
CI/CD pipelines are designed for businesses who want to improve their applications and infrastructure in a frequent process while requiring a reliable delivery process
CI/CD + Terraform = Dev/Ops
The environment
For this blog post, we are going to have two environments –
- Develop
- Production
Both these environments will include all the same Azure Resources
Deploying the environment, what are the consideration and requirements?
- Lets step back, even before writing any code – we need to think of a branching strategy. For this; it will be two environments, I will be going with develop/master branches
- Develop Branch:- To deploy the develop environment
- Production branch:- To deploy the production environment
- I want to deploy the environment using Terraform
Time to deploy !
Branching Strategy
The full branching strategy I am not going to cover in this blog post; it would be a different blog all together.
New to development and CI/CD? I do recommend looking at a feature branch strategy:-

A good blog post to go into the Feature Branching strategy further
For this blog, I am going to be using two branches as mentioned:-
- Develop Branch:- To deploy the develop environment
- Production branch:- To deploy the production environment
In theory:- I will be writing any changes or additions to develop and once merged into develop; a branch trigger will then run the develop environment pipeline. Once develop environment has been completed successfully; another pipeline will be ran to run the Production environment. This will be ran using a pipeline trigger
Triggers
Use a trigger to run a pipeline automatically. Azure Pipelines does support quite a number of triggers; I do recommend you reading this post to view more types of triggers and depending what you are looking to do, select the appropriate trigger.
Use triggers to run a pipeline automatically. Azure Pipelines supports many types of triggers. Based on your pipeline’s type, select the appropriate trigger from the list below:
Branch Trigger
Branch Triggers are used to run a branch automatically once a branch has been updated. I will be using them:-
- When a pull request has been approved into develop

Pipeline Trigger
Pipeline triggers are triggered whenever another pipeline has been successfully completed, deploying an app? You could have multiple pipelines with pipeline triggers, starting to get into the “CI/CD” world

Awesome; so far I’ve covered a recommended branching strategy and the Triggers that we will be using; now lets look at Terraform and continue the CI/CD journey!
The Terraform Code
Time to take you through the journey that I want to achieve with the above; continuous triggers that will deploy both develop & production environment after I create and merge code into the Develop Branch
Lets work on the initial Terraform code
For my example to show deployments between develop & production environments, I am going to create a storage account static website; below shows the Production example, with develop showing “Develop” instead of “Production“

Folder Structure for my Terraform Deployment (Throughout this blog post I will be adding more folders/files to this structure)
Azure-Back-to-School
└──develop
└──develop.tfvars
└──index.html
└──production
└──develop.tfvars
└──index.html
└──terraform
└── main.tf
└── variables.tf
Terraform folder:-
main.tf
provider "azurerm" {
# The "feature" block is required for AzureRM provider 2.x.
# If you're using version 1.x, the "features" block is not allowed.
version = "~>2.0"
features {}
}
terraform {
backend "azurerm" {
resource_group_name = "tamopstf"
storage_account_name = "thomastfstate"
container_name = "terraform.tfstate"
}
}
data "azurerm_client_config" "current" {}
#Create Resource Group
resource "azurerm_resource_group" "tamops" {
name = var.resource_group_name
location = var.location
}
#Create Storage account
resource "azurerm_storage_account" "storage_account" {
name = var.storage_account_name
resource_group_name = azurerm_resource_group.tamops.name
location = var.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
static_website {
index_document = "index.html"
}
}
#Add index.html to blob storage
resource "azurerm_storage_blob" "example" {
name = "index.html"
storage_account_name = azurerm_storage_account.storage_account.name
storage_container_name = "$web"
type = "Block"
content_type = "text/html"
source = var.sa_web_source
}
variables.tf
variable "location" {
type = string
description = "Default resources location"
}
variable "storage_account_name" {
type = string
description = "Storage account name"
}
variable "resource_group_name" {
type = string
description = "Storage account name"
}
variable "sa_web_source" {
type = string
description = "Source Index Web Page Location"
}
index.html (shows both Development and Production)
<h1> AzureBack2School Development </h1>
<h1> AzureBack2School Production </h1>
.tfvars
Notice both Develop & Production have reference to .tfvars? Here is a good summary of .tfvars and its usage, taken from here:-
This tells Terraform that this module accepts an input variable called example
. Stating this makes it valid to use var.example
elsewhere in the module to access the value of the variable.
There are several different ways to assign a value to this input variable:
- Include
-var
options on theterraform plan
orterraform apply
command line. - Include
-var-file
options to select one or more.tfvars
files to set values for many variables at once. - Create a
terraform.tfvars
file, or files named.auto.tfvars
, which are treated the same as-var-file
arguments but are loaded automatically. - For a child module, include an expression to assign to the variable inside the calling
module
block.
A variable can optionally be declared with a default value, which makes it optional. Variable defaults are used for situations where there’s a good default behavior that would work well for most uses of the module/configuration, while still allowing that behavior to be overridden in exceptional cases.
The various means for assigning variable values are for dealing with differences. What that means will depend on exactly how you are using Terraform, but for example if you are using the same configuration multiple times to deploy different “copies” of the same infrastructure (environments, etc) then you might choose to have a different .tfvars
file for each of these copies.
Because terraform.tfvars
and .auto.tfvars
are automatically loaded without any additional options, they behave similarly to defaults, but the intent of these is different. When running Terraform in automation, some users have their automation generate a terraform.tfvars
file or .auto.tfvars
just before running Terraform in order to pass in values the automation knows, such as what environment the automation is running for, etc.
The difference between the automatically-loaded .tfvars
files and variable defaults is more clear when dealing with child modules. .tfvars
files (and -var
, -var-file
options) only apply to the root module variables, while variable defaults apply when that module is used as a child module too, meaning that variables with defaults can be omitted in module
blocks.
develop.tfvars
location = "ukwest"
storage_account_name = "tamopsdevelopukw"
resource_group_name = "tamops-develop"
sa_web_source = "../develop/index.html"
production.tfvars
location = "uksouth"
storage_account_name = "tamopsproductionuks"
resource_group_name = "tamops-production"
sa_web_source = "../production/index.html"
Terraform setup is now complete!
Azure DevOps Pipeline(s)
Running the code to build the environment will be ran in an Azure DevOps Pipeline(s).
This will consist of two pipelines:-
- Develop-Pipeline.yaml:- To deploy the Develop environment
- Production-Pipeline.yaml:- To deploy the Production environment
Folder update!
Azure-Back-to-School
└──develop
└──develop.tfvars
└──index.html
└──pipelines
└──Develop-Pipeline.yaml
└──Production-Pipeline.yaml
└──production
└──develop.tfvars
└──index.html
└──terraform
└── main.tf
└── variables.tf
Develop-Pipeline.yaml
name: $(BuildDefinitionName)_$(date:yyyyMMdd)$(rev:.r)
variables:
- group: azureback2school-develop
# Only run against develop
trigger:
batch: true # batch changes if true (the default); start a new build for every push if false
branches:
include:
- develop
# Don't run against PRs
pr: none
stages :
- stage: validate
jobs:
- job: validate
continueOnError: false
steps:
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'validate'
inputs:
provider: 'azurerm'
command: 'validate'
- stage: plan
dependsOn: [validate]
condition: succeeded('validate')
jobs:
- job: terraform_plan_develop
steps:
- checkout: self
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'plan'
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '-input=false -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- stage: apply
dependsOn: [plan]
condition: succeeded('plan')
jobs:
- job: terraform_apply_develop
steps:
- checkout: self
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'plan'
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '-input=false -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'apply'
inputs:
provider: 'azurerm'
command: 'apply'
commandOptions: '-input=false -auto-approve -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
What will the develop pipeline do?

Production-Pipeline.yaml
name: $(BuildDefinitionName)_$(date:yyyyMMdd)$(rev:.r)
variables:
- group: azureback2school-production
resources:
pipelines:
- pipeline: Azure-Back-to-School-Production
source: Azure-Back-to-School-Develop
trigger:
branches:
include:
- master
# Don't run against PRs
pr: none
stages :
- stage: validate
jobs:
- job: validate
continueOnError: false
steps:
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'validate'
inputs:
provider: 'azurerm'
command: 'validate'
- stage: plan
dependsOn: [validate]
condition: succeeded('validate')
jobs:
- job: terraform_apply_production
steps:
- checkout: self
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'plan'
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '-input=false -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- stage: apply
dependsOn: [plan]
condition: succeeded('plan')
jobs:
- job: terraform_plan_production
steps:
- checkout: self
- task: TerraformInstaller@0
displayName: 'install'
inputs:
terraformVersion: '0.13.3'
- task: TerraformTaskV1@0
displayName: 'init'
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: 'tamopstf'
backendAzureRmResourceGroupName: 'tamopstf'
backendAzureRmStorageAccountName: 'thomastfstate'
backendAzureRmContainerName: 'tamopsazurebacktoschool'
backendAzureRmKey: 'terraform.tfstate'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'plan'
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '-input=false -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
- task: TerraformTaskV1@0
displayName: 'apply'
inputs:
provider: 'azurerm'
command: 'apply'
commandOptions: '-input=false -auto-approve -var-file="../$(Environment)/$(Environment).tfvars"'
environmentServiceNameAzureRM: 'tamopstf'
workingDirectory: '$(System.DefaultWorkingDirectory)/terraform/'
What will the production pipeline do?

Sounds great, although I want to test the website URL before
The pipeline breakdown
Both pipelines have been created similarly
- Both use variable groups, read more here about variable groups and their usage (I have one variable in each, which is environment = develop or production, depending on the environment)
- Next, notice each have reference to a trigger as I described at the start of this blog?
- Both for now, will not run if have a pr has been created
- Three stages, terraform related to validate, plan , deploy
Running the Pipeline
Where to store the Terraform state file?
When deploying Terraform there is a requirement that it must store a state file; this file is used by Terraform to map Azure Resources to your configuration that you want to deploy, keeps track of meta data and can also assist with improving performance for larger Azure Resource deployments.
In these pipelines, I want to store the state file remotely in Azure; I will be storing my state file in a Storage Account container called:- tamopsazurebacktoschool
Lets deploy the required storage container called tamopsazurebacktoschool in Storage Account thomastfstate inside Resource Group tamopstf
Terraform must store state about your managed infrastructure and configuration. This state is used by Terraform to map real world resources to your configuration, keep track of metadata, and to improve performance for large infrastructures.
#Create Resource Group
New-AzureRmResourceGroup -Name "tamopstf" -Location "eastus2"
#Create Storage Account
New-AzureRmStorageAccount -ResourceGroupName "tamopstf" -AccountName "thomastfstate" -Location eastus2 -SkuName Standard_LRS
#Create Storage Container
New-AzureRmStorageContainer -ResourceGroupName "tamopstf" -AccountName "thomastfstate" -ContainerName "tamopsazurebacktoschool"
See in this blog, how to setup a pipeline
I have two pipelines setup:-

Ensure you have two branches; master will be created by default; now create an additional branch called “develop”

Now create a pull request for develop, in my example – I have added to both index.html
develop/index.html
<h1> AzureBack2School Development </h1>
<h1> Pull request test </h1>
production/index.html
<h1> AzureBack2School Production </h1>
<h1> Built with pipeline trigger </h1>
Now to merge this into the develop branch

Once merged; the magic begins to happen!
Review the pipelines and you will notice the development pipeline has been kicked off


Check in Azure Portal

URL Check:-

Now check the Azure DevOps Pipelines again; now that develop pipeline has finished successfully, Production pipeline now begins automatically!



URL check:-

Wrap up
Wow – its been another fun blog post; hopefully you’ve followed it successfully and have a similar output as I have above!
Azure DevOps is super powerful and this is only the tip of the iceberg in relation to taking you on the CI/CD journey!
As I mentioned previously; remember to check out my recommended reads!
Deploy Terraform Using Azure DevOps
Validating Terraform code during a Pull Request in Azure DevOps
Inspec Azure in Azure DevOps Pipeline
Github:-
GitHub repo here for code used above

This article is part of #AzureBacktoSchool. You’ll find other helpful articles and videos in this Azure content collection. New articles are published every day from community members and cloud advocates in the month of September. Thank you Dwayne Natwick for organizing this awesome idea!
1 comment