Let’s consider a scenario in which you are deploying your infrastructure using a Terraform code (infrastructure-as-code) which is stored in a remote git repository. Now working in an organization you need to make sure that all your deployments are always tracked without an exception, an add-on to that whether your Terraform code is following your security and compliance policies or not. Or maybe what is the monthly cost that you can expect with that infra and whether it lies under your budget or not. You may also want to take note that all your resources are being created in the same region… etc… etc.
Sounds magical right !!! We all know that these concerns are very important when you’re looking for a highly consistent, fully tracked, and automated approach. That’s why in this article we are going to look for a simple step-by-step way to automate and streamline our Terraform code using Azure DevOps (ADO).
Soo… Let’s Get Started !!!
First of all, we need to know what is Terraform & Azure DevOps.
Talking About Terraform: HashiCorp Terraform is an infrastructure as code tool that lets you define both cloud and on-prem resources in human-readable configuration files that you can version, reuse, and share. You can then use a consistent workflow to provision and manage all of your infrastructure throughout its lifecycle. Terraform can manage low-level components like compute, storage, and networking resources, as well as high-level components like DNS entries and SaaS features.

If you want to learn more about terraform you can click here.
Talking about Azure DevOps: Azure DevOps provides developer services for allowing teams to plan work, collaborate on code development, and build and deploy applications. Azure DevOps supports a collaborative culture and set of processes that bring together developers, project managers, and contributors to develop software. It allows organizations to create and improve products at a faster pace than they can with traditional software development approaches.

If you want to learn more about Azure DevOps click here.
Azure Pipeline For Terraform
Okay !!! I know that’s a lot of information. Now let’s get back to the point… the question we all started with, “How the hell are we going to achieve all this and that too in a single pipeline?” Well, the answer is very simple, by using different tools dedicated to a particular task.
This is the broad architecture of the pipeline that we are going to create.
Pre-requisites:
No matter whether we are deploying our infrastructure into Azure Cloud Services or Amazon Web Services (AWS). All we need are the following checklist:
- Active Cloud Service (Azure/AWS)
- Azure DevOps Account
- Terraform Code to deploy
- A Linux machine (VM or EC2) for agent pool
- Docker
- Storage Account (Azure Blob Container or AWS S3)
Tools used:
- TFsec
TFsec is a static analysis security scanner for your Terraform code.
TFsec takes a developer-first approach to scan your Terraform templates; using static analysis and deep integration with the official HCL parser ensures that security issues can be detected before your infrastructure changes take effect.
- TFlint
TFlint is a framework and each feature is provided by plugins, the key features are as follows:
- Find possible errors (like illegal instance types) for Major Cloud providers (AWS/Azure/GCP).
- Warn about deprecated syntax and unused declarations.
- Enforce best practices and naming conventions.
- InfraCost
Infracost shows cloud cost estimates for Terraform. It lets DevOps, SRE, and engineers see a cost breakdown and understand costs before making changes, either in the terminal or in pull requests. It can also show us the difference between our present state and desired state.
Rest all the requirements we can fulfill within our pipeline itself
Build Pipeline steps
Assuming that we have already configured an agent in the agent pool which is going to assist us in executing all the commands to achieve our pipeline goals. Also, try importing your terraform code into your Azure Repos as it’ll benefit you in a very unique way that we’ll find out further in this article in the bonus section.
If you what to know how to configure an agent you can click here
You can click here to follow the steps to import a repo.
Part 1: Installing Dependencies

All we need to do in this part is to download and install all the dependencies required in your pipeline.
Here we will use the docker images for different tasks, eg:
- Terraform security compliance: Tfsec
- Terraform Linting: Tflint
- Infrastructure Cost Estimation & Cost Difference: Infracost
Alongside you can try this YAML format of the pipeline.
- task: Bash@3 displayName: Install Docker enabled: False inputs: targetType: inline script: >- sudo apt update sudo apt install apt-transport-https ca-certificates curl software- properties-common -y curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable" sudo apt update apt-cache policy docker-ce sudo apt install docker-ce -y sudo systemctl start docker - task: Bash@3 displayName: Install Azure CLI enabled: False inputs: targetType: inline script: "sudo apt-get update \nsudo apt-get install ca-certificates curl apt-transport-https lsb-release gnupg -y\ncurl -sL https://packages.microsoft.com/keys/microsoft.asc |\n gpg --dearmor |\n sudo tee /etc/apt/trusted.gpg.d/microsoft.gpg > /dev/null\nAZ_REPO=$(lsb_release -cs)\necho \"deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $AZ_REPO main\" |\n sudo tee /etc/apt/sources.list.d/azure-cli.list\nsudo apt-get update\nsudo apt-get install azure-cli -y\nsudo apt install unzip -y" - task: TerraformInstaller@0 displayName: Install Terraform 1.1.8 inputs: terraformVersion: 1.1.8 - task: Bash@3 displayName: Pulling Required Docker Images enabled: False inputs: targetType: inline script: > # TFSEC sudo docker pull tfsec/tfsec:v1.13.2-arm64v8 # TFLINT sudo docker pull ghcr.io/terraform-linters/tflint:v0.35.0 # InfraCost sudo docker pull infracost/infracost:0.
For more detailed info click here.
Part 2: Terraform Initializing & Planning
This is one of the most simple and well-known step of the whole pipeline

In this part, we’ll initialize, validate and plan our terraform code and store the output into a file using -out=plan.out
flag.
- task: TerraformTaskV2@2 displayName: 'Terraform : INIT' inputs: backendServiceArm: a575**********************4bcc71 backendAzureRmResourceGroupName: ADOagent_rg backendAzureRmStorageAccountName: terrastoragestatesreport backendAzureRmContainerName: statefile backendAzureRmKey: terraform.tfstate - task: TerraformTaskV2@2 displayName: 'Terraform : VALIDATE' inputs: command: validate - task: TerraformTaskV2@2 displayName: 'Terraform : PLAN ( For Cost Optimization )' inputs: command: plan commandOptions: -lock=false -out=plan.out environmentServiceNameAzureRM: a575**********************4bcc71
For more detailed steps click here.
Part 3: Heart of Our Pipeline: Terraform Security Compliance, Linting, Cost Estimation & Cost Difference

Now using the above-mentioned tools we can achieve these tasks Terraform Security Compliance, Linting, Cost Estimation & Cost Difference in a bash task.
Though you don’t need custom settings while linting or cost calculating but you can definitely use a custom checks file for Terraform Compliance step.
A custom check file of Tfsec will look like this:
--- checks: - code: CUS001 description: Custom check to ensure the Name tag is applied to Resources Group Module impact: By not having Name Tag we can't keep track of our Resources requiredTypes: - module requiredLabels: - resource_group severity: MEDIUM matchSpec: name: tag_map action: contains value: Name errorMessage: The required Name tag was missing - code: CUS002 description: Custom check to ensure the Name tag is applied to Resources Group Module impact: By not having Environment Tag we can't keep track of our Resources requiredTypes: - module requiredLabels: - resource_group severity: CRITICAL matchSpec: name: tag_map action: contains value: Environment errorMessage: The required Environment tag was missing - code: CUS003 description: Custom check to ensure Resource Group is going to be created in Australia East region impact: By not having our resource in Australia East we might get some latency requiredTypes: - module requiredLabels: - resource_group severity: MEDIUM matchSpec: name: resource_group_location action: equals value: "Australia East" errorMessage: The required "Australia East" location was missing
YAML Pipeline for the task:
- task: Bash@3 displayName: 'Terraform : TFSEC' condition: succeededOrFailed() enabled: False inputs: targetType: inline script: sudo docker run --rm -v "$(pwd):/src" aquasec/tfsec /src --tfvars-file /src/terraform.tfvars - task: Bash@3 displayName: 'Terraform : Linting' condition: succeededOrFailed() enabled: False inputs: targetType: inline script: > sudo docker run --rm -v $(pwd):/data -t ghcr.io/terraform-linters/tflint - task: Bash@3 displayName: 'Terraform : Cost Estimation' condition: succeededOrFailed() enabled: False inputs: targetType: inline script: "terraform show -json plan.out > plan.json\n\nsudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v \"$(pwd):/src\" infracost/infracost breakdown --path /src/plan.json --show-skipped \n" - task: Bash@3 displayName: 'Terraform : Cost Difference' condition: succeededOrFailed() enabled: False inputs: targetType: inline script: > sudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v "$(pwd):/src" infracost/infracost diff --path /src/plan.json --show-skipped > $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber)
Here we also need to generate an API Key for the Infracost app, to learn how to do it you can click here.
Part 4: Generating & Uploading Logs
The need for this step is to store our logs generated in previous steps into a storage account and make sure that we are not going to lose them.

- task: Bash@3 displayName: Generating Logs condition: succeededOrFailed() enabled: False inputs: targetType: inline script: > # Creating Logs Of Tfsec sudo docker run --rm -v "$(pwd):/src" aquasec/tfsec /src --tfvars-file /src/terraform.tfvars > $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) cat $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) #Creating Logs Of Cost Estimation sudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v "$(pwd):/src" infracost/infracost breakdown --path /src/plan.json --show-skipped --format html > $(Build.DefinitionName)-cost-$(Build.BuildNumber).html cat $(Build.DefinitionName)-cost-$(Build.BuildNumber).html #Creating Logs Of Cost Diffrence sudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v "$(pwd):/src" infracost/infracost diff --path /src/plan.json --show-skipped > $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) cat $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) - task: AzureCLI@2 displayName: 'Upload tfsec file ' condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: > az storage blob upload --file $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) --name $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s - task: AzureCLI@2 displayName: 'Upload cost file ' condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: az storage blob upload --file $(Build.DefinitionName)-cost-$(Build.BuildNumber).html --name $(Build.DefinitionName)-cost-$(Build.BuildNumber).html --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s - task: AzureCLI@2 displayName: Upload cost diff file condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: az storage blob upload --file $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) --name $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s
For a detailed description of this part, you can click here.
Part 5: Generating Artifacts
In this step, we’ll generate two artifacts, one named Release which will trigger the Release pipeline, and the other one named Repot which will publish the reports for our output files of Compliance, Cost Estimation & Cost Difference.

- task: Bash@3 displayName: Generating Logs condition: succeededOrFailed() enabled: False inputs: targetType: inline script: > # Creating Logs Of Tfsec sudo docker run --rm -v "$(pwd):/src" aquasec/tfsec /src --tfvars-file /src/terraform.tfvars > $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) cat $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) #Creating Logs Of Cost Estimation sudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v "$(pwd):/src" infracost/infracost breakdown --path /src/plan.json --show-skipped --format html > $(Build.DefinitionName)-cost-$(Build.BuildNumber).html cat $(Build.DefinitionName)-cost-$(Build.BuildNumber).html #Creating Logs Of Cost Diffrence sudo docker run --rm -e INFRACOST_API_KEY=$(INFRACOST_API_KEY) -v "$(pwd):/src" infracost/infracost diff --path /src/plan.json --show-skipped > $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) cat $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) - task: AzureCLI@2 displayName: 'Upload tfsec file ' condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: > az storage blob upload --file $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) --name $(Build.DefinitionName)-tfsec-$(Build.BuildNumber) --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s - task: AzureCLI@2 displayName: 'Upload cost file ' condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: az storage blob upload --file $(Build.DefinitionName)-cost-$(Build.BuildNumber).html --name $(Build.DefinitionName)-cost-$(Build.BuildNumber).html --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s - task: AzureCLI@2 displayName: Upload cost diff file condition: succeededOrFailed() enabled: False inputs: connectedServiceNameARM: a575*********************c71 scriptType: bash scriptLocation: inlineScript inlineScript: az storage blob upload --file $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) --name $(Build.DefinitionName)-cost-diff-$(Build.BuildNumber) --account-name terrastoragestatesreport --container-name report cwd: $(Pipeline.Workspace)/s
For a detailed description of this part, you can click here.
Release Pipeline Steps
Our Build Step will generate an artifact named Release which will contain all the terraform files required to apply our desired configuration. Also after all the checks and validations, we’ve found out that there is no error in our code and it is exactly what we have desired, so we will allow the Continuous Deployment for our Release Pipeline.

Part 1: Auto Approval For Terraform Apply

In this step, we will simply apply our terraform code and keep this stage as Auto-Approved.
steps: - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0 displayName: 'Install Terraform 1.1.8' inputs: terraformVersion: 1.1.8 - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2 displayName: 'Terraform : INIT' inputs: workingDirectory: '$(System.DefaultWorkingDirectory)/_IAC-CI/release' backendServiceArm: 'Opstree-PoCs (4c9***************************f3c)' backendAzureRmResourceGroupName: 'ADOagent_rg' backendAzureRmStorageAccountName: terrastoragestatesreport backendAzureRmContainerName: statefile backendAzureRmKey: terraform.tfstate - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2 displayName: 'Terraform : APPLY' inputs: command: apply workingDirectory: '$(System.DefaultWorkingDirectory)/_IAC-CI/release' commandOptions: '--auto-approve' environmentServiceNameAzureRM: 'Opstree-PoCs (4c9***************************f3c)'
To know more about this stage click here.
Part 2: Manual Approval For Terraform Destroy
Here in our Terraform Destroy pipeline, we will configure it for manual approval as it is going to be very sensitive & secured. An unnecessarily or unwanted destroyed infrastructure can cause a huge loss of time, money, resources, backup & data. So we’ll keep it highly secured and limit the access to reliable users only.

steps: - task: ms-devlabs.custom-terraform-tasks.custom-terraform-installer-task.TerraformInstaller@0 displayName: 'Install Terraform 1.1.8' inputs: terraformVersion: 1.1.8 - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2 displayName: 'Terraform : INIT' inputs: workingDirectory: '$(System.DefaultWorkingDirectory)/_IAC-CI/release' backendServiceArm: 'Opstree-PoCs (4c9***************************f3c)' backendAzureRmResourceGroupName: 'ADOagent_rg' backendAzureRmStorageAccountName: terrastoragestatesreport backendAzureRmContainerName: statefile backendAzureRmKey: terraform.tfstate - task: ms-devlabs.custom-terraform-tasks.custom-terraform-release-task.TerraformTaskV2@2 displayName: 'Terraform : APPLY' inputs: command: apply workingDirectory: '$(System.DefaultWorkingDirectory)/_IAC-CI/release' commandOptions: '--auto-approve' environmentServiceNameAzureRM: 'Opstree-PoCs (4c9***************************f3c)'
To know more about this stage click here.
Bonus: Branching Policy
We need branching policies in order to save our main/master branch from unwanted commits. To make any changes to our main/master branch we need to Merge a branch, after committing changes, into the main/master branch using Pull Request. Also, remember that our Terraform-CD or Release branch will only be triggered if Terraform-CI or Build branch was triggered from the main branch.

To learn how to configure the branching policy in Azure DevOps click here.
Conclusion
So in this article, we get to learn about how to automate and streamline our terraform code deployment using Azure DevOps and use tools like Tfsec for security and compliance, Tflint for linting terraform code and Infracost for Cost Estimation & Cost Difference. Along with that we also learned how to upload our logs into Blob Containers, publish artifacts and make a release pipeline using it which will have different stages for Terraform apply and terraform destroy.
Content References – Reference 1, Reference 2
Image References – Image 1, Image 2
Blog Pundit: Bhupender Rawat and Sandeep Rawat
Opstree is an End to End DevOps solution provider
Connect Us
Atlantis is also a good option to use for terraform, include tfsec or checkov with infracost in atlantis workflow.
LikeLiked by 1 person