Terraform on Azure

Multi-cloud IaC, state, plan/apply.

Intermediate30 min · lesson 8 of 15

If Bicep is the architect who only ever works in one city, Terraform is the general contractor who will build anywhere: Azure, AWS, Google Cloud. It has two house rules. Nothing gets built until it has been drawn, and every finished job goes into an as-built ledger. The drawings are your .tf files, written in HCL (HashiCorp Configuration Language, HashiCorp's own language for describing infrastructure). The ledger is state, a JSON (JavaScript Object Notation, a plain-text data format) file that maps each resource block in your code to the real resource ID in Azure. Before it touches anything, Terraform reads both, calls Azure to see what actually exists, and hands you a punch list called the plan. You approve the punch list first. That approve-then-build loop is why Terraform sits so comfortably inside a pull-request workflow.

State: the map between code and reality

Run terraform apply and Terraform writes down what it built: the Azure resource ID of each thing, its current attributes, and what depends on what. The next terraform plan does three jobs in order. It reads that record. It calls the Azure Resource Manager APIs (Azure Resource Manager, or ARM, is the front door every change to Azure goes through) to refresh what each recorded resource looks like right now. Then it compares both against your HCL. The plan you read is that three-way comparison. Lose the state and Terraform forgets it ever owned anything. Let two people write it at the same moment and it corrupts. So state does not live on a laptop. It lives in a remote backend, which on Azure means a blob in a Storage account, and that backend also gives you locking: Terraform takes a blob lease (a short, exclusive claim on the file) before writing, so two applies cannot interleave. One change in the v4 generation of the provider is worth remembering. The azurerm provider now insists you name the subscription explicitly, usually through the ARM_SUBSCRIPTION_ID environment variable in a pipeline. That is a deliberate guard against applying to the wrong subscription.

versions.tf
# Pin Terraform + provider; state goes to a hardened Storage backend
terraform {
required_version = ">= 1.9.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstateprod001" # shared-key access disabled
container_name = "tfstate"
key = "shop/prod.tfstate"
use_azuread_auth = true # Entra ID RBAC, not storage keys
}
}
provider "azurerm" {
features {}
# v4.x: subscription comes from ARM_SUBSCRIPTION_ID in the pipeline
}
# $ terraform init
#
# Initializing the backend...
#
# Successfully configured the backend "azurerm"! Terraform will automatically
# use this backend unless the backend configuration changes.
# Initializing provider plugins...
# - Finding hashicorp/azurerm versions matching "~> 4.0"...
# - Installing hashicorp/azurerm v4.31.0...
# - Installed hashicorp/azurerm v4.31.0 (signed by HashiCorp)
#
# Terraform has been initialized successfully!
Treat Terraform state as a secrets file
State keeps resource attributes in plain text, including the ones you tagged sensitive: storage account keys, connection strings, the lot. Marking a value sensitive blanks it out of terminal output and does nothing else. The file itself is untouched. So never commit *.tfstate or *.tfstate.backup, and add both to .gitignore. Harden the backend instead. Set use_azuread_auth = true and disable shared-key access on the storage account, give the pipeline identity Storage Blob Data Contributor on that one container and nothing wider, and switch on blob versioning plus soft delete so a corrupted or deleted state can be rolled back. The saved plan file (tfplan) carries the same secrets, so keep pipeline artifact retention short and never attach it anywhere public.

Provision an App Service with a staging slot

A deployment slot is a second live copy of your App Service, like an identical shop unit next door with its own address and its own signage. This course keeps coming back to it. You deploy to the slot, test it, then swap it into production, and because those workers are already warm there is no cold start. Here is the catch. Most app settings travel with the code during a swap, which is exactly backwards for any setting that describes where the app is running rather than what was built. Azure calls those sticky settings (slotSetting: true), and they stay pinned to the slot they were set on. The usual two are the Application Insights connection string (staging telemetry has no business in production dashboards) and any ENVIRONMENT-style flag. Terraform gives you sticky_settings as a real block on the web app resource. Underneath it writes ARM's slotConfigNames, which is why the block belongs on the production app and not on the slot. The pinning then gets code-reviewed, instead of living as a portal checkbox someone forgets to tick.

