
September 09, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
Terraform with Azure DevOps Pipelines is how most teams I work with ship Azure infrastructure without clicking through the portal for every change. You define resources in HCL, store state remotely, and let a YAML pipeline run terraform plan on pull requests and terraform apply only after human approval. That pattern mirrors the infrastructure as code with Terraform workflow I use on production systems, but wired into Azure DevOps service connections, variable groups, and environment gates. This guide walks through a copy-pasteable setup you can run in 2026.
What is Terraform with Azure DevOps Pipelines and why use it?
Azure DevOps gives you repos, pipelines, environments, and approvals in one place. Terraform gives you declarative Azure resources with a predictable plan output. Together they replace ad-hoc portal changes with auditable runs tied to Git commits.
On client projects I have maintained Laravel apps on Azure alongside sister sites on AWS. The Terraform layer stays consistent: modules in Git, state in a locked backend, and pipelines that never apply directly from a laptop. That is the same discipline I apply when handling enterprise application deployments, just aimed at VMs, networks, and databases instead of PHP releases.
Compared with running Terraform locally, a pipeline gives you:
- Repeatable agent images with pinned Terraform and provider versions
- Pull-request plans posted as build summaries or PR comments
- Environment gates before production
apply - Central secret storage through Azure Key Vault variable groups
- Full audit trail: who approved what, and which commit triggered it
How do you set up remote state and service connections for Terraform?
Before writing pipeline YAML, provision the backend Terraform will use on every run. Remote state is non-negotiable for team pipelines. Without it, concurrent applies corrupt state and you lose the single source of truth.
Create the state storage account
Run this once per subscription or per environment tier. Lock the storage account down with private access where your network policy allows it.
# bootstrap-state.sh — run locally or in a one-off pipeline
RESOURCE_GROUP="rg-terraform-state"
STORAGE_ACCOUNT="sttfstateprod001"
CONTAINER="tfstate"
LOCATION="eastus"
az group create --name $RESOURCE_GROUP --location $LOCATION
az storage account create \
--name $STORAGE_ACCOUNT \
--resource-group $RESOURCE_GROUP \
--sku Standard_LRS \
--encryption-services blob \
--min-tls-version TLS1_2
az storage container create \
--name $CONTAINER \
--account-name $STORAGE_ACCOUNT
Configure the backend in backend.tf. The azurerm backend documentation covers every argument; this is the minimal working block:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "sttfstateprod001"
container_name = "tfstate"
key = "prod/network.tfstate"
}
}
Use a distinct key per stack — for example prod/app.tfstate and staging/app.tfstate. That pattern aligns with Terraform workspaces and environments guidance, though many teams prefer separate state keys over workspaces for clearer blast-radius boundaries.
Wire the Azure Resource Manager service connection
In Azure DevOps, open Project Settings → Service connections → New → Azure Resource Manager. Choose workload identity federation when available; it avoids long-lived client secrets rotating on a calendar.
Grant the service principal these roles on the subscription or resource group:
- Contributor on target resource groups Terraform manages
- Storage Blob Data Contributor on the state storage account
- Key Vault Secrets User if pipelines read secrets from Vault
Name the connection something explicit like sc-azure-prod-contributor. You will reference that exact name in YAML. For deeper pipeline auth patterns, see the guide on Azure Key Vault secrets in pipelines.
How do you write a Terraform pipeline in Azure DevOps YAML?
A production pipeline separates validate, plan, and apply into stages. Dev runs plan on every commit. Production apply waits for a manual check on an Azure DevOps Environment. This mirrors the stage model in the Azure DevOps YAML pipelines practical guide.
Install Terraform on the agent
Microsoft publishes the Terraform task extension for Azure DevOps. It wraps init, plan, apply, and destroy with consistent logging. Alternatively, use a bash script and pin the CLI version yourself. Pinning matters: an agent image update must not silently jump provider behaviour.
# azure-pipelines.yml
trigger:
branches:
include:
- main
paths:
include:
- infra/*
pr:
branches:
include:
- main
paths:
include:
- infra/*
variables:
terraformVersion: '1.9.8'
workingDirectory: '$(System.DefaultWorkingDirectory)/infra'
serviceConnection: 'sc-azure-prod-contributor'
backendResourceGroup: 'rg-terraform-state'
backendStorageAccount: 'sttfstateprod001'
backendContainer: 'tfstate'
backendKey: 'prod/app.tfstate'
stages:
- stage: Validate
jobs:
- job: fmt_and_validate
pool:
vmImage: 'ubuntu-latest'
steps:
- task: TerraformInstaller@1
inputs:
terraformVersion: $(terraformVersion)
- script: |
cd $(workingDirectory)
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
displayName: 'Terraform fmt and validate'
- stage: Plan
dependsOn: Validate
jobs:
- job: plan
pool:
vmImage: 'ubuntu-latest'
steps:
- task: TerraformInstaller@1
inputs:
terraformVersion: $(terraformVersion)
- task: TerraformTaskV4@4
displayName: 'Terraform init'
inputs:
provider: 'azurerm'
command: 'init'
workingDirectory: $(workingDirectory)
backendServiceArm: $(serviceConnection)
backendAzureRmResourceGroupName: $(backendResourceGroup)
backendAzureRmStorageAccountName: $(backendStorageAccount)
backendAzureRmContainerName: $(backendContainer)
backendAzureRmKey: $(backendKey)
- task: TerraformTaskV4@4
displayName: 'Terraform plan'
inputs:
provider: 'azurerm'
command: 'plan'
workingDirectory: $(workingDirectory)
environmentServiceNameAzureRM: $(serviceConnection)
commandOptions: '-out=$(Build.ArtifactStagingDirectory)/tfplan'
- publish: $(Build.ArtifactStagingDirectory)/tfplan
artifact: tfplan
The TerraformTaskV4 reference lists every input. Match the task major version to the extension installed in your organisation.
Gate production apply behind an Environment
- stage: Apply
dependsOn: Plan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: apply_prod
displayName: 'Apply to production'
environment: 'production-infra'
pool:
vmImage: 'ubuntu-latest'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: tfplan
- task: TerraformInstaller@1
inputs:
terraformVersion: $(terraformVersion)
- task: TerraformTaskV4@4
displayName: 'Terraform init'
inputs:
provider: 'azurerm'
command: 'init'
workingDirectory: $(workingDirectory)
backendServiceArm: $(serviceConnection)
backendAzureRmResourceGroupName: $(backendResourceGroup)
backendAzureRmStorageAccountName: $(backendStorageAccount)
backendAzureRmContainerName: $(backendContainer)
backendAzureRmKey: $(backendKey)
- task: TerraformTaskV4@4
displayName: 'Terraform apply'
inputs:
provider: 'azurerm'
command: 'apply'
workingDirectory: $(workingDirectory)
environmentServiceNameAzureRM: $(serviceConnection)
commandOptions: '$(Pipeline.Workspace)/tfplan/tfplan'
Create the production-infra Environment under Pipelines → Environments. Add an approval check assigned to your infra lead or platform team. Pull requests should stop at Plan; only merges to main reach Apply. That split is a core item in build pipeline automation best practices.
TerraformTask vs script tasks: which approach fits your team?
Both patterns appear in production. The right choice depends on how much control you need over logging, provider caching, and task extension upgrades.
| Criteria | TerraformTaskV4 extension | Bash script with pinned CLI |
|---|---|---|
| Setup speed | Faster — built-in Azure auth wiring | More YAML, but fully transparent |
| Version pinning | Via TerraformInstaller task | You download exact binary yourself |
| Plan artifact handling | Native -out support | Manual artifact publish/download |
| Extension dependency | Requires marketplace extension | None — only agent tools |
| Self-hosted agents | Works if extension installed | Ideal when you pre-install Terraform on the VM |
| Debugging | Task wraps output | Full shell access and custom flags |
For Microsoft-hosted agents, start with TerraformTaskV4. Move to scripts when you hit extension lag or need custom provider mirror settings. Self-hosted agents — covered in the self-hosted Azure DevOps agents article — often pre-bake Terraform and provider plugins to cut init time on every run.
How do you manage secrets, variables, and modules in pipeline runs?
Never commit secrets in *.tfvars files. Use Azure DevOps variable groups linked to Key Vault for database passwords, API keys, and third-party tokens. Pass them as pipeline variables prefixed with TF_VAR_ so Terraform picks them up automatically.
# Pipeline variables (set in Azure DevOps UI or variable group)
# TF_VAR_db_admin_password — secret, from Key Vault
# TF_VAR_environment — non-secret, e.g. "prod"
steps:
- task: TerraformTaskV4@4
displayName: 'Terraform plan with vars'
inputs:
provider: 'azurerm'
command: 'plan'
workingDirectory: $(workingDirectory)
environmentServiceNameAzureRM: $(serviceConnection)
commandOptions: '-var-file=environments/$(TF_VAR_environment).tfvars -out=tfplan'
Structure repo directories so reviewers see intent quickly:
infra/
modules/
network/
app-service/
environments/
prod.tfvars
staging.tfvars
main.tf
variables.tf
outputs.tf
backend.tf
providers.tf
Reuse modules the way described in Terraform modules for reusable infrastructure. Keep environment differences in tfvars and backend keys, not forked copies of main.tf. For variable design, cross-read Terraform variables, locals, and outputs.
Lock provider versions in providers.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
Commit .terraform.lock.hcl to Git. Run terraform providers lock in CI if your agents span Linux and Windows. Validate JSON pipeline outputs with the site JSON formatter tool when wiring custom deployment scripts around Terraform outputs.
What production mistakes break Terraform with Azure DevOps Pipelines?
Most failures I troubleshoot are operational, not syntax errors. The plan looks fine; the pipeline or state setup is wrong.
State lock timeouts during parallel runs
Two pipelines applying the same state key at once produce lock errors. Enforce branch policies so only one pipeline targets a given backend key. Use lockTimeout in the backend config if legitimate runs queue behind each other. Read manage Terraform state safely before splitting state files.
Plan and apply running on different commits
Always pass the saved plan file to apply. Running a fresh plan inside the apply stage invites drift. Someone may merge another PR between stages. The artifact must be the contract.
Over-privileged service connections
Contributor on the whole subscription is convenient and dangerous. Scope service principals to resource groups Terraform actually touches. Separate connections for prod and non-prod. Key Vault integration details live in Azure Key Vault keys, secrets, and certificates.
Missing path filters
Without paths triggers, every application commit rebuilds infrastructure. That wastes agent minutes and increases accidental apply risk. Scope triggers to infra/** unless you intentionally couple app and infra releases.
Provider upgrades without a plan review
Renovate or Dependabot bumping the azurerm provider can recreate resources. Require plan stage success and human review before merge when lock files change. Provider pinning guidance appears in Terraform provider version pinning.
On multi-cloud work — for example the pattern in deploy the same app to AWS and Azure with Terraform — use separate pipeline stages per cloud with distinct service connections and state backends. Do not share one apply job across providers.
For teams running Linux workloads on Azure, pairing this pipeline with Linux system administration practices keeps VM extensions and post-provision config consistent. Booking platforms like Adventure Third Pole Trek run application CI/CD separately from infra pipelines; the Terraform pipeline provisions networking and compute, while app pipelines deploy Laravel releases on top.
Key Takeaways
- Store Terraform state in Azure Storage with blob locking before enabling team pipelines.
- Split validate, plan, and apply into YAML stages; save the plan artifact and apply that exact file.
- Use Azure DevOps Environments with manual approval for every production
terraform apply. - Pass secrets via Key Vault-linked variable groups as
TF_VAR_*variables — never commit them. - Pin Terraform CLI and provider versions; commit
.terraform.lock.hcland review lock-file PRs. - Scope pipeline triggers and RBAC narrowly so infra runs only when
infra/changes and only where needed.
People Also Ask
Can Azure DevOps pipelines run Terraform for AWS or GCP?
Yes. The Terraform task supports multiple providers. Install the relevant CLI credentials as secret variables or use OIDC federation. Keep separate state backends per cloud. The pipeline mechanics — init, plan, artifact, gated apply — stay identical.
Do you need the Terraform extension from the marketplace?
No, but it saves time on Azure authentication wiring. You can replace every task with bash scripts that call the Terraform CLI directly. Many regulated teams prefer scripts for a fully auditable pipeline definition.
How do you run Terraform plan on pull requests without applying?
Configure pr: triggers in YAML and stop the pipeline after the Plan stage. Add a branch condition on Apply so only main merges proceed. Optionally publish plan output as a build summary or PR comment through a custom script task.
What is the cost of running Terraform in Azure DevOps Pipelines?
Microsoft-hosted parallel jobs consume pipeline minutes from your Azure DevOps organisation. A typical plan stage on a medium stack finishes in two to five minutes. Self-hosted agents on a Rs 5,000/month VM (~USD 37) can be cheaper at high volume. Infrastructure cost is dominated by the Azure resources Terraform creates, not the pipeline itself.
Ship infrastructure the same way you ship application code
Terraform with Azure DevOps Pipelines turns infrastructure changes into reviewed, repeatable releases instead of portal experiments. Start with remote state, a service connection scoped to one resource group, and a three-stage YAML file that plans on pull requests and applies only after approval. Expand into modules, separate environments, and policy checks as the stack grows. If you want help wiring Terraform pipelines for a Laravel, WordPress, or multi-environment Azure deployment, contact us or review the DevOps roadmap for 2026 and the Terraform Associate certification guide for structured next steps.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

