Terraform Lesson 46 of 89

Terraform CI/CD with Azure DevOps: YAML Pipelines, Service Connections, plan/apply Stages & Approval Gates

The fastest way to lose an audit — and eventually a production environment — is a Terraform apply run from an engineer’s laptop. Nobody saw the plan, the cloud credentials were long-lived and over-scoped, the state lock was optional, and there is no record of who changed what or whether anyone approved it. This lesson replaces that with a pipeline: Azure DevOps triggers on a pull request, validates and plans the change, publishes the exact binary plan as an artifact, pauses on an Environment approval gate until a named human clicks approve, and then applies that saved plan — authenticating keylessly to Azure through a workload-identity (OIDC) service connection, with state living in Azure Storage under a blob lease lock.

By the end you will have a complete, copy-pasteable azure-pipelines.yml, the backend/provider configuration it drives, and the click-by-click steps to create the service connection, the variable group, and the Environment with its approval check. This is the Azure DevOps counterpart to the GitHub Actions pipeline taught in A Production Terraform CI/CD Pipeline on GitHub Actions with OIDC — the mechanics (keyless auth, saved plan, gated apply, drift) are identical; only the platform primitives differ.

What you’ll build

You will build a real delivery pipeline for a small Azure footprint — a resource group with a virtual network and a storage account — managed entirely through Terraform and Azure DevOps. The point is not the resources; it is the delivery machine around them. A developer opens a pull request against main. A branch policy requires the pipeline’s Validate + Plan stages to pass and posts the plan into the PR. On merge, the pipeline runs again, saves tfplan as a pipeline artifact, and stops at an Environment called prod that carries a manual-approval check. A reviewer approves; the Apply stage downloads the saved plan and runs terraform apply tfplan. No secret was ever stored — the service connection uses workload identity federation, so the agent exchanges a short-lived Azure DevOps token for an Entra ID access token at run time. State is a blob in Azure Storage, locked by a lease for the life of each run.

Here is the whole machine end to end. Read it left to right: the pull request feeds the pipeline, the plan becomes an artifact, the approval gate stands between plan and mutation, and only then does anything change in Azure.

Pipeline-driven Terraform on Azure DevOps: an Azure Repos pull request triggers Validate and Plan stages that publish a tfplan artifact, an Environment approval gate pauses the pipeline, then an Apply stage replays the saved plan against Azure — authenticated keylessly through an OIDC workload-identity service connection with remote state in an Azure Storage blob under a lease lock

The six numbered decisions on the diagram are the spine of this lesson: PR validation, the plan artifact as an immutable contract, the Environment approval gate, keyless OIDC auth, remote state under a lease lock, and scheduled drift detection. We will build each one with real YAML and HCL, then run the whole thing and tear it down.

Why Terraform through a pipeline rather than the portal, the CLI, or ARM/Bicep? Because the pipeline is the only place all four of these hold at once: a reviewed plan, a keyless identity, an enforced approval, and an immutable audit trail. The portal has no plan and no code review. A laptop CLI apply has no gate and usually a long-lived secret. ARM/Bicep can be pipelined too, but Terraform’s saved binary plan gives you a guarantee those tools cannot: the apply is provably the artifact a human reviewed, or it errors. This lesson assumes you already know core Terraform — HCL, providers, state, modules — and the azurerm provider basics (provider authentication and the Azure Storage remote backend are the getting-started foundation this builds on). Here we wrap that knowledge in a delivery system.

Why pipeline-driven Terraform (and never apply from a laptop)

Before the YAML, be clear on what problem the pipeline solves, because every design choice below traces back to one of these failure modes. A laptop apply and a pipeline apply are not two styles of the same thing; they have different risk profiles on every axis that an auditor, a security team, or a 3 a.m. incident cares about.

Concern Laptop terraform apply Pipeline-driven apply What the pipeline gives you
Who can change prod Anyone with the state + creds Only an approved run of a protected branch Enforced least-privilege via Environment approvals
Credentials Long-lived SPN secret in ~/.azure or env Short-lived OIDC token, no stored secret Nothing to leak, nothing to rotate
Was the plan reviewed Rarely; apply re-plans silently Saved tfplan reviewed on the PR, replayed on apply Plan/apply integrity
Audit trail Shell history, if that Run record: who, what, when, which commit, who approved Compliant, queryable history
State locking Optional; easy to skip Backend lease acquired every run No concurrent-write corruption
Drift Discovered at the next apply, painfully Caught nightly by a scheduled plan Early, loud, ticketed
Blast radius of a mistake Immediate Gated behind validate → plan → approve Multiple humans/machines between edit and mutation
Reproducibility “Works on my machine” tfenv/version Pinned agent image + pinned Terraform version Deterministic runs

The through-line: a pipeline converts trust in a person into trust in a process. That is exactly what audits, change-advisory boards, and SOC 2 controls require, and it is why every serious platform team runs Terraform this way.

Azure DevOps building blocks for Terraform

Azure DevOps (ADO) is a suite; you will use five of its services to run Terraform. If you have used GitHub Actions, the mapping is close but the names differ — the biggest conceptual difference is that ADO makes service connections and Environments first-class, governed objects rather than repository settings.

ADO building block What it is Its role in the Terraform pipeline
Azure Repos Git repository Holds the .tf code and azure-pipelines.yml; PRs trigger the pipeline
Azure Pipelines (YAML) CI/CD engine defined in azure-pipelines.yml Runs stages: Validate, Plan, Apply; orchestrates the whole flow
Service connection A stored, governed identity to Azure (SPN or workload identity) Authenticates terraform and the backend to the target subscription
Variable groups Named sets of pipeline variables, optionally Key Vault-linked Supplies non-secret config and (via Key Vault) secrets at run time
Environments A deployment target with approvals & checks The prod gate: a deployment job pauses here until approved
Agents / pools The compute that runs the jobs (Microsoft-hosted or self-hosted) Where terraform actually executes; determines network reach & tools
Branch policies Rules on a branch (e.g., required build validation) Runs plan on PRs and blocks merge until the pipeline passes
Pipeline artifacts Files published by one stage, consumed by another Carries tfplan from Plan to Apply, unchanged