main.tf
resource "azurerm_resource_group" "rg" {
name = "rg-shop-prod"
location = "westeurope"
}
resource "azurerm_service_plan" "plan" {
name = "asp-shop-prod"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
os_type = "Linux"
sku_name = "P1v3" # slots need Standard tier or above
}
resource "azurerm_linux_web_app" "app" {
name = "app-shop-prod"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
service_plan_id = azurerm_service_plan.plan.id
site_config {}
app_settings = {
ENVIRONMENT = "production"
APPLICATIONINSIGHTS_CONNECTION_STRING = var.appinsights_connection_string
}
sticky_settings { # pinned to the slot — these do NOT travel on swap
app_setting_names = [
"ENVIRONMENT",
"APPLICATIONINSIGHTS_CONNECTION_STRING",
]
}
}
resource "azurerm_linux_web_app_slot" "staging" {
name = "staging"
app_service_id = azurerm_linux_web_app.app.id
site_config {}
app_settings = {
ENVIRONMENT = "staging"
}
}
# $ terraform plan -out=tfplan
#
# Terraform will perform the following actions:
#
# # azurerm_linux_web_app.app will be created
# + resource "azurerm_linux_web_app" "app" {
# + name = "app-shop-prod"
# + service_plan_id = (known after apply)
# + sticky_settings {
# + app_setting_names = ["ENVIRONMENT", ...]
# }
# }
# ...
# Plan: 4 to add, 0 to change, 0 to destroy.
# Saved the plan to: tfplan

Bicep or Terraform?

The previous lesson's Bicep raises the obvious question: which one do you pick? Bicep compiles down to ARM templates, so it can use every new Azure feature on the day it ships, and there is *no state file at all*. Azure Resource Manager itself is the record, and az deployment group what-if gives you a preview that reads much like a plan. Nothing to lose, nothing to leak, nothing to lock. Terraform buys you one language and one workflow across Azure, AWS, GitHub, Datadog, Cloudflare, anything with a provider, plus a large registry of ready-made modules. The bill for that is state. Storing it, securing it and recovering it are now your problems. New Azure features can also lag by days or weeks in the azurerm provider, and the escape hatch there is the azapi provider, which calls the ARM APIs directly. A rule you can defend in a design review: Azure-only estate, use Bicep; multi-cloud estate, or a team already fluent in Terraform, use Terraform. Treating the choice as a matter of faith helps nobody. The pipeline discipline you wrap around either tool matters far more than which tool you picked.

main.bicep
// Same service plan in Bicep — no state file: ARM is the source of truth
resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'asp-shop-prod'
location: 'westeurope'
sku: { name: 'P1v3' }
kind: 'linux'
properties: { reserved: true }
}
// $ az deployment group what-if -g rg-shop-prod -f main.bicep
// + Microsoft.Web/serverfarms/asp-shop-prod
// Resource changes: 1 to create.
//
// $ az deployment group create -g rg-shop-prod -f main.bicep \
// --query properties.provisioningState -o tsv
// Succeeded

Plan on pull request, apply on merge

Never run terraform apply from a laptop against production. The pattern that survives contact with a real team: plan on the pull request, apply on merge, gate the apply behind a human. On a pull request the pipeline runs terraform plan -out=tfplan, publishes the binary plan as an artifact, and reviewers read the plan output in the run log the same way they read a code diff. Azure DevOps has one wrinkle here. The pr: keyword in YAML only fires for repos hosted on GitHub or Bitbucket, so if your code sits in Azure Repos Git you get pull-request validation by adding a build-validation branch policy on main instead. Before the plan runs, scan the HCL with Checkov or Trivy (the successor to tfsec) so misconfigurations like a public storage account or a missing HTTPS setting get caught while they are still text on a screen. On merge to main, the apply stage targets an Azure DevOps environment carrying an approval check, and it applies the exact saved tfplan. What runs is byte-for-byte what the approver read, and Terraform refuses a stale plan if state moved underneath it. Authentication is where teams get careless. Use a service connection with workload identity federation, so Azure DevOps trades a short-lived OIDC token (OpenID Connect, a standard way for one system to prove who it is without a stored password) for access instead of keeping a client secret on file. Setting addSpnToEnvironment: true hands that token to your script as $idToken.

azure-pipelines.yml
# Plan on PR, gated apply on main
trigger:
branches: { include: [main] }
pr: # honored for GitHub/Bitbucket repos only —
branches: { include: [main] } # Azure Repos Git ignores pr: and needs a
# build-validation branch policy on main
stages:
- stage: Plan
jobs:
- job: plan
pool: { vmImage: ubuntu-latest }
steps:
- task: AzureCLI@2
displayName: checkov + terraform plan
inputs:
azureSubscription: sc-shop-prod # workload identity federation
scriptType: bash
scriptLocation: inlineScript
addSpnToEnvironment: true # exposes $idToken to the script
inlineScript: |
pipx install checkov && checkov -d . --quiet
export ARM_USE_OIDC=true ARM_CLIENT_ID=$servicePrincipalId
export ARM_OIDC_TOKEN=$idToken ARM_TENANT_ID=$tenantId
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
terraform init -input=false
terraform plan -out=tfplan -input=false
- publish: tfplan
artifact: tfplan
- stage: Apply
dependsOn: Plan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: apply
pool: { vmImage: ubuntu-latest }
environment: prod-infra # approval check lives here
strategy:
runOnce:
deploy:
steps:
- checkout: self
- download: current
artifact: tfplan
- task: AzureCLI@2
displayName: terraform apply (reviewed plan)
inputs:
azureSubscription: sc-shop-prod
scriptType: bash
scriptLocation: inlineScript
addSpnToEnvironment: true
inlineScript: |
# ...same four ARM_* exports as the Plan stage...
terraform init -input=false
terraform apply -input=false "$(Pipeline.Workspace)/tfplan/tfplan"
# Queue it from the CLI and watch:
# $ az pipelines run --name infra-shop --branch main -o table
# Run ID Number Status Result Pipeline ID Pipeline Name Source Branch Queued Time Reason
# -------- ----------- ---------- -------- ------------- --------------- --------------- -------------------------- --------
# 4812 20260714.2 notStarted 42 infra-shop main 2026-07-14 09:14:33.290433 manual
#
# Apply-stage log, after the prod-infra approval:
# azurerm_linux_web_app_slot.staging: Creating...
# azurerm_linux_web_app_slot.staging: Creation complete after 47s
# Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Plan on PR, gated apply on merge
1PR opened
Checkov scan + terraform plan
2tfplan artifact
reviewed diff, saved binary plan
3approval gate
environment check on prod-infra
4terraform apply
state updated under blob lease
5nightly drift plan
-detailed-exitcode: 2 = drift
Apply consumes the exact tfplan that was reviewed. Terraform refuses a stale plan, so nothing unreviewed can slip in.

Swap the slot, keep the sticky settings

Terraform's job stops at provisioning. The release pipeline, covered later in *Zero-downtime deployment*, ships each build to the staging slot, smoke-tests it and swaps. Run the swap by hand once anyway. It is the only way to prove the sticky settings you declared behave the way you claimed they would. During a swap, App Service warms the staging workers with production's settings already applied, then repoints front-end routing. The code moves across. The sticky settings stay exactly where they were.

slot-swap.sh
# Deploy the new build to staging, warm it, then swap into production
az webapp deployment slot swap \
--resource-group rg-shop-prod --name app-shop-prod \
--slot staging --target-slot production
# (no output; exit 0 — the swap is done when the command returns)
# Which settings are sticky on this app?
az webapp config appsettings list \
-g rg-shop-prod -n app-shop-prod \
--query "[?slotSetting].name" -o tsv
# ENVIRONMENT
# APPLICATIONINSIGHTS_CONNECTION_STRING
# Proof the swap didn't drag staging config into production:
az webapp config appsettings list \
-g rg-shop-prod -n app-shop-prod --slot staging \
--query "[?name=='ENVIRONMENT'].value" -o tsv
# staging <- still says staging: the sticky setting did not swap

One caution that loops straight back to state. If someone flips one of these settings in the portal next month, the following terraform plan reports it as drift, a gap between what state remembers and what Azure actually has. Treat that as the system working, not as noise. A nightly pipeline running terraform plan -detailed-exitcode (exit 0 means clean, 2 means changes are pending) turns an out-of-band edit into an alert instead of a surprise at the worst possible moment. The plan output names the precise attribute that moved, which tells you who to go and ask about it.

Where this goes next

You have the full shape now. HCL reviewed like application code, state locked in a hardened Storage backend, a plan artifact nobody can apply without approval, and sticky settings that stop a swap dragging staging config into production. What is still crude is anything environment-specific. Names, SKUs and regions sit hard-coded in one main.tf, and exactly one value escaped into a variable, var.appinsights_connection_string. Real estates run dev, test and prod from the *same* code with different inputs, and promote one reviewed change through all three. Variable files, variable groups, Key Vault-backed secrets and the promotion flow between environments are where the next lesson, *IaC pipelines & config*, picks up.

Two providers usually appear side by side in an Azure estate. azurerm builds the infrastructure, and azuread builds the directory objects around it: app registrations, groups, service principals. Both write into the same state file, which is why anyone who gets a copy of that file learns your whole layout and sometimes walks away with a working secret too. The blob lease the Storage backend takes is the other half of the story. It is what stops a second run writing over the first one's bookkeeping mid-flight.

Reading a plan well is a skill worth practising. Scan for the word destroy first, then for forces replacement on anything holding data, then check that the counts on the last line match what the pull request claimed to be doing. Federated credentials keep the other half honest: because the pipeline never holds a long-lived client secret, there is nothing sitting in a variable group for a compromised build step to steal and reuse next week.

Try this

In a lab directory, point an azurerm backend at a storage account container, run terraform init and then plan for a single resource group, and save the plan file locally the way a pipeline would publish it as an artifact, so you can read it before anything gets applied.

terminal
terraform init
terraform plan -out tfplan
terraform show -no-color tfplan | head
# Never commit terraform.tfstate — use remote state + locking
output
$ terraform plan -out tfplan
Plan: 1 to add, 0 to change, 0 to destroy.
# Sample output
$ terraform show -no-color tfplan | head
# azurerm_resource_group.lab will be created

Takeaway

Remember: state is the map from your config to real Azure resources, and it can be holding secrets in plain text. Remote backend, locking, tight access, never in Git.

Next: run fmt, validate and plan on every pull request, and let only one controlled pipeline identity apply, with the smallest Azure RBAC (role-based access control) role that can finish the job.

Quick check
01You mark a storage account key sensitive in your Terraform config. A colleague says that makes the state file safe to commit to Git. What actually happens when you run apply?
Incorrect — No. This is the exact belief the lesson is written to kill. sensitive never encrypts or alters the file, so the commit would hand over the key.
Correct — sensitive only blanks what prints to your screen. Keep *.tfstate out of Git and lean on use_azuread_auth with shared-key access disabled.
Incorrect — No. Sensitive attributes stay in the state JSON, and nothing is moved into blob metadata, which is why the backend itself has to be locked down.
Incorrect — No. Terraform knows nothing about Git. Staying out of the repo is on you, via .gitignore entries for *.tfstate and *.tfstate.backup.
02A nightly pipeline runs terraform plan -detailed-exitcode to catch drift. The run finishes with exit code 2. What is it telling you?
Incorrect — No. A failure comes back as exit code 1.
Incorrect — No. A clean run with no changes is exit code 0.
Correct — With -detailed-exitcode, 0 means no changes, 1 means error and 2 means a diff exists, which is what the nightly job alerts on.
Incorrect — No. A blob lease held by another run surfaces as a locking error, not as the exit code that signals a pending diff.
03Your Terraform code lives in an Azure Repos Git repository. You added a pr: trigger to azure-pipelines.yml so terraform plan would run on every pull request, but nothing fires when a PR is opened. What fixes it?
Incorrect — No. A schedule buys you drift detection, not the plan-on-PR review the team asked for.
Correct — Azure Repos Git ignores the YAML pr: trigger, so PR builds have to be configured as a build-validation check in the target branch's policy.
Incorrect — No. The CI trigger is not suppressing anything. Azure Repos never honours pr: either way.
Incorrect — No. Nothing is being blocked by permissions. The trigger type is not supported for this repo host.

Related