Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Terraform with Azure DevOps Pipelines

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
Terraform + Azure DevOps Pipeline FlowGit Repo*.tf modulesYAML Pipelineplan / apply stagesRemote StateAzure StorageAzureRG / VNetTypical Stage SequenceValidatePlanApprovalApplyService connection authenticates pipeline to Azure subscription
End-to-end Terraform with Azure DevOps Pipelines: code in Git, state in Azure Storage, gated apply to live resources

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:

  1. Contributor on target resource groups Terraform manages
  2. Storage Blob Data Contributor on the state storage account
  3. 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.

YAML Stage Gating for Terraform AppliesPR TriggerValidate + Plan onlyMerge to mainPlan artifact savedEnvironment GateManual approvalApplyCondition ExpressionsApply stage: eq(SourceBranch, refs/heads/main)Skip Apply on PR builds — plan output onlyenvironment: production-infra adds approval checkSaved plan file prevents drift between plan and apply
Pull requests plan only; production Terraform apply runs after merge and manual environment approval

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.

CriteriaTerraformTaskV4 extensionBash script with pinned CLI
Setup speedFaster — built-in Azure auth wiringMore YAML, but fully transparent
Version pinningVia TerraformInstaller taskYou download exact binary yourself
Plan artifact handlingNative -out supportManual artifact publish/download
Extension dependencyRequires marketplace extensionNone — only agent tools
Self-hosted agentsWorks if extension installedIdeal when you pre-install Terraform on the VM
DebuggingTask wraps outputFull 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.

Secrets Path: Key Vault to TF_VARKey Vaultdb-password secretVariable Grouplinked to VaultPipeline RunTF_VAR_db_passwordWhat Never Enters GitPlaintext passwords in tfvars filesService principal client secrets in YAMLStorage account access keys in Terraform codeUse workload identity and RBAC instead
Azure Key Vault feeds variable groups; pipeline exposes TF_VAR secrets to Terraform without committing credentials

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.

Pipeline Failure Modes vs FixesState lock timeoutOne pipeline per state keyApply without saved planPublish plan artifactStale provider cacheCommit lock file + pin versionsWrong service connectionScoped RBAC per environment
Four recurring Terraform with Azure DevOps Pipelines failures and the production fix for each

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.hcl and 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

It runs terraform init, plan, and apply inside Azure DevOps YAML stages, stores state in Azure Storage, authenticates through an Azure Resource Manager service connection, and gates production applies behind manual environment approval.

A pipeline gives repeatable agent images with pinned Terraform and provider versions, pull-request plans you can review before merge, environment gates before production apply, central secret storage through Key Vault-linked variable groups, and a full audit trail of who approved what and which Git commit triggered the run. On client projects I have kept the same discipline as application deployments: modules in Git, state in a locked backend, and no laptop applies to production. That replaces ad-hoc portal changes with auditable infrastructure runs tied to commits.

Remote state is non-negotiable for team pipelines. Provision a dedicated resource group, storage account, and blob container once, lock the account down with private access where policy allows, then point backend.tf at that azurerm backend with a distinct key per stack such as prod/app.tfstate and staging/app.tfstate. The pipeline passes backendResourceGroup, backendStorageAccount, backendContainer, and backendKey into TerraformTaskV4 init on every plan and apply stage. Without remote state, concurrent applies corrupt state and you lose the single source of truth.

Grant the service principal Contributor on the resource groups Terraform manages, Storage Blob Data Contributor on the state storage account so init and apply can read and write blob state, and Key Vault Secrets User if pipelines pull secrets from Vault into variable groups. Prefer workload identity federation over long-lived client secrets when Azure DevOps offers it. Name the connection explicitly, for example sc-azure-prod-contributor, and reference that exact name in YAML. Scope prod and non-prod to separate connections rather than one subscription-wide Contributor login.

Yes. The Terraform task supports multiple providers. Install the relevant cloud credentials as secret variables or use OIDC federation, and keep a separate state backend per cloud. On multi-cloud work, use separate pipeline stages per provider with distinct service connections and backends. Do not share one apply job across Azure, AWS, and GCP; that pattern avoids crossed credentials and makes blast-radius boundaries obvious during review.

