Skip to content

Terraform for Azure Infrastructure — A Practical Primer

Infrastructure as Code isn’t about writing YAML for fun — it’s about making your infrastructure reviewable, repeatable, and recoverable. Terraform is the industry standard for multi-cloud IaC, and it pairs well with Azure.

  • Multi-cloud: One language for Azure, AWS, and everything else
  • State management: Terraform tracks what exists vs. what you declared
  • Plan before apply: terraform plan shows exactly what will change before anything happens
  • Ecosystem: Thousands of providers and modules

That said, Bicep is excellent if you’re Azure-only. Terraform wins when you span clouds or want portable skills.

FeatureTerraformBicepARM JSON
LanguageHCLBicep DSLJSON
Multi-cloud✅ Yes❌ Azure only❌ Azure only
State managementExplicit (.tfstate)None (Azure handles it)None (Azure handles it)
Plan previewterraform planaz deployment what-ifaz deployment what-if
MaturityVery matureMatureLegacy
Learning curveMediumLow (for Azure devs)High
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
provider "azurerm" {
features {}
}

Golden rule: Never store state locally for anything beyond experiments. Use a remote backend from day one.

resource "azurerm_resource_group" "main" {
name = "rg-myapp-prod"
location = "Australia East"
}
resource "azurerm_service_plan" "main" {
name = "asp-myapp-prod"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
os_type = "Linux"
sku_name = "B1"
}
resource "azurerm_linux_web_app" "main" {
name = "app-myapp-prod"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.main.id
site_config {
application_stack {
dotnet_version = "9.0"
}
}
}
resource "azurerm_kubernetes_cluster" "main" {
name = "aks-myapp-prod"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = "myapp"
default_node_pool {
name = "default"
node_count = 2
vm_size = "Standard_D2s_v3"
}
identity {
type = "SystemAssigned"
}
}
variable "environment" {
type = string
default = "prod"
}
locals {
resource_prefix = "myapp-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
infrastructure/
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars
└── modules/
├── networking/
├── compute/
└── database/

Modules keep things DRY. Start with a flat structure and extract modules when you see repetition across environments.

Terminal window
terraform init # download providers, init backend
terraform plan # preview changes (always review this)
terraform apply # apply changes
terraform destroy # tear down (careful!)

In CI/CD: Run plan on PR, apply on merge to main. Store the plan file as an artifact so the apply matches exactly what was reviewed.

  1. Forgetting state locking — Use blob lease (Azure) or DynamoDB (AWS). Concurrent applies without locking corrupt your state file
  2. Secrets in state — Sensitive values land in .tfstate in plaintext. Encrypt your remote backend and restrict access to the storage account
  3. Massive single state file — Split state by service boundary (networking, compute, database). Large files slow down plan/apply and increase blast radius
  4. Skipping plan review — The one time you skip it is the time it deletes your database. Red - lines deserve scrutiny
  5. No prevent_destroy — Add lifecycle { prevent_destroy = true } to databases, storage accounts, and anything critical
  6. Provider version drift~> 4.0 pins to 4.x. Without pinning, a major provider bump can break your plan on next terraform init

Terraform + Azure is a deep topic. This covers the foundation — enough to provision real infrastructure and not shoot yourself in the foot.