Two of these — the service connection and the Environment — are where the security of the whole system lives, so they get their own sections. Everything else is plumbing you configure once.

The YAML pipeline is structured as stages → jobs → steps. A stage is a major phase (Validate, Plan, Apply). A job runs on one agent; a special deployment job targets an Environment and is what unlocks approvals. A step is a single task (AzureCLI@2, PublishPipelineArtifact@1) or an inline script. Keep that hierarchy in mind — the approval gate is a property of a deployment job’s Environment, not of a step.

Service connections & authentication: SPN secret vs workload identity federation (OIDC)

A service connection of type Azure Resource Manager (ARM) is how the pipeline proves who it is to Azure. There are two authentication schemes, and choosing the modern one is the single highest-leverage security decision in this lesson.

The legacy scheme is a service principal with a client secret (or certificate): you register an Entra ID app, generate a secret, and the service connection stores it. It works, but the secret is a long-lived bearer credential that must be rotated, can be exfiltrated from logs, and is exactly the kind of thing that ends up in an incident report.

The modern scheme is workload identity federation (WIF), Azure DevOps’s implementation of OIDC. The service connection has no secret at all. At run time, the pipeline presents a short-lived token issued by Azure DevOps; Entra ID trusts that issuer for a specific federation subject and hands back an access token. Nothing is stored, nothing is rotated, and there is no secret to leak.

Dimension SPN + client secret Workload identity federation (OIDC)
Stored credential Yes — a client secret/cert None
Rotation Manual/scheduled; expiry outages common Not applicable
Leak blast radius Full SPN validity window A single short-lived token, useless after minutes
Setup App registration + secret + role assignment App registration + federated credential + role assignment
Terraform provider flag use_oidc off; ARM_CLIENT_SECRET set use_oidc = true; ARM_OIDC_TOKEN set
Trust boundary “Whoever holds the secret” “This exact org/project/service-connection” (the subject)
Recommendation Legacy only; migrate away Default — use this

WIF works by pinning a federation subject to your exact service connection. When you create the connection with the automatic workflow, ADO registers the app and the federated credential for you. If you configure it manually, you create the federated credential yourself with these three values:

Federated-credential field Value for Azure DevOps WIF Notes
Issuer https://vstoken.dev.azure.com/<organizationId> The ADO token service for your org
Subject sc://<org>/<project>/<serviceConnectionName> Pins trust to one service connection
Audience api://AzureADTokenExchange Fixed audience for the token exchange

Here is the manual path with the CLI, for when you want the app registration in code or need to reuse an existing app. Create the app, assign it least-privilege RBAC scoped to the resource group it manages (not the whole subscription), then add the federated credential:

# 1. App registration (the identity the pipeline will assume)
appId=$(az ad app create --display-name "sp-tf-prod" --query appId -o tsv)
az ad sp create --id "$appId"

# 2. Least-privilege RBAC — Contributor on the target RG only, plus
#    Storage Blob Data Contributor on the state account (for use_azuread_auth).
subId=$(az account show --query id -o tsv)
az role assignment create --assignee "$appId" --role "Contributor" \
  --scope "/subscriptions/$subId/resourceGroups/rg-app-prod"
az role assignment create --assignee "$appId" --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/$subId/resourceGroups/rg-tfstate-prod/providers/Microsoft.Storage/storageAccounts/sttfstateprod01"

# 3. Federated credential — the keyless trust to this one service connection.
az ad app federated-credential create --id "$appId" --parameters '{
  "name": "ado-sc-tf-prod",
  "issuer": "https://vstoken.dev.azure.com/00000000-org-guid",
  "subject": "sc://my-org/platform-infra/sc-tf-prod",
  "audiences": ["api://AzureADTokenExchange"]
}'

Then, in Project settings → Service connections → New service connection → Azure Resource Manager → Workload Identity federation (manual), paste the appId, tenant, subscription, and the same subject. (The automatic option skips steps 1 and 3 — ADO creates the app and federated credential — but manual is what you want when RBAC and app lifecycle must be governed in Terraform/scripts.)

Service-connection creation method When to use Trade-off
ARM → WIF automatic Fastest; you own the subscription ADO auto-creates the app registration; less control over its lifecycle
ARM → WIF manual App reg + RBAC governed in code/IaC You create the app + federated credential yourself (shown above)
ARM → secret (az devops CLI) Legacy, or platforms without WIF Stores a secret; must rotate; avoid for new work
Per-environment connections Always, for isolation sc-tf-dev, sc-tf-prod — a dev run can never touch prod

Create one service connection per environment, each with its own app registration scoped to that environment’s resource groups. That way the dev pipeline physically cannot authenticate to prod, and the WIF subject (sc://.../sc-tf-prod) is the security boundary.

Remote state in Azure Storage (the backend the pipeline shares)

Every pipeline run must read and write the same state, safely. On Azure that means the azurerm backend: a blob in a storage-account container, locked by a blob lease for the duration of each plan/apply. The lease is what stops two concurrent runs from corrupting state — the equivalent of DynamoDB locking on the AWS S3 backend.

Provision the state account once, out-of-band (it must exist before any terraform init). A dedicated resource group keeps it separate from the workloads it tracks:

az group create -n rg-tfstate-prod -l centralindia
az storage account create -n sttfstateprod01 -g rg-tfstate-prod \
  -l centralindia --sku Standard_LRS --kind StorageV2 \
  --min-tls-version TLS1_2 --allow-blob-public-access false
az storage container create -n tfstate --account-name sttfstateprod01 \
  --auth-mode login
# Optional but recommended: enable blob versioning + soft delete for state recovery.
az storage account blob-service-properties update -n sttfstateprod01 \
  -g rg-tfstate-prod --enable-versioning true --enable-delete-retention true \
  --delete-retention-days 30

The backend block in your Terraform names where state lives. The two flags that matter for a pipeline are use_oidc (authenticate the backend keylessly, matching the service connection) and use_azuread_auth (use Entra ID RBAC on the blob instead of storage-account keys — so no account key is ever needed in CI):

# backend.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  backend "azurerm" {
    resource_group_name  = "rg-tfstate-prod"
    storage_account_name = "sttfstateprod01"
    container_name       = "tfstate"
    key                  = "prod/network.tfstate"
    use_oidc             = true
    use_azuread_auth     = true
  }
}
Backend auth mode Backend flags / env Security posture
OIDC + Azure AD (best) use_oidc = true, use_azuread_auth = true Keyless; RBAC on the blob; no account key anywhere
SPN secret + Azure AD ARM_CLIENT_SECRET, use_azuread_auth = true RBAC on blob, but a stored secret
Storage account key ARM_ACCESS_KEY / access_key A god-mode key to all blobs; avoid in CI
SAS token sas_token Time-boxed but fiddly; rarely worth it over OIDC
MSI (self-hosted agent) use_msi = true Great on a self-hosted agent with a managed identity

Keep use_azuread_auth = true and grant the pipeline’s identity Storage Blob Data Contributor on the state account (done in the RBAC step above). That removes storage-account keys from the entire system — one fewer secret to protect.

The pipeline: a real multi-stage azure-pipelines.yml

Now the centerpiece. This is a complete, working pipeline with three stages — Validate, Plan, Apply — where Apply is a deployment job gated on the prod Environment and consumes the exact plan the Plan stage produced. Read it once top to bottom; the sections after it dissect each decision.

# azure-pipelines.yml
trigger:
  branches:
    include: [ main ]          # CI: run on merge to main
pr:
  branches:
    include: [ main ]          # PR: run on pull requests targeting main

pool:
  vmImage: ubuntu-latest       # Microsoft-hosted agent

variables:
  - group: tf-prod             # variable group (Key Vault-linked; see below)
  - name: TF_VERSION
    value: '1.9.8'
  - name: workingDir
    value: '$(System.DefaultWorkingDirectory)/infra'
  - name: azureServiceConnection
    value: 'sc-tf-prod'        # the WIF service connection
  - name: TF_IN_AUTOMATION
    value: 'true'              # quiets interactive-only hints

stages:
# ---------------------------------------------------------------- VALIDATE
- stage: Validate
  displayName: 'Validate'
  jobs:
  - job: validate
    steps:
    - task: TerraformInstaller@1
      inputs: { terraformVersion: '$(TF_VERSION)' }
    - script: terraform fmt -check -recursive
      displayName: 'fmt -check'
      workingDirectory: $(workingDir)
    - task: AzureCLI@2
      displayName: 'init + validate'
      inputs:
        azureSubscription: $(azureServiceConnection)
        scriptType: bash
        scriptLocation: inlineScript
        addSpnToEnvironment: true          # exposes $idToken / $servicePrincipalId
        workingDirectory: $(workingDir)
        inlineScript: |
          export ARM_CLIENT_ID=$servicePrincipalId
          export ARM_OIDC_TOKEN=$idToken
          export ARM_TENANT_ID=$tenantId
          export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
          export ARM_USE_OIDC=true
          terraform init -input=false
          terraform validate -no-color
    - script: |
        curl -sSL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
        tflint --init && tflint -f compact
      displayName: 'tflint'
      workingDirectory: $(workingDir)
      continueOnError: true                 # advisory gate; flip to false to enforce

# ---------------------------------------------------------------- PLAN
- stage: Plan
  displayName: 'Plan'
  dependsOn: Validate
  jobs:
  - job: plan
    steps:
    - task: TerraformInstaller@1
      inputs: { terraformVersion: '$(TF_VERSION)' }
    - task: AzureCLI@2
      displayName: 'terraform plan -out=tfplan'
      inputs:
        azureSubscription: $(azureServiceConnection)
        scriptType: bash
        scriptLocation: inlineScript
        addSpnToEnvironment: true
        workingDirectory: $(workingDir)
        inlineScript: |
          export ARM_CLIENT_ID=$servicePrincipalId
          export ARM_OIDC_TOKEN=$idToken
          export ARM_TENANT_ID=$tenantId
          export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
          export ARM_USE_OIDC=true
          terraform init -input=false
          terraform plan -input=false -lock-timeout=300s \
            -out=tfplan -detailed-exitcode | tee plan.txt
    - publish: $(workingDir)/tfplan
      artifact: tfplan-prod                 # the immutable plan artifact
      displayName: 'Publish plan artifact'

# ---------------------------------------------------------------- APPLY
- stage: Apply
  displayName: 'Apply (gated)'
  dependsOn: Plan
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: apply
    displayName: 'terraform apply tfplan'
    environment: prod                       # <-- approval gate lives here
    strategy:
      runOnce:
        deploy:
          steps:
          - download: current
            artifact: tfplan-prod            # bring the exact plan back
          - task: TerraformInstaller@1
            inputs: { terraformVersion: '$(TF_VERSION)' }
          - task: AzureCLI@2
            displayName: 'apply the saved plan'
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              addSpnToEnvironment: true
              workingDirectory: $(workingDir)
              inlineScript: |
                export ARM_CLIENT_ID=$servicePrincipalId
                export ARM_OIDC_TOKEN=$idToken
                export ARM_TENANT_ID=$tenantId
                export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
                export ARM_USE_OIDC=true
                terraform init -input=false
                terraform apply -input=false -lock-timeout=300s \
                  $(Pipeline.Workspace)/tfplan-prod/tfplan

Three details in that file are doing the heavy lifting, and each corresponds to a badge on the diagram.

Auth happens inside the AzureCLI@2 task, keylessly. With addSpnToEnvironment: true, the task exposes $servicePrincipalId, $tenantId, and — because the service connection is WIF — $idToken (the federated token) into the inline script. We map those to the ARM_* variables Terraform reads and set ARM_USE_OIDC=true. Because the exports live only inside that one script step, we run terraform init and the plan/apply in the same step, so the token is in scope. This is why every Terraform command sits inside an AzureCLI@2 inline script rather than a bare script: step.

The plan is saved and replayed, never re-planned. Plan runs terraform plan -out=tfplan and publishes tfplan as a pipeline artifact. Apply does download: current / artifact: tfplan-prod and runs terraform apply <path>/tfplan. There are no -var flags on apply — a saved plan already has every value baked in, and passing variables to apply <planfile> is an error. If state drifted since the plan was made, apply tfplan refuses with a “saved plan is stale” error. That is the guarantee: the apply is the reviewed plan, or it fails.

Apply is a deployment job targeting environment: prod. That single line is what makes the approval gate possible — Azure DevOps evaluates the Environment’s checks before the agent runs the deploy steps, freezing the job until an approver acts. A normal job cannot carry an approval; only a deployment job bound to an Environment can.

Stage Runs on Key tasks Gate Output
Validate PR + CI fmt -check, init, validate, tflint Branch policy (on PR) Pass/fail
Plan PR + CI init, plan -out=tfplan -detailed-exitcode dependsOn: Validate tfplan artifact + plan.txt
Apply CI (main only) download, init, apply tfplan Environment approval Applied infra
AzureCLI@2 input Value Why
azureSubscription sc-tf-prod The WIF service connection to assume
scriptType bash Linux agent; use pscore on Windows
scriptLocation inlineScript Keeps auth exports + terraform in one scope
addSpnToEnvironment true Exposes $idToken/$servicePrincipalId/$tenantId
workingDirectory $(workingDir) Where the .tf files live
Variable / env Set from Purpose
ARM_CLIENT_ID $servicePrincipalId The SPN/app the connection represents
ARM_OIDC_TOKEN $idToken The federated token exchanged for an access token
ARM_TENANT_ID $tenantId Entra tenant
ARM_SUBSCRIPTION_ID az account show Target subscription
ARM_USE_OIDC true Tell azurerm to use the OIDC token, not a secret
TF_IN_AUTOMATION pipeline var Quiets interactive hints in output

If you prefer not to hand-wire the ARM_* exports, the community TerraformTaskV4@4 task (Microsoft DevLabs “Terraform” extension) accepts backendServiceArm / environmentServiceNameAzureRM and handles WIF auth for you — TerraformTaskV4@4 with command: plan and command: apply. It is less transparent but shorter. The AzureCLI@2 approach shown here is preferred because you can see exactly how auth flows, which matters when you are debugging a 401.

Hands-on: wire it end to end

Now build it. Each step is a real action with what you should see. ⚠️ The apply creates real Azure resources; the storage account and VNet cost pennies, but destroy them at the end.

Step 1 — Lay out the repo. A minimal but real structure:

infra/
  backend.tf        # azurerm backend (shown above)
  providers.tf      # provider "azurerm" { features {}  use_oidc = true }
  main.tf           # the resources
  variables.tf
  outputs.tf
azure-pipelines.yml

providers.tf and main.tf:

# providers.tf
provider "azurerm" {
  features {}
  use_oidc = true            # matches the WIF service connection
}

# main.tf
resource "azurerm_resource_group" "app" {
  name     = "rg-app-prod"
  location = var.location
  tags     = { env = "prod", managed_by = "terraform", pipeline = "azure-devops" }
}

resource "azurerm_virtual_network" "app" {
  name                = "vnet-app-prod"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location
  address_space       = ["10.40.0.0/16"]
  tags                = azurerm_resource_group.app.tags
}

resource "azurerm_subnet" "app" {
  name                 = "snet-app"
  resource_group_name  = azurerm_resource_group.app.name
  virtual_network_name = azurerm_virtual_network.app.name
  address_prefixes     = ["10.40.1.0/24"]
}
# variables.tf
variable "location" {
  type    = string
  default = "centralindia"
}

# outputs.tf
output "vnet_id"  { value = azurerm_virtual_network.app.id }
output "subnet_id" { value = azurerm_subnet.app.id }

Step 2 — Create the state backend. Run the az storage commands from the remote-state section. Verify:

az storage container show -n tfstate --account-name sttfstateprod01 --auth-mode login
# EXPECT: JSON describing the 'tfstate' container.

Step 3 — Create the WIF service connection. In Project settings → Service connections → New → Azure Resource Manager → Workload Identity federation (automatic), pick the subscription and resource group scope, name it sc-tf-prod, and save. (Or use the manual CLI path from earlier.) Then grant the pipeline permission to use it: on the connection, Security → allow this pipeline (or make it available to all pipelines in the project if that fits your governance).

Step 4 — Create the Key Vault-linked variable group. In Pipelines → Library → + Variable group, name it tf-prod, toggle Link secrets from an Azure Key Vault as variables, choose the service connection and the vault, and select the secrets to expose (e.g., a DB admin password your Terraform consumes as TF_VAR_db_password). Non-secret values (like TF_VAR_location) can be plain variables in the same group.

Variable-group source Example Exposed to Terraform as
Plain variable TF_VAR_location = centralindia var.location (via TF_VAR_ convention)
Secret variable (marked secret) db_password Masked in logs; reference explicitly
Key Vault-linked kv-prod → sql-admin-password Pulled at run time; never stored in ADO

Linking to Key Vault means the secret never lives in Azure DevOps at all — the agent fetches it from the vault at run time using the same service connection, and rotation happens in one place.

Step 5 — Create the prod Environment with an approval. In Pipelines → Environments → New environment, name it prod (resource: None). Open it, go to ⋯ → Approvals and checks → + → Approvals, add yourself (or a group) as a required approver, optionally set a timeout and “approver cannot be the requester”. Save. This is the gate; the environment: prod line in the YAML binds to it.

Step 6 — Commit azure-pipelines.yml and create the pipeline. Push the repo, then Pipelines → New pipeline → Azure Repos Git → your repo → Existing YAML file → /azure-pipelines.yml. Run it.

Step 7 — Watch the run. Validate and Plan run on the hosted agent. In the Plan stage log you should see the plan summary:

Plan: 3 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan

Then the Apply stage shows “Waiting for approval” — the deployment job is frozen. No agent is mutating anything.

Step 8 — Approve. Open the run, click Review → Approve on the pending prod deployment. The Apply job resumes, downloads tfplan-prod, and runs apply tfplan:

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
vnet_id = "/subscriptions/.../vnet-app-prod"

Step 9 — Verify in Azure. Confirm the resources and the state blob:

az network vnet show -g rg-app-prod -n vnet-app-prod --query "addressSpace.addressPrefixes"
# EXPECT: [ "10.40.0.0/16" ]
az storage blob show -c tfstate -n prod/network.tfstate \
  --account-name sttfstateprod01 --auth-mode login --query "properties.lease.state"
# EXPECT: "available"  (the lease was released after apply; "leased" would mean a stuck run)

Step 10 — ⚠️ Destroy and clean up. Real spend, however small, should not linger. The cleanest teardown is a manual destroy pipeline run (or a parameterized apply stage with -destroy); for the lab you can run it locally against the same backend so state stays consistent:

cd infra
# Auth locally the same way the pipeline does (az login as a user with the roles),
# or run a destroy stage in the pipeline.
terraform init -input=false
terraform plan -destroy -out=tfdestroy
terraform apply tfdestroy      # EXPECT: Resources: 3 destroyed.
# Then remove the state backend + service connection you created for the lab:
az group delete -n rg-app-prod --yes --no-wait
az group delete -n rg-tfstate-prod --yes --no-wait

Never run destroy unreviewed against prod — in a real pipeline, a destroy is itself a gated, approved stage, exactly like apply.

PR validation: branch policies that run plan on pull requests

The pipeline above already has a pr: trigger, so it runs on pull requests. But a trigger alone does not block a bad merge — for that you attach the pipeline as a required build validation policy on main.

The trigger vs pr keywords control when the pipeline runs:

Keyword Fires on Typical use
trigger: Commits pushed to the listed branches CI: run on merge to main
pr: Pull requests targeting the listed branches Validate + Plan on the PR
trigger: none Never on push (manual/PR only) Apply-only or scheduled pipelines
schedules: Cron Drift detection (below)