Split work into three stages. Validate runs terraform fmt -check, init -backend=false, and validate on every qualifying commit or pull request. Plan runs init against the remote backend, then plan with -out saved as a published tfplan artifact. Apply downloads that artifact, re-inits, and applies the exact saved plan file. Limit Apply to main with a succeeded Plan dependency, and attach the deployment job to an Azure DevOps Environment such as production-infra with a manual approval check. Pull requests should stop at Plan; only merges to main reach Apply.

For Microsoft-hosted agents, start with TerraformTaskV4 because it wires Azure auth and plan output handling quickly. Move to a bash script with a pinned CLI when you need full shell control, custom provider mirror settings, or the marketplace extension lags a Terraform release you must ship. Self-hosted agents often pre-install Terraform and provider plugins to cut init time; scripts fit that model well. Either way, pin the CLI through TerraformInstaller or your own download so agent image updates do not silently change provider behaviour.

Never commit secrets in tfvars files. Link an Azure DevOps variable group to Key Vault for database passwords, API keys, and third-party tokens, then expose pipeline variables prefixed with TF_VAR_ so Terraform picks them up automatically, for example TF_VAR_db_admin_password as a secret and TF_VAR_environment as a non-secret value like prod. Reference -var-file=environments/$(TF_VAR_environment).tfvars in plan commandOptions. Keep environment differences in tfvars and backend keys, not forked copies of main.tf, so reviewers see intent quickly during pull request review.

Create an Azure DevOps Environment named production-infra under Pipelines → Environments and add an approval check assigned to your infra lead or platform team. Model Apply as a deployment job with environment: production-infra, dependsOn Plan, and a condition that runs only when the source branch is main and Plan succeeded. That gate sits alongside the saved-plan workflow: humans review the plan output on the pull request, merge to main, then explicitly approve before any live resource change. Skipping the Environment turns production apply into an automated foot-gun.

Plan and apply on different commits invite drift. Someone can merge another infrastructure pull request between stages, so a newly generated plan may differ from what reviewers approved. The published tfplan artifact is the contract: Plan writes it with -out, publish stores it, Apply downloads and passes that exact file to terraform apply. This mirrors production infrastructure-as-code practice I use elsewhere — the approved plan is what ships, not whatever the agent recalculates minutes later. Breaking that link is one of the most common operational failures I troubleshoot.

Two pipelines applying the same backend key at once produce lock errors because Azure Storage blob locking serialises writers. Enforce branch policies so only one pipeline targets a given state key, and consider lockTimeout in the backend config when legitimate runs queue behind each other. Splitting state files per stack — separate keys for network versus app tiers — reduces contention and blast radius. Read Terraform guidance on managing state safely before multiplying pipelines against one key; parallel infra jobs without coordination look fine until both reach apply.

Without path filters, every application commit rebuilds infrastructure, wasting agent minutes and increasing accidental apply risk. Scope trigger and pull request paths to infra/** unless you intentionally couple app and infra releases. The example pipeline triggers on main and pull requests only when files under infra/ change. Booking platforms I have worked on run application CI/CD separately from infra pipelines: Terraform provisions networking and compute while app pipelines deploy application releases on top. Keep those concerns split unless a single atomic release truly requires both.

Set terraformVersion in pipeline variables, for example 1.9.8, and install it with TerraformInstaller@1 on every job. In providers.tf declare required_version >= 1.5.0 and azurerm version ~> 4.0, then commit .terraform.lock.hcl to Git. Run terraform providers lock in CI if agents span Linux and Windows. Treat lock-file pull requests like production changes: require plan stage success and human review before merge when Renovate or Dependabot bumps the azurerm provider, because upgrades can recreate resources. Pinning stops agent image updates from silently shifting behaviour.

The sample azure-pipelines.yml sets terraformVersion to 1.9.8 and installs it through TerraformInstaller@1 on validate, plan, and apply jobs.

Organise under infra/ with modules/ for reusable pieces such as network and app-service, environments/ holding prod.tfvars and staging.tfvars, plus root files main.tf, variables.tf, outputs.tf, backend.tf, and providers.tf at the infra level. Reuse modules rather than copying main.tf per environment. Keep environment differences in tfvars and distinct backend keys, not duplicated stacks. That layout lets pull request reviewers see which module changed, which variables differ, and which state file a pipeline run will touch before they approve plan output.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: