Skip to content
All Posts
Terraform Azure IaC DevOps

Getting Started with Terraform on Azure — A Practical Guide

January 2026 · 9 min read

Infrastructure as Code sounds great in theory. In practice, the learning curve can be steep. Here's a practical walkthrough of how I use Terraform to manage Azure resources — from project structure to state management to the gotchas nobody warns you about.

Why Terraform (Not Bicep or ARM)

Azure has Bicep, and it's excellent if you're 100% Azure. I chose Terraform because:

Project Structure

infra/
├── environments/
│   ├── staging/
│   │   ├── main.tf          # Module calls with staging values
│   │   ├── backend.tf       # Remote state config (Azure Storage)
│   │   └── terraform.tfvars # Staging-specific variables
│   └── production/
│       ├── main.tf
│       ├── backend.tf
│       └── terraform.tfvars
├── modules/
│   ├── networking/
│   │   ├── main.tf          # VNet, subnets, NSGs
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── compute/
│   │   ├── main.tf          # VMs, scale sets
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── iot/
│       ├── main.tf          # IoT Hub, DPS, storage
│       ├── variables.tf
│       └── outputs.tf
└── README.md

The key pattern: modules define reusable infrastructure components, environments compose them with different parameters. This lets staging and production share the same infrastructure logic but differ in size, SKU, and networking rules.

Remote State with Azure Storage

Storing Terraform state locally is fine for experiments, but for team work you need remote state with locking:

# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "tfstate"
    key                  = "staging.tfstate"
  }
}

I provisioned the storage account manually (one-time bootstrap) and enabled blob versioning for state file history. This way, if a terraform apply corrupts state, we can revert to a previous version from Azure Storage.

A Real Module: IoT Hub

# modules/iot/main.tf
resource "azurerm_iothub" "main" {
  name                = var.iot_hub_name
  resource_group_name = var.resource_group_name
  location            = var.location

  sku {
    name     = var.sku_name
    capacity = var.sku_capacity
  }

  endpoint {
    type              = "AzureIotHub.StorageContainer"
    name              = "telemetry-archive"
    connection_string = var.storage_connection_string
    container_name    = "telemetry"
    encoding          = "JSON"
    batch_frequency   = 300
    max_chunk_size    = 10485760
  }

  route {
    name           = "telemetry-to-storage"
    source         = "DeviceMessages"
    condition      = "true"
    endpoint_names = ["telemetry-archive"]
    enabled        = true
  }

  tags = var.tags
}

# modules/iot/variables.tf
variable "iot_hub_name" {
  type        = string
  description = "Name of the IoT Hub instance"
}

variable "sku_name" {
  type    = string
  default = "S1"
}

variable "sku_capacity" {
  type    = number
  default = 1
}

The module creates an IoT Hub with a built-in route that archives all device messages to Azure Storage as JSON. In staging, we use S1 with capacity 1; production uses S2 with capacity 4.

CI/CD Integration

We run Terraform through our Jenkins pipeline (see my Jenkins post for the full setup):

# Simplified CI flow
terraform init
terraform validate
terraform plan -out=tfplan

# Human reviews the plan output, then:
terraform apply tfplan

The -out=tfplan flag is critical — it ensures the apply step executes exactly the plan that was reviewed, not whatever the current state happens to be when someone hits “approve.”

Gotchas & Lessons