Terraform with Azure
Key Points
- Terraform is HashiCorp's declarative IaC tool: HCL files describe desired state;
terraform applyreconciles with the cloud. - Three providers matter on Azure: AzureRM (curated, mature, lags features by months), AzAPI (raw ARM passthrough — Day-1 access to new resources), AzureAD (Entra ID identities, groups, app registrations).
- State is the heart of Terraform — a JSON file mapping HCL to real cloud IDs. Store remotely in Azure Storage with blob lease locking; never commit it.
- Modules + workspaces = composition + per-env separation.
terraform planshows drift;terraform importbrings existing resources under management. - Bicep wins for Azure-only Microsoft-supported shops; Terraform wins for multi-cloud, polyglot teams, or where existing TF expertise lives. See Bicep & IaC.
Concepts (deep dive)
What Terraform is
A binary (terraform) plus a config language (HCL). You write .tf files describing desired infrastructure; Terraform diffs against state, calls provider APIs, and reconciles. Vendor-agnostic — same workflow for Azure, AWS, GCP, Datadog, GitHub.
.tf files (desired) ─┐
├── plan ──► diff ──► apply ──► provider API ──► cloud
state file (actual) ─┘ │
└──► state updated
Provider ecosystem on Azure
| Provider | Source | What it covers | When to use |
|---|---|---|---|
| AzureRM | hashicorp/azurerm | Hand-curated Azure resource types | Default. Stable, well-documented. |
| AzAPI | Azure/azapi | Raw ARM/REST passthrough | New resources / preview features AzureRM hasn't added yet. |
| AzureAD | hashicorp/azuread | Entra ID (groups, apps, SPs) | Identity provisioning. |
| azuredevops | microsoft/azuredevops | ADO projects, pipelines, repos | Bootstrapping ADO itself. |
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azapi = { source = "Azure/azapi", version = "~> 2.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
}
}
provider "azurerm" {
features {}
use_oidc = true # OIDC-from-pipeline; no secrets
subscription_id = var.sub_id
}
Tip: mix providers freely. Use AzureRM for 95% of resources; reach for AzAPI when you need a brand-new feature AzureRM hasn't shipped yet.
State
State is the source of truth Terraform consults to compute diffs. Two storage modes:
- Local (
terraform.tfstateon disk) — fine for solo experiments; never for teams. - Remote — shared backend with locking. On Azure, almost always Azure Storage with blob-lease-based locking.
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstatecorp"
container_name = "tfstate"
key = "platform/prod.tfstate"
use_oidc = true
}
}
Locking: when one engineer runs apply, Terraform takes a lease on the blob; others wait. Without locking, two concurrent applys corrupt state.
State is encrypted at rest by Azure Storage (SSE) and over the wire by HTTPS — but it contains secrets in cleartext (DB passwords, keys). Treat the storage account like a vault: private endpoint, RBAC, no anonymous access, soft delete + versioning enabled.
Drift detection
terraform plan reads state, calls the cloud, and shows differences. If someone clicked something in the portal, plan reveals it:
Choose: re-apply to revert, or update HCL to match reality. Run plan daily in CI to catch drift early.
Workspaces
A workspace is a named state slot. Default is default.
Each workspace = separate state file. Same HCL, different envs. Reference via terraform.workspace:
locals {
env = terraform.workspace
}
resource "azurerm_resource_group" "rg" {
name = "rg-app-${local.env}"
location = var.location
}
💡 In practice many teams prefer separate root modules per env (cleaner isolation, different backend keys) over workspaces. Workspaces are best for short-lived ephemeral envs (PR previews).
Modules
A module is a directory with .tf files exposing variables (inputs) and outputs. Compose root configs from modules.
module "web" {
source = "./modules/webapp"
name = "myapp"
location = var.location
resource_group_name = azurerm_resource_group.rg.name
}
output "app_url" {
value = module.web.app_url
}
Sources: local path, Git, Public Registry (registry.terraform.io), private registries (Azure DevOps Artifacts, Terraform Cloud, Spacelift), or HTTPS URLs. Pin versions:
Azure Verified Modules (AVM) publishes Terraform variants alongside Bicep — use them.
HCL essentials
# Variables
variable "env" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.env)
error_message = "env must be dev/test/prod."
}
}
# Locals (computed; not overridable)
locals {
tags = {
Env = var.env
ManagedBy = "Terraform"
}
}
# for_each (named resources; preferred over count)
resource "azurerm_storage_account" "stg" {
for_each = toset(["logs", "data", "backups"])
name = "stg${each.key}${var.env}"
resource_group_name = azurerm_resource_group.rg.name
location = var.location
account_tier = "Standard"
account_replication_type = "LRS"
tags = local.tags
}
# count (numeric; produces a list)
resource "azurerm_subnet" "sub" {
count = 3
name = "subnet-${count.index}"
# ...
}
# dynamic blocks
resource "azurerm_network_security_group" "nsg" {
name = "nsg-app"
dynamic "security_rule" {
for_each = var.rules
content {
name = security_rule.value.name
priority = security_rule.value.priority
direction = "Inbound"
access = "Allow"
# ...
}
}
}
# Ternary + try
locals {
sku = var.env == "prod" ? "P2v3" : "B1"
port = try(var.config.port, 8080)
}
# Outputs
output "rg_id" {
value = azurerm_resource_group.rg.id
sensitive = false
}
for_each is almost always better than count — moving an item in a count list reindexes everything and forces destroy/recreate.
CI/CD pipeline shape
Standard flow on a PR:
PR opened
├── terraform fmt -check
├── terraform validate
├── tflint
├── tfsec / checkov (security)
├── terraform plan -out tfplan
└── post plan output as PR comment
PR merged to main
├── terraform plan -out tfplan (re-run; state may have moved)
├── manual approval gate (prod only)
└── terraform apply tfplan
Use OIDC federation between Azure DevOps / GitHub Actions and Entra ID — no client secrets in the pipeline.
# Azure DevOps snippet
- task: AzureCLI@2
inputs:
azureSubscription: 'tf-prod-oidc'
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
terraform init
terraform plan -out tfplan
terraform apply tfplan
Import workflow
Existing resource (made by hand or another tool) → bring under Terraform.
Old way (terraform import CLI):
New way (Terraform 1.5+, declarative import block):
import {
to = azurerm_storage_account.stg
id = "/subscriptions/.../storageAccounts/mystg"
}
resource "azurerm_storage_account" "stg" {
name = "mystg"
# ... must match real config
}
Run terraform plan -generate-config-out=generated.tf and Terraform writes the resource block for you. Review, refine, commit, apply.
Security scanning
tfsec(Aqua, free) — fast static analysis on HCL.checkov(Bridgecrew) — broader, supports modules, Bicep, ARM, Kubernetes too.terrascan,KICS— alternatives.
Wire into PR checks. Block HIGH/CRITICAL issues at the gate.
Minimal example: RG + ASP + Web App
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
}
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstatecorp"
container_name = "tfstate"
key = "demo/dev.tfstate"
}
}
provider "azurerm" {
features {}
}
variable "app_name" { type = string }
variable "location" { type = string, default = "eastus" }
variable "env" { type = string, default = "dev" }
locals {
tags = { Env = var.env, ManagedBy = "Terraform" }
}
resource "azurerm_resource_group" "rg" {
name = "rg-${var.app_name}-${var.env}"
location = var.location
tags = local.tags
}
resource "azurerm_service_plan" "asp" {
name = "asp-${var.app_name}-${var.env}"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
os_type = "Linux"
sku_name = var.env == "prod" ? "P1v3" : "B1"
tags = local.tags
}
resource "azurerm_linux_web_app" "app" {
name = "web-${var.app_name}-${var.env}"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_service_plan.asp.location
service_plan_id = azurerm_service_plan.asp.id
identity { type = "SystemAssigned" }
site_config {
application_stack {
dotnet_version = "8.0"
}
always_on = var.env == "prod"
}
tags = local.tags
}
output "app_url" {
value = "https://${azurerm_linux_web_app.app.default_hostname}"
}
terraform init
terraform plan -var app_name=myapp -var env=dev
terraform apply -var app_name=myapp -var env=dev
Bicep vs Terraform
| Aspect | Bicep | Terraform |
|---|---|---|
| Coverage | Azure only | Multi-cloud |
| State | Azure-managed | External file (you own it) |
| Day-1 features | Yes (ARM = source of truth) | AzureRM lags; AzAPI bridges |
| Vendor support | Microsoft 1st-party | HashiCorp + community |
| Syntax | Cleaner for Azure | More verbose, more powerful |
| Modules | Public + private registries (ACR) | Public + private registries |
| Cost | Free | Free OSS; paid TF Cloud/Enterprise |
| Drift | Manual (re-deploy) | plan shows it |
Pick Bicep if Azure is the only cloud and the team is .NET-shaped. Pick Terraform if you have multi-cloud needs, existing TF muscle memory, or like plan diff UX.
Code: correct vs wrong
❌ Wrong: local state on a team
Two engineers apply → corrupt state, lost resources.
✅ Correct: remote state + locking
❌ Wrong: secrets in state-readable variables
State file holds the cleartext.
✅ Correct: pull from Key Vault at apply
data "azurerm_key_vault_secret" "db" {
name = "db-password"
key_vault_id = data.azurerm_key_vault.kv.id
}
resource "azurerm_linux_web_app" "app" {
app_settings = {
"ConnectionStrings__Db" = data.azurerm_key_vault_secret.db.value
}
}
State still has the value — but no plain HCL leak. Even better: use Key Vault references (@Microsoft.KeyVault(...)).
❌ Wrong: count on a list of named things
resource "azurerm_storage_account" "stg" {
count = length(var.names)
name = var.names[count.index]
}
Remove first item → all subsequent storage accounts destroyed/recreated.
✅ Correct: for_each
Stable identity; safe to add/remove.
Design patterns for this topic
Pattern 1 — "Remote state in Azure Storage with locking"
- Intent: team-safe shared state.
Pattern 2 — "Modules + AVM"
- Intent: reuse vetted patterns instead of reinventing.
Pattern 3 — "Plan-on-PR, apply-on-merge with approval gates"
- Intent: review before destructive changes.
Pattern 4 — "OIDC federation, no secrets in CI"
- Intent: zero-credential pipelines.
Pattern 5 — "tfsec / checkov in CI"
- Intent: shift security left.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Terraform | Multi-cloud; mature; strong plan UX | State complexity; you own the backend |
| AzureRM | Curated, stable | Lags new features |
| AzAPI | Day-1 features | Less ergonomic; raw ARM |
| Workspaces | Easy env split | Mixed reputation; many prefer separate roots |
| OSS | Free | No paid support unless TF Cloud/Enterprise |
When to use / when to avoid
- Use for multi-cloud or polyglot infra.
- Use when team already has TF expertise.
- Use when
plandiff UX matters. - Avoid Azure-only greenfield where Bicep is simpler.
- Avoid if no one on the team understands state.
Interview Q&A
Q1. What is Terraform state and why does it matter? JSON map between HCL and real resource IDs. Without it, Terraform can't compute diffs. Must be remote + locked for teams.
Q2. AzureRM vs AzAPI? AzureRM: curated, lags features. AzAPI: raw ARM passthrough; new features Day 1.
Q3. How do you lock state on Azure? backend "azurerm" uses blob lease locking automatically.
Q4. count vs for_each? for_each keys by name → stable identity. count indexes by integer → reindex cascades.
Q5. Workspaces — when? Short-lived ephemeral envs (PR previews). For prod isolation, prefer separate root modules.
Q6. Drift? Resource changed outside Terraform. terraform plan shows the diff.
Q7. Importing existing resources? terraform import CLI or declarative import block (1.5+) with -generate-config-out.
Q8. Bicep vs Terraform? Bicep: Azure-only, Microsoft-native, no state file. Terraform: multi-cloud, state file you own, richer ecosystem.
Q9. How to keep secrets out of state? Pull from Key Vault at apply via data block, or use Key Vault references in app settings.
Q10. CI/CD shape? Plan on PR, apply on merge. Approval gate for prod. OIDC for credentials.
Q11. Security scanning tools? tfsec, checkov, terrascan, KICS.
Q12. AVM? Azure Verified Modules — Microsoft-curated for both Bicep and Terraform.
Gotchas / common mistakes
- ⚠️ Local state on a team — corruption inevitable.
- ⚠️ Secrets in HCL end up in state cleartext.
- ⚠️
counton named lists → reindex cascades. - ⚠️ Unpinned provider/module versions → silent drift.
- ⚠️ No
planreview in CI → surprise destroys. - ⚠️ State file in a public storage account — disaster.
- ⚠️ Mixing AzureRM + AzAPI for the same resource — fights over ownership.