Level Up Your Terraform Pipelines: Branch Protection, Auto-Reviews & Security Checks

In last week’s post, I introduced a simple YAML pipeline that can be used within Azure DevOps to deploy your Terraform infrastructure.

Today, I’ll build on that foundation and walk through a set of practical, production-ready improvements that harden the pipeline, protect the main branch, and make Terraform changes safer and easier to review.

Here’s what you’ll learn

  • Branch Policy security – prevent direct commits to main and force the use of pull requests
  • Run an automatic validate and plan pipeline when a pull request is issued
  • Run basic Terraform security checks automatically

Understanding how pipeline parameters get passed to Terraform

In our pipeline, we defined a few parameters for regions and environment (dev/uat/prod) which we’ll use within our Terraform code. In Terraform, we can pass inputs in a few different ways:

  1. During the terraform plan / apply using the -var "environment=prod" flag
  2. During the terraform plan / apply using a .tfvars file with -var-file="xxx.tfvars"
  3. Using environment variables within the OS running the pipeline

When we create parameters, we can simply push them into the Terraform plan/apply command using the -var="environment=${{ parameters.environment }}" syntax.

In Terraform, we simply have to declare a variable:

variable "environment" {
  type        = string
  description = "The environment to deploy to: dev, uat, prod"
  default     = "dev"
}

Using OIDC Federated DevOps Credentials within Terraform

When using ADO OIDC Federated service connections, we are not tied directly to a subscription. We need to specify the workload subscription ID where we will deploy our resources to. We also need to tell Terraform the connection is using OIDC to connect to Azure.

Within our Terraform code we need to create a provider block for the Azure Resource Manager provider, within it, we will pass in the workload subscription ID and tell it we are using OIDC.

provider "azurerm" {
  features {}
  use_oidc        = true
  subscription_id = var.deployment_subscription_id
}

You can either pass in the subscription directly, or create a variable which can be configured through tfvars or variable inputs when running the terraform plan / apply commands.

Git branching strategy

Even if you’re a single developer, it makes sense to set up the Git framework in a way that will protect you but also scale if new team members come on board. I’ve made the mistake before of not setting up Git in a team-friendly way, and it’s caught me out as we onboard new team members.

Here’s the approach I find has worked for me, but find the option that works for you.

Main branch

The critical focus of the main branch is that it should always remain deployable. This means we never work directly in the main branch. We require pull requests with approval steps to commit code to main.

In addition to always being deployable, I really like the idea of having Git tags that we can use as versions. I’ll show you how to automatically create tags when a commit to main is made. Tags allow us to call specific versions of our main branch, so we can introduce version control into our modules and infrastructure—it helps when we need to introduce breaking changes.

Feature branches

Whenever we work on Terraform code, we create a feature branch for the particular feature/bug/update we’re making. When we commit code to the feature branch and push it to Azure DevOps, we want to automatically run a terraform validate and terraform plan. If the plan is successful, we can then create a pull request to update the main branch, which we can then deploy from.

What runs where?

This simple table illustrates what we run against each branch and action:

Branch / Action Terraform Action
Feature Branch Terraform validate / plan
Pull Request Plan (commented back to Pull Request)
Main Apply (only after approval)

What is the pull request?

When we’re working on feature branches and we’re ready to deploy, we want to create a pull request. A pull request initiates the process where we can have approvers validate the code and Terraform plan (automatically submitted with the pull request) before the code gets committed to the main branch.

Why it matters

  • Reviewers and approvers see exactly what is changing, why it’s changing, and what the impact will be
  • Prevents accidental deployments—no “I didn’t realise that would happen” moments
  • No more guessing what has changed and looking through multiple commits and code diffs
  • Makes infrastructure changes auditable and repeatable

How it fits into our existing deployment model

We already have a working Terraform plan stage within our pipeline, and we separated out the apply stage and added an approval stage. What we want to add is a pipeline that’s triggered when a pull request is submitted. The pull request pipeline would run:

  • terraform validate
  • terraform plan
  • Publish the plan output

Our code review team will then review the pull request, validate the Terraform plan, and if approved, the code gets committed to main with an automatic version tag created.

Let’s break this down with a working example.

The pull request pipeline

We need to create a pipeline that gets triggered when a pull request is submitted within Azure DevOps. The pipeline will run the terraform validate and plan commands, and the output will be automatically appended to the pull request, allowing reviewers to visually see the impact of the pull request prior to approval.

One caveat here is that the pull request pipeline can’t accept parameters—it needs to run automatically. Our original pipeline I showed in the previous post accepts parameters for the location and environment. For the purposes of the pull request, we need to default to one environment and one location.

Remember, our code should be reusable across multiple environments and regions, so when doing a plan, it should reflect the correct output. With this in mind, it makes sense to default the pull request pipeline to use the DEV environment and a primary region.

trigger: none

pr:
  branches:
    include:
      - main

pool:
  vmImage: "ubuntu-latest"

variables:
  # Fixed, deterministic inputs for PRs
  environment: dev
  location: centralus
  
  # Replace with your actual resource group, storage account and container name
  management-subscription-id: "xxxx"
  TFSTATE_RESOURCE_GROUP: "xxx"
  TFSTATE_STORAGE_ACCOUNT: "xxx"
  TFSTATE_CONTAINER_NAME: "xxx"

stages:
  - stage: ValidateAndPlan
    displayName: "Terraform PR Validate and Plan"
    jobs:
      - job: TerraformValidateAndPlan
        displayName: "Terraform Validate and Plan (PR)"
        steps:
          - checkout: self
          
          # Generate TFSTATE_KEY (same logic as your deploy pipeline)
          - script: |
              key=$(echo "${SYSTEM_TEAMPROJECT}_$(environment)_$(location).tfstate" \
                | tr '[:upper:]' '[:lower:]' | tr -d ' ')
              echo "Generated TFSTATE_KEY: $key"
              echo "##vso[task.setvariable variable=TFSTATE_KEY]$key"
            displayName: "Set dynamic TFSTATE_KEY"
          
          # Shared terraform setup & init
          - template: templates/terraform-setup.yml
            parameters:
              tfstateKey: $(TFSTATE_KEY)
          
          # Terraform Validate
          - task: AzureCLI@2
            displayName: "Terraform Validate (OIDC)"
            inputs:
              azureSubscription: "Federated-Azure-Connection-$(System.TeamProject)"
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                terraform validate
          
          # Terraform Plan (PR tfvars)
          - task: AzureCLI@2
            displayName: "Terraform Plan (PR)"
            inputs:
              azureSubscription: "Federated-Azure-Connection-$(System.TeamProject)"
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                terraform plan \
                  -var-file=environments/pr/pr.tfvars

I’ve created a new .tfvars file within the repo’s environments folder and put this in a folder called environments/pr with the filename pr.tfvars. Within this, we can pass any specific inputs we need to our Terraform plan when the pull request pipeline runs.

Create the pipeline within your Azure DevOps project and call it pull-request.

Set the branch policies

Branch policies allow us to protect our main branch from inadvertent changes.

Within your Azure DevOps project, select Branches and Branch Policies against the main branch.

Azure DevOps Branch Policies
Azure DevOps Branch Policies

Now turn on branch policies and require a minimum number of reviewers. Select any other options you feel are appropriate within your use case.

Azure DevOps Branch Policies
Azure DevOps Branch Policies

Next up, create a Build Validation Policy and select your pull-request pipeline with an automatic trigger.

Azure DevOps Build Validation Policy
Azure DevOps Build Validation Policy

Important: Remember that these policies only impact the remote repository. Any changes made locally are uncontrolled and can be done. However, if a developer attempts to commit code directly to the main branch, it will fail when they push the changes from local to the remote repository.

Commit Error
Commit Error

Create the pull request

Once the developer is happy with the code in their feature branch and is ready to deploy, they can create a pull request within Azure DevOps.

Under the Repos section, select Pull Requests and New Pull Request. Select the feature branch you want to pull into main, create the title and description, and select Create.

Azure DevOps Pull Request
Azure DevOps Pull Request

Once the pull request is created, you’ll see the pull-request pipeline is automatically triggered with a direct link to the Terraform plan being visible. Approvers can review the request, validate the plan, and approve or reject. If approved, the feature branch gets committed to the main branch.

