Audience: Software engineers starting with Infrastructure as Code
Goal: Understand Terraform fundamentals, configure Azure, and deploy your first resources
Terraform is an Infrastructure as Code (IaC) tool by HashiCorp. You describe your infrastructure in declarative configuration files (.tf), and Terraform figures out how to create, update, or destroy the real resources to match.
Key idea: You write what you want, not how to do it.
"I want a Resource Group, a Virtual Network, and an App Service in West Europe"
↓ terraform apply
Azure creates exactly that. Every time. Reproducibly.
Azure has its own native IaC tools. Before choosing Terraform, you should know what they are:
ARM Templates (Azure Resource Manager Templates) are Azure's original IaC format. You write JSON files that describe resources, and Azure Resource Manager deploys them directly. They work, but the JSON syntax is extremely verbose and hard to read — a simple VM can be 200+ lines of nested JSON with cryptic function syntax like [concat(variables('vmName'), '-nic')].
Bicep is Microsoft's answer to the ARM readability problem. It's a domain-specific language (DSL) that compiles down to ARM JSON behind the scenes, but with a clean, concise syntax similar to Terraform's HCL. It's first-party, tightly integrated with Azure, and requires no state file because Azure Resource Manager itself tracks what exists.
| Terraform | Bicep | ARM Templates | |
|---|---|---|---|
| Language | HCL (HashiCorp Configuration Language) | Bicep DSL | Raw JSON |
| Readability | Clean, expressive | Clean, expressive | Verbose, hard to maintain |
| Cloud scope | Multi-cloud (Azure, AWS, GCP, 4000+ providers) | Azure only | Azure only |
| State management | Explicit state file (local or remote) | Stateless — Azure RM tracks resources | Stateless — Azure RM tracks resources |
| Change preview | terraform plan (detailed diff) |
what-if (similar but less mature) |
what-if |
| Modularity | Modules (registry + local) | Modules (registry + local) | Linked/nested templates (painful) |
| Learning curve | Medium | Low (if Azure-only) | High (JSON complexity) |
| Best fit | Multi-cloud, hybrid, large teams | Pure Azure shops, MS-centric teams | Legacy — prefer Bicep for new projects |
Our choice: Terraform. We work across cloud providers and need the state-driven workflow (plan → apply → destroy) with locking and team collaboration. If your world is 100% Azure, Bicep is a solid alternative — but Terraform's portability and ecosystem are hard to beat.
| Concept | What it means |
|---|---|
| Provider | Plugin that talks to a cloud API (e.g., azurerm) |
| Resource | A single infrastructure object (e.g., a VM, a database) |
| Data Source | Read-only query to fetch existing infrastructure info |
| Variable | Input parameter to make configs reusable |
| Output | Exported value after apply (e.g., an IP address) |
| Module | Reusable package of Terraform configuration |
| State | Terraform's record of what it manages (the source of truth) |
DEVELOPER A DEVELOPER B
┌──────────────────────┐ ┌──────────────────────┐
│ .tf files │ │ .tf files │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ TERRAFORM CORE │ │ │ │ TERRAFORM CORE │ │
│ └───────┬────────┘ │ │ └───────┬────────┘ │
└──────────┼───────────┘ └──────────┼───────────┘
│ │
│ CI/CD PIPELINE │
│ (GitHub Actions / │
│ Azure DevOps) │
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ AZURE CLOUD │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ AZURE BLOB STORAGE (Remote State) │ │
│ │ Storage Account: stterraformstate │ │
│ │ Container: tfstate │ │
│ │ │ │
│ │ ┌───────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ terraform.tfstate│ │ STATE FEATURES │ │ │
│ │ │ (JSON) │ │ │ │ │
│ │ │ │ │ 🔒 Locking (Blob Lease) │ │ │
│ │ │ Maps .tf code ◄──┼──▶ 📋 Versioning │ │ │
│ │ │ to real Azure │ │ 🔐 Encryption at rest │ │ │
│ │ │ resource IDs │ │ 🌐 Shared across team │ │ │
│ │ └───────────────────┘ └──────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ Azure Provider │────────▶│ AZURE RESOURCE MANAGER │ │
│ │ (azurerm) │ API │ │ │
│ └──────────────────┘ calls │ ┌──────────────────────┐ │ │
│ │ │ Resource Group │ │ │
│ │ │ App Service Plan │ │ │
│ │ │ Linux Web App │ │ │
│ │ │ SQL Database │ │ │
│ │ │ VNet / Subnet │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
How it flows:
- You write
.tffiles describing desired infrastructure - On
terraform planorapply, Terraform acquires a lock on the remote state (Blob Lease) — no one else can modify it simultaneously - Terraform Core pulls the state from Azure Blob Storage and reads your
.tffiles - It computes a diff (plan) between desired state (
.tf), known state (.tfstate), and actual Azure resources - The Azure Provider translates the plan into Azure ARM API calls
- After apply, the updated state is pushed back to Azure Blob Storage and the lock is released
This is not optional in a real project — it's a day-one decision. Here's why:
| Without remote state (local) | With Azure Blob remote state |
|---|---|
State lives on one laptop — if that laptop dies, you've lost track of what Terraform manages. Recovery is painful manual import of every resource. |
State is stored in durable Azure Storage with redundancy (LRS/GRS). Survives any single machine failure. |
Two developers run apply at the same time → state corruption, resources created twice or partially. |
Blob Lease locking — Terraform automatically acquires/releases a lease. Second developer gets a clear error: "state is locked by another operation". |
| CI/CD pipeline can't access the state file sitting on someone's machine. | Any authenticated client (developer, pipeline, service principal) can access the same state. |
| State file contains secrets in plaintext (database passwords, connection strings) on a local disk. | Azure Storage provides encryption at rest by default. Access controlled via Azure RBAC or storage keys. |
| No history — overwrite is permanent. | Blob versioning lets you recover a previous state if something goes wrong. |
| No way to share outputs (e.g., VNet ID) between separate Terraform projects. | terraform_remote_state data source lets Project B read outputs from Project A's state — enabling cross-project references. |
Rule of thumb: If more than one person or one pipeline will ever touch this infrastructure, use remote state. Set it up before writing your first resource.
# Terraform (macOS)
brew install terraform
# Terraform (Linux/WSL)
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Azure CLI
brew install azure-cli # macOS
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash # Linux
# Verify
terraform -version
az --version# Interactive login (dev workstation)
az login
# Set your target subscription
az account set --subscription "MY-SUBSCRIPTION-ID"
# Verify
az account show --output tableFor CI/CD, use a Service Principal instead:
az ad sp create-for-rbac --name "terraform-sp" --role Contributor \ --scopes /subscriptions/MY-SUBSCRIPTION-IDThen export
ARM_CLIENT_ID,ARM_CLIENT_SECRET,ARM_TENANT_ID,ARM_SUBSCRIPTION_ID.
my-project/
├── main.tf # Core resources
├── variables.tf # Input variable declarations
├── outputs.tf # Output values
├── terraform.tfvars # Variable values (DO NOT commit secrets)
├── providers.tf # Provider configuration
├── backend.tf # Remote state configuration
└── .gitignore # Must include: *.tfstate, *.tfstate.*, .terraform/
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "West Europe"
}
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
default = "dev"
}resource "azurerm_resource_group" "main" {
name = "rg-myapp-${var.environment}"
location = var.location
}
resource "azurerm_service_plan" "main" {
name = "asp-myapp-${var.environment}"
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-${var.environment}"
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 {
docker_image_name = "nginx:latest"
docker_registry_url = "https://index.docker.io"
}
}
}output "app_url" {
value = "https://${azurerm_linux_web_app.main.default_hostname}"
}
output "resource_group_id" {
value = azurerm_resource_group.main.id
}subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
environment = "dev"
location = "West Europe"┌─────────────────────────────────────────────────────────┐
│ TERRAFORM WORKFLOW │
│ │
│ terraform init Download providers & modules │
│ │ Set up backend │
│ ▼ │
│ terraform plan Preview changes (dry run) │
│ │ Shows: +create ~update -destroy │
│ ▼ │
│ terraform apply Execute the plan │
│ │ Creates/modifies real resources │
│ ▼ │
│ terraform destroy Tear down everything │
│ (use with caution!) │
└─────────────────────────────────────────────────────────┘
| Command | What it does | When to use |
|---|---|---|
terraform init |
Downloads providers, initializes backend | First time, or after adding a provider/module |
terraform plan |
Shows what will change (add/modify/destroy) | Always before apply |
terraform apply |
Executes changes against Azure | When plan looks correct |
terraform destroy |
Removes all managed resources | End of life / cleanup |
terraform fmt |
Auto-formats .tf files |
Before every commit |
terraform validate |
Checks syntax and internal consistency | During development |
terraform output |
Shows output values | To get IPs, URLs, IDs |
terraform state list |
Lists all resources in state | Debugging / inspecting |
terraform state show <resource> |
Shows details of one resource | Debugging |
terraform import <addr> <id> |
Imports an existing Azure resource into state | Adopting existing infra |
terraform taint <resource> |
Marks a resource for recreation on next apply | Force replacement |
# 1. Initialize
terraform init
# 2. Check what will happen
terraform plan -out=tfplan
# 3. Apply the saved plan
terraform apply tfplan
# 4. Get your app URL
terraform output app_urlState is a JSON file (terraform.tfstate) that maps your .tf code to real Azure resources. It stores:
- The resource IDs Terraform created
- Current attribute values
- Dependency graph between resources
- Metadata (provider versions, serial number)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ .tf files │ │ STATE │ │ AZURE │
│ (desired) │ │ (known) │ │ (actual) │
│ │ │ │ │ │
│ resource_grp │◄────▶│ id: /sub/.. │◄────▶│ Real RG │
│ app_service │◄────▶│ id: /sub/.. │◄────▶│ Real App │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└──────── terraform plan compares all three ─┘
- Never edit state manually — use
terraform statecommands - Never lose state — you lose track of what Terraform manages
- Never commit state to git — it contains secrets (connection strings, passwords)
By default, state lives as a file on your machine: terraform.tfstate. This is fine for learning, but dangerous for teams.
For team work, state must be shared and locked. On Azure, use an Azure Storage Account:
┌──────────────────────────────────────────────────────┐
│ REMOTE STATE │
│ │
│ Developer A ──┐ │
│ ├──▶ Azure Blob Storage ◀── CI/CD │
│ Developer B ──┘ (terraform.tfstate) │
│ 🔒 State Locking │
│ 📋 Versioning │
│ 🔐 Encryption at rest │
└──────────────────────────────────────────────────────┘
Why remote state matters:
| Problem with local state | Remote state fixes it |
|---|---|
| Two people apply at once → corruption | Locking — only one operation at a time |
| State on one laptop → bus factor | Shared — anyone on the team can apply |
| Lost laptop → lost state | Durable — backed by Azure storage |
| Secrets in plaintext on disk | Encrypted — Azure handles encryption |
Step 1 — Create the storage (one-time, via CLI or a separate TF config):
# Create resource group for state
az group create --name rg-terraform-state --location westeurope
# Create storage account (name must be globally unique)
az storage account create \
--name stterraformstate42 \
--resource-group rg-terraform-state \
--sku Standard_LRS \
--encryption-services blob
# Create container
az storage container create \
--name tfstate \
--account-name stterraformstate42Step 2 — Configure backend in your project:
# backend.tf
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate42"
container_name = "tfstate"
key = "myapp/dev.terraform.tfstate"
}
}Step 3 — Migrate:
terraform init -migrate-state
# Terraform will ask to copy local state to remote. Say yes.variable "app_settings" {
type = map(string)
default = {
"ENVIRONMENT" = "dev"
}
}locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project
}
}variable "app_names" {
default = ["api", "web", "worker"]
}
resource "azurerm_linux_web_app" "apps" {
for_each = toset(var.app_names)
name = "app-${each.key}-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.main.id
site_config {}
}data "azurerm_client_config" "current" {}
output "current_tenant_id" {
value = data.azurerm_client_config.current.tenant_id
}| Pitfall | Fix |
|---|---|
Forgetting terraform init after adding a provider |
Always run init when config changes |
| Editing resources manually in Azure Portal | Terraform won't know → state drift. Run terraform plan to detect |
Committing .tfstate to Git |
Add to .gitignore immediately |
| Hardcoding values | Use variables and terraform.tfvars |
| No remote state in a team | Set up Azure backend from day one |
| Destroying prod by accident | Use workspaces or separate state files per environment |
| Provider version not pinned | Always use version = "~> X.0" constraints |
# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!example.tfvars
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraform.lock.hcl- Try it — Deploy the example from Section 5 to your Azure subscription
- Add a database — Try
azurerm_mssql_server+azurerm_mssql_database - Modules — Extract reusable blocks into
modules/directories - CI/CD — Run
terraform planin a pipeline (Azure DevOps or GitHub Actions) - Read the docs — registry.terraform.io/providers/hashicorp/azurerm
Last updated: March 2026 — Terraform >= 1.5, AzureRM provider ~> 4.0