Deploying Terraform from develop to production consecutively using Azure DevOps

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?

Most popular CI/CD pipelines and tools | by Tony Eneh | FAUN | Medium

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:-

Feature Branching Using Feature Flags
Image Reference:- https://launchdarkly.com/blog/feature-branching-using-feature-flags/

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 the terraform plan or terraform 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!

Check in Azure Portal

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

Alt Text

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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s