Azure DevOps Pull Request Approval
Azure DevOps Pull Request Approval

Incorporate automatic version tagging on the main branch

We now have a working model to support working from feature branches and having a streamlined pull request that will show the impacts of merging the feature branch with main. What we need now is a way to protect existing deployments from potential breaking changes introduced in our code. This is especially handy when we have code bases shared across multiple implementations—for example, Terraform modules.

The auto-tagging pipeline

This pipeline has a trigger which is set to run when a commit is made to the main branch.

trigger:
  branches:
    include:
      - master  # or main

pr: none

pool:
  vmImage: "ubuntu-latest"

stages:
  - stage: TagMain
    displayName: "Tag Main"
    jobs:
      - job: AutoTag
        displayName: "Auto-tag main"
        steps:
          - checkout: self
            persistCredentials: true
          
          - script: |
              set -euo pipefail
              
              git config user.name "Azure Pipelines"
              git config user.email "builds@devops.com"
              git fetch --tags --force
              
              latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
                | sort --version-sort | tail -n1)
              
              if [ -z "$latest_tag" ]; then
                next_tag="v0.0.1"
              else
                IFS='.' read -r major minor patch <<< "${latest_tag#v}"
                next_tag="v${major}.${minor}.$((patch + 1))"
              fi
              
              if git rev-parse "$next_tag" >/dev/null 2>&1; then
                echo "Tag $next_tag already exists; nothing to do."
                exit 0
              fi
              
              git tag -a "$next_tag" -m "Automated tag from main merge"
              git -c http.extraheader="AUTHORIZATION: bearer $SYSTEM_ACCESSTOKEN" \
                push origin "$next_tag"
              echo "##vso[task.setvariable variable=TAG_CREATED]$next_tag"
            displayName: "Create next version tag"
            env:
              SYSTEM_ACCESSTOKEN: $(System.AccessToken)

When the pipeline runs, it first sets up some Git credentials as we need to run Git commands. It then runs a bash script to pull a list of all existing tags on the repository and increments the patch value on the tag by 1.

Create the pipeline in the main branch within the DevOps project and call it main-branch-tagging (or something meaningful to you).

Setting up credentials

We need to allow our build service access to the repo and to be able to create tags.

Within the repository settings, edit the security, select your build service, and grant it Allow to Contribute and to Create tag.

Azure DevOps Security
Azure DevOps Security

The outcome

Now when a pull request is approved, it will trigger the main-branch-tagging pipeline. The pipeline runs, checks the main branch for the latest tag, and creates a new tag linked to the latest updates from the pull request, incremented by 1.

Azure DevOps Tags
Azure DevOps Tags

If we need to, we can now refer to a specific version tag in our code by using the format:

source = "git::ssh://git@ssh.dev.azure.com/v3/[devops_org]/[project_name]/[repo_name]?ref=v0.0.2"

Automatic security checks

A great way to add a simple checks of our code is through using an automated tool that is called during our pipeline runs.

There are several tools available for static analysis of Terraform code. In this example I’m using Checkov, as it provides a broad set of built-in policies, good Azure coverage, and integrates cleanly into CI pipelines.

Adding

          # checkov – static security analysis
          - script: |
              python3 -m pip install --upgrade pip
              pip install checkov
              checkov -d . --soft-fail
            displayName: "Run Checkov"

Before the Terraform plan commands in our pull request pipeline works well. I add the -soft-fail parameter as the tool can catch false positives which we want to know about but not necessarily stop running for. This makes the checks visible to the approvers and allows them to decide if they should allow the merge to main to continue.

Conclusion

Bringing automation and enforcing pull requests to protect our main branch helps ensure it is always deployable. It protects our code, creates automatic documentation, and provides version control. Adding the optional tagging pipeline ensures processes are followed and protects existing and future deployments from breaking changes introduced into our modules.

At this point, we’re no longer “running Terraform from a pipeline” — we’re operating an infrastructure delivery system

In next week’s post, we will work on adding in steps to detect and alert on configuration drift, catching problems early on allows us to respond and remediate quickly.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment