All writing
·8 min read

A Terraform Pipeline on Azure DevOps That Won't Wreck Production

TerraformAzureCI/CD

Running Terraform from a laptop works right up until two people do it at once. This is the pipeline shape I use for Azure: remote state, a plan stage anyone can read, and an apply stage that needs a human.

State goes in a storage account, first

Before any pipeline exists, create the backend by hand — a resource group, a storage account with versioning enabled, and a container. This is the one piece that cannot be managed by the Terraform it stores state for.

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateprod"
    container_name       = "tfstate"
    key                  = "platform.tfstate"
  }
}

Blob versioning is not optional. It is the only thing standing between a corrupted state file and rebuilding your environment by hand.

Authenticate with workload identity federation

Do not put a service principal secret in a variable group. Azure DevOps service connections support workload identity federation — the pipeline exchanges a short-lived token, and there is no credential to rotate or leak.

Split plan from apply

The plan stage runs on every pull request and publishes the plan file as an artifact. The apply stage consumes that exact artifact — not a fresh plan — so what gets approved is what gets applied.

stages:
  - stage: Plan
    jobs:
      - job: plan
        steps:
          - script: terraform init
          - script: terraform plan -out=tfplan
          - publish: tfplan
            artifact: tfplan

  - stage: Apply
    dependsOn: Plan
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: apply
        environment: production   # approval gate lives here
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: tfplan
                - script: terraform apply -auto-approve tfplan

Put the approval on the environment, not the stage

Azure DevOps environments carry approval checks and a deployment history. Using a deployment job against a named environment gives you a record of who approved which change to production — which is the first thing anyone asks for after an incident.

Guardrails worth adding early

  • terraform fmt -check and terraform validate in the plan stage — cheap, catches noise before review.
  • A policy scanner (Checkov or tfsec) as a non-blocking step at first, blocking once the backlog is clear.
  • State locking is automatic with the azurerm backend; do not disable it to unstick a run. Find out who holds the lock.
  • Separate state files per environment. One giant state file is a single blast radius.

None of this is exotic. It is the difference between infrastructure code you can hand to a team and infrastructure code only you can run.

Building something and want a second pair of eyes on the deployment side?

Get in touch