To enforce the gate: Project settings → Repositories → your repo → Policies → Branch policies → main → Build Validation → + Add. Point it at the pipeline, set it Required, and optionally scope the trigger to paths (/infra/*) so a docs-only PR does not run a plan.

Branch-policy setting Recommended Effect
Build expiration 12 hours or “immediately when main changes” Re-run stale validations
Policy requirement Required Merge blocked until the build passes
Path filter /infra/* Only Terraform changes trigger the plan
Minimum reviewers 1–2 Human review on top of the automated plan

To see the plan on the PR, publish it as a comment. The clean options are (a) the community tfcmt or ado-terraform-plan-comment style step that calls the Azure DevOps REST API to post a PR thread, or (b) publishing plan.txt as an artifact and letting reviewers open it. A minimal REST-API comment step:

- script: |
    SUMMARY=$(grep -E "Plan:|No changes" plan.txt | tail -1)
    BODY=$(jq -n --arg c "### Terraform Plan\n\`\`\`\n$SUMMARY\n\`\`\`" '{comments:[{parentCommentId:0,content:$c,commentType:1}],status:1}')
    curl -sS -X POST \
      -H "Authorization: Bearer $(System.AccessToken)" \
      -H "Content-Type: application/json" \
      -d "$BODY" \
      "$(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.ID)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=7.1"
  condition: eq(variables['Build.Reason'], 'PullRequest')
  displayName: 'Post plan to PR'

That uses the built-in $(System.AccessToken) (the pipeline’s own OAuth token); enable “Allow scripts to access the OAuth token” on the job, and grant the build service Contribute to pull requests on the repo. One safety note that mirrors the GitHub lesson: for PRs from forks, Azure DevOps by default does not expose secrets or the service connection to the fork’s build, which is correct — never loosen that to make a fork’s plan authenticate with prod credentials.

Approvals & checks on Environments

The approval you added in Step 5 is one of several checks an Environment can carry. Checks are evaluated before the deployment job’s agent runs, and all configured checks must pass. This is where you encode “prod changes need a human, during business hours, one run at a time.”

Check What it enforces Use for Terraform apply
Approvals Named users/groups must approve The core apply gate — required reviewers
Business hours Only proceed within a time window Avoid Friday-night prod applies
Exclusive lock Only one run to this Environment at a time Serialize applies; prevents overlapping state writes
Invoke REST API Call an external system (change ticket, CMDB) Verify a change request is approved
Invoke Azure Function Custom gate logic Policy checks, security scans
Required template Pipeline must extend an approved YAML template Enforce the org’s hardened pipeline shape
Evaluate artifact Policy (e.g., checkov) on the artifact Fail the gate on a policy violation
Branch control Only listed branches may deploy main-only deploys to prod
Approval option Recommended for prod Why
Approvers A team, not one person Bus-factor; someone is always available
Requester cannot approve On Separation of duties — you cannot rubber-stamp your own change
Timeout 1–3 days Stale approvals auto-reject instead of lingering
Instructions “Confirm the plan summary matches the PR” Tells the approver what to check

Two checks deserve emphasis for Terraform. Exclusive lock is your defense against two applies racing to the same Environment — it queues the second run instead of letting both mutate state (the backend lease is the last line of defense, but the exclusive lock stops the race earlier and more gracefully). Branch control combined with the YAML condition pinning apply to refs/heads/main gives defense in depth: even a pipeline edit on a feature branch cannot reach the prod Environment.

Agents: Microsoft-hosted vs self-hosted

The pipeline runs on an agent. Microsoft-hosted agents (vmImage: ubuntu-latest) are ephemeral VMs Microsoft manages — zero maintenance, fresh every run, but on the public internet. Self-hosted agents are machines you run, which matters the moment your state account or target resources sit behind private endpoints or a firewall the hosted agent cannot reach.

Dimension Microsoft-hosted Self-hosted
Maintenance None You patch, scale, secure it
Network reach Public internet only Your VNet — reaches private endpoints
Tools preinstalled Terraform, az, common CLIs Only what you install (declare capabilities)
Clean environment Fresh VM every run Persists unless you reset it
Cost model Free tier: 1 parallel job / 1800 min-mo (grant-gated) 1 free self-hosted parallel job; you pay for the VM
Managed identity No Yes — attach an MSI, use use_msi = true
Best for Public Azure resources, simplicity Private networking, custom tooling, MSI auth

Choose self-hosted when: the storage account or resources are behind private endpoints; you need a managed identity instead of a service connection (ARM_USE_MSI=true); you must pin an exact Terraform/tooling version or a hardened OS image; or compliance forbids build workloads on shared infrastructure. Otherwise the hosted agent is simpler and safer (nothing to keep patched).

Agent capability issue Symptom Fix
Terraform not installed (self-hosted) terraform: command not found Add TerraformInstaller@1, or install + register a capability
Wrong TF version Provider needs newer core Pin TF_VERSION; TerraformInstaller@1 fetches it
Demands not met “No agent found that satisfies demands” Add the demanded capability to the agent, or fix the demands:
No private-endpoint reach Backend init times out to storage Move to a self-hosted agent inside the VNet

Drift detection on a schedule

State drifts — someone clicks in the portal, a policy remediation retags a resource, a sister pipeline edits something shared. Catch it on a cadence with a scheduled pipeline that plans and asserts “no changes,” raising a work item when it finds drift. -detailed-exitcode is the mechanism: 0 = no changes, 2 = drift, 1 = error.

# drift.yml — a separate pipeline
schedules:
  - cron: "0 2 * * *"          # 02:00 UTC nightly
    displayName: Nightly drift
    branches: { include: [ main ] }
    always: true               # run even with no new commits
trigger: none
pool: { vmImage: ubuntu-latest }
variables:
  - group: tf-prod
steps:
  - task: AzureCLI@2
    displayName: 'drift plan'
    inputs:
      azureSubscription: 'sc-tf-prod'
      scriptType: bash
      scriptLocation: inlineScript
      addSpnToEnvironment: true
      workingDirectory: $(System.DefaultWorkingDirectory)/infra
      inlineScript: |
        export ARM_CLIENT_ID=$servicePrincipalId ARM_OIDC_TOKEN=$idToken
        export ARM_TENANT_ID=$tenantId ARM_USE_OIDC=true
        export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
        terraform init -input=false
        set +e
        terraform plan -input=false -detailed-exitcode -lock-timeout=120s
        code=$?
        if [ $code -eq 2 ]; then echo "##vso[task.logissue type=warning]Drift detected"; exit 1; fi
        exit $code
Exit code Meaning Pipeline action
0 No changes — state matches reality Pass silently
2 Drift — plan has changes Fail loud; raise a work item / notification
1 The run itself errored Fail; investigate the pipeline

Wire the failure to a notification (an ADO service hook to Teams/Slack, or a Create work item step) so drift lands in someone’s queue by morning rather than surprising the next apply. Use always: true so the schedule runs even on quiet days.

Terragrunt in Azure DevOps

If your repository is Terragrunt-based (per-environment terragrunt.hcl over a shared module library), the pipeline shape is the same — Validate → Plan → gated Apply — with three adjustments. First, install Terragrunt on the agent (a script step that curls the binary, or a self-hosted image that bundles it). Second, use terragrunt run-all plan / run-all apply to walk the dependency graph, and pass --terragrunt-non-interactive so it never blocks on a prompt. Third — the tricky one — run-all fans out across many units, so the “one saved tfplan artifact” pattern becomes “one plan file per unit”; publish the whole .terragrunt-cache plan set or plan-and-apply per unit with --terragrunt-include-dir.

Terragrunt-in-ADO concern Handling
Auth Same WIF service connection + ARM_* exports; Terragrunt inherits them
Backend generation remote_state {} block generates the azurerm backend per unit
Multi-unit plan run-all plan --terragrunt-non-interactive; artifact per unit
Ordering dependency blocks drive apply order; run-all respects the graph
Approval Still an Environment gate on the run-all apply deployment job

The full multi-environment Terragrunt promotion model — dev auto-apply, uat/staging manual, prod required-reviewers, with the exact terragrunt.hcl layout — is the subject of Multi-Environment 3-Tier Infrastructure with Terragrunt & CI/CD Approval Gates; that lesson shows the AWS and Azure DevOps equivalents of the graduated gate model in depth.

Variables, outputs & making it reusable

Copy-pasting the same 90-line pipeline into every repo is how pipelines rot. Azure DevOps has YAML templates for exactly this: extract the stages into a reusable template with parameters, and each repo extends it with a few values. This also lets platform teams enforce a hardened shape via the Environment’s “Required template” check.

A reusable templates/terraform-stages.yml:

# templates/terraform-stages.yml
parameters:
  - name: environmentName          # 'dev' | 'prod'
    type: string
  - name: serviceConnection
    type: string
  - name: workingDir
    type: string
    default: '$(System.DefaultWorkingDirectory)/infra'
  - name: applyBranch
    type: string
    default: 'refs/heads/main'

stages:
  - stage: Plan_${{ parameters.environmentName }}
    jobs:
      - job: plan
        steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: ${{ parameters.serviceConnection }}
              scriptType: bash
              scriptLocation: inlineScript
              addSpnToEnvironment: true
              workingDirectory: ${{ parameters.workingDir }}
              inlineScript: |
                export ARM_CLIENT_ID=$servicePrincipalId ARM_OIDC_TOKEN=$idToken
                export ARM_TENANT_ID=$tenantId ARM_USE_OIDC=true
                export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
                terraform init -input=false
                terraform plan -input=false -out=tfplan -lock-timeout=300s
          - publish: ${{ parameters.workingDir }}/tfplan
            artifact: tfplan-${{ parameters.environmentName }}

  - stage: Apply_${{ parameters.environmentName }}
    dependsOn: Plan_${{ parameters.environmentName }}
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], '${{ parameters.applyBranch }}'))
    jobs:
      - deployment: apply
        environment: ${{ parameters.environmentName }}
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: tfplan-${{ parameters.environmentName }}
                - task: AzureCLI@2
                  inputs:
                    azureSubscription: ${{ parameters.serviceConnection }}
                    scriptType: bash
                    scriptLocation: inlineScript
                    addSpnToEnvironment: true
                    workingDirectory: ${{ parameters.workingDir }}
                    inlineScript: |
                      export ARM_CLIENT_ID=$servicePrincipalId ARM_OIDC_TOKEN=$idToken
                      export ARM_TENANT_ID=$tenantId ARM_USE_OIDC=true
                      export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
                      terraform init -input=false
                      terraform apply -input=false -lock-timeout=300s \
                        $(Pipeline.Workspace)/tfplan-${{ parameters.environmentName }}/tfplan

The consuming pipeline shrinks to a few lines per environment:

# azure-pipelines.yml (consumer)
trigger: { branches: { include: [ main ] } }
pr: { branches: { include: [ main ] } }
extends:
  template: templates/terraform-stages.yml
  parameters:
    environmentName: prod
    serviceConnection: sc-tf-prod
Template parameter Type Why it varies per repo/env
environmentName string Selects the prod/dev Environment + artifact name
serviceConnection string Per-environment WIF connection
workingDir string Where the .tf lives in that repo
applyBranch string Which branch may apply (default main)
Reuse pattern When Note
extends template Org-wide hardened pipeline Enforceable via “Required template” check
- template: include Share steps/stages within a repo Simpler; no governance guarantee
Matrix over envs Fan out dev/staging/prod Each still needs its own Environment + connection
Module registry (Azure/*/azurerm) Reuse the resources, not the pipeline Pair with your pipeline template

For the Terraform resources themselves, prefer published modules where they fit — the verified Azure/*/azurerm registry modules (e.g., Azure/naming/azurerm, Azure/avm-res-network-virtualnetwork/azurerm) — and reserve roll-your-own for the glue. The pipeline template and the module registry are orthogonal: one makes the delivery reusable, the other the infrastructure.

Common mistakes and troubleshooting

The failures below are the ones that actually cost hours in Azure DevOps Terraform pipelines. Symptom → cause → fix:

Symptom Cause Fix
AADSTS700213 / AADSTS70021 no matching federated credential WIF subject mismatch — sc://org/project/name wrong or connection renamed Recreate the federated credential with the exact sc:// subject; names are case-sensitive
Error building AzureRM Client: obtaining subscription ARM_USE_OIDC/ARM_OIDC_TOKEN not set, or exports in a different step than terraform Run init/plan/apply inside the AzureCLI@2 inline script with addSpnToEnvironment: true
Backend init403 AuthorizationPermissionMismatch Identity lacks Storage Blob Data Contributor on the state account Grant the RBAC role at the storage-account scope; use_azuread_auth = true
Backend initAuthenticationFailed on the blob use_azuread_auth off but no account key provided Set use_azuread_auth = true and grant RBAC (drop account keys entirely)
Error acquiring the state lock (blob lease) A previous run died holding the lease Confirm no live apply, then terraform force-unlock <ID> / break the blob lease
Saved plan is stale on apply State changed between Plan and Apply stages Re-run Plan; never regenerate/patch state to force it. This is the guarantee working
Apply re-plans / does something the PR didn’t show Apply ran plan again instead of apply tfplan Apply must consume the artifact: terraform apply <path>/tfplan, no -var
Secret printed in logs echoing a variable, or a secret not marked secret Mark it secret / link from Key Vault; never echo; ADO masks known secrets only
“No agent found that satisfies demands” Self-hosted agent missing a capability Install the tool + register capability, or use a hosted agent
Variable group values are empty Group not authorized for the pipeline Library → variable group → Pipeline permissions → add the pipeline
Apply stage runs on a PR Missing branch condition on the Apply stage condition: eq(variables['Build.SourceBranch'],'refs/heads/main')
Approval never appears Apply is a plain job, not a deployment job Use deployment + environment:; approvals are an Environment property
terraform init re-downloads providers every run No plugin cache Set TF_PLUGIN_CACHE_DIR + cache it with Cache@2 keyed on the lock file
Two applies corrupt/race state No exclusive lock; concurrent runs Add the Exclusive lock check to the Environment; keep -lock-timeout

Three of these deserve a longer word. The stale-plan error is not a bug — it is the feature. When someone applies out-of-band between your Plan and Apply, the saved tfplan no longer matches the state serial, and Terraform refuses to apply it. The correct response is to re-plan (regenerate the artifact), never to force it. The 403 on backend init is the single most common first-run failure: the pipeline identity can create resources (Contributor on the RG) but you forgot the separate Storage Blob Data Contributor role on the state account — the backend and the provider need different grants. Secret exposure is subtler than it looks: Azure DevOps masks values it knows are secret (marked secret variables, Key Vault-linked), but a secret you compute at run time or receive from a data source is not automatically masked, so never echo Terraform outputs that might contain secrets, and mark sensitive outputs sensitive = true.

Cost, cleanup & production notes

What it costs. Azure DevOps itself: the free tier includes 1 Microsoft-hosted parallel job with 1800 minutes/month (subject to a grant request for new orgs) or 1 self-hosted parallel job free (you pay for the VM). The lab’s Azure resources are trivial — an empty VNet is free, the state storage account is a few rupees a month for a tiny blob with versioning. The real cost is whatever infrastructure you manage through the pipeline, which is unchanged by how you deliver it.

Item Cost driver Note
ADO Microsoft-hosted agent Free 1800 min/mo, then per-parallel-job Grant-gated for brand-new orgs
ADO self-hosted agent 1 free parallel job + your VM cost Needed for private-endpoint reach
State storage account Per-GB + transactions (pennies) Enable versioning + soft delete for recovery
Key Vault Per-operation (negligible) Secrets fetched per run
Managed resources Whatever you deploy The pipeline doesn’t add to this

Cleanup. Run the destroy from Step 10, then delete the two resource groups and remove the service connection and Environment if they were lab-only.

Production hardening — five things that separate a demo from a system:

Hardening Do this Why
Keyless auth WIF service connection, use_oidc = true, no stored secret Removes the top credential-leak vector
Least-privilege identity One SPN per env, Contributor scoped to the RG, Blob Data Contributor on state only A dev run can’t touch prod; blast radius bounded
Protected state Separate RG + storage for state; versioning + soft delete; RBAC not keys State is the crown jewel; make it recoverable and keyless
Enforced gates Required approvers + exclusive lock + branch control on prod Separation of duties; serialized applies; main-only
Plan integrity + retention Apply the saved tfplan; short artifact retention; restrict artifact download Reviewed == shipped; short-lived plan artifacts
Drift + audit Nightly drift pipeline; keep run history Reality tracked; every change attributable

Cheat-sheet

Dense quick-reference for building and operating an Azure DevOps Terraform pipeline.

az / az devops command Purpose
az storage account create ... --allow-blob-public-access false Create the state account
az storage container create -n tfstate --auth-mode login Create the state container
az ad app create --display-name sp-tf-prod App registration for the pipeline identity
az ad app federated-credential create --id <appId> --parameters ... Add the WIF federated credential
az role assignment create --role Contributor --scope <rg> Least-priv RBAC for resources
az role assignment create --role "Storage Blob Data Contributor" --scope <sa> RBAC for the backend
az pipelines variable-group create --name tf-prod ... Create a variable group
az devops service-endpoint list Inspect service connections
Pipeline YAML keyword Meaning
trigger: / pr: Run on push / on pull request
stages: → jobs: → steps: The pipeline hierarchy
deployment: + environment: A gated job bound to an Environment (approvals)
strategy: runOnce: deploy: The deployment execution strategy
- publish: <path> / artifact: Publish a pipeline artifact
- download: current / artifact: Consume an artifact in a later stage
- group: under variables Attach a variable group
condition: Gate a stage/job (e.g., branch-only apply)
schedules: cron: Scheduled (drift) runs
Terraform command (in CI) Purpose
terraform init -input=false Init backend + providers non-interactively
terraform fmt -check -recursive Fail on unformatted HCL
terraform validate -no-color Static config validation
terraform plan -out=tfplan -detailed-exitcode Save the plan; exit 2 = changes
terraform apply <path>/tfplan Apply the exact saved plan (no -var)
terraform force-unlock <ID> Break a stuck backend lease (carefully)
Auth env var Set from (WIF)
ARM_CLIENT_ID $servicePrincipalId
ARM_OIDC_TOKEN $idToken
ARM_TENANT_ID $tenantId
ARM_SUBSCRIPTION_ID az account show --query id -o tsv
ARM_USE_OIDC true

Interview and exam questions

Q1. Why publish a binary tfplan as an artifact and apply that, instead of re-running plan in the apply stage? Because it guarantees the apply is exactly what was reviewed. A re-plan can pick up drift or changed variables and do something the reviewer never saw. The saved plan also refuses to apply if state moved (stale-plan error), so plan/apply integrity is enforced, not hoped for.

Q2. What is the difference between a service connection using an SPN secret and one using workload identity federation? The secret variant stores a long-lived client secret that must be rotated and can leak; WIF stores nothing and exchanges a short-lived Azure DevOps token for an Entra ID access token at run time, trusted via a federated credential pinned to the sc://org/project/connection subject.

Q3. Why must the Apply job be a deployment job rather than a regular job? Approvals and checks are properties of an Environment, and only a deployment job can target an Environment (environment: prod). A plain job cannot carry an approval gate, so the pipeline would apply without pausing.

Q4. Your backend init fails with 403 AuthorizationPermissionMismatch. The apply itself would have worked. Why? The pipeline identity has Contributor on the workload RG (so it can create resources) but lacks Storage Blob Data Contributor on the state storage account, which use_azuread_auth = true requires. Backend and provider need separate RBAC grants.

Q5. Where does the OIDC token come from and how does Terraform use it? AzureCLI@2 with addSpnToEnvironment: true exposes $idToken (a federated token from Azure DevOps) into the script; you export it as ARM_OIDC_TOKEN, set ARM_USE_OIDC=true, and the azurerm provider/backend exchange it with Entra ID for an access token — no secret involved.

Q6. How do you stop two applies from racing to the same environment? Add the Exclusive lock check to the Environment (queues the second run) and keep -lock-timeout on plan/apply so the backend blob lease is the last-resort guard. The exclusive lock stops the race earlier and more gracefully than the lease alone.

Q7. (Terraform Associate style) What does terraform plan -detailed-exitcode return, and how does a drift pipeline use it? 0 = no changes, 1 = error, 2 = changes present. A scheduled drift pipeline treats 2 as “reality drifted from state” and fails/notifies, treats 0 as clean, and 1 as a broken run to investigate.

Q8. (Associate style) You pass -var flags to terraform apply tfplan. What happens? It errors. A saved plan already has all variable values baked in; you cannot (and must not) pass variables when applying a plan file. This is deliberate — it removes the “plan used one value, apply used another” class of bug.

Q9. Why link a variable group to Azure Key Vault instead of storing secrets as ADO secret variables? Key Vault-linked secrets never live in Azure DevOps at all — the agent fetches them at run time via the service connection — so rotation happens in one place, access is auditable in the vault, and there is no secret to leak from the pipeline definition.

Q10. When do you need a self-hosted agent for Terraform on Azure? When the state account or target resources are behind private endpoints the hosted agent can’t reach, when you need a managed identity (ARM_USE_MSI=true) instead of a service connection, or when you must pin an exact toolchain/hardened image. Otherwise the Microsoft-hosted agent is simpler and needs no patching.

Q11. How do branch policies and the YAML pr: trigger differ in enforcing PR validation? The pr: trigger runs the pipeline on pull requests; a Build Validation branch policy requires that run to pass before merge is allowed. You need both: the trigger to run it, the policy to block a bad merge.

Q12. How would you share one hardened pipeline across many repos and enforce it? Extract the stages into a YAML template with parameters, have each repo extends: it, and add the “Required template” check on the prod Environment so a deployment is rejected unless it came through the approved template.

Key takeaways

TerraformTerragruntAzure DevOpsazurermCI/CDYAML Pipelinesservice-connectionOIDCworkload-identity-federationremote-stateAzure StorageKey Vaultapproval-gatesIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments