Skip to content

Instantly share code, notes, and snippets.

@samklr
Created March 15, 2026 00:27
Show Gist options
  • Select an option

  • Save samklr/8734b3fef12a3c677c75c2611dc34353 to your computer and use it in GitHub Desktop.

Select an option

Save samklr/8734b3fef12a3c677c75c2611dc34353 to your computer and use it in GitHub Desktop.
Terraform Primer (Azure related)

Terraform on Azure — Condensed Training Guide

Audience: Software engineers starting with Infrastructure as Code
Goal: Understand Terraform fundamentals, configure Azure, and deploy your first resources


1. What is Terraform?

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.

What about ARM Templates and Bicep?

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 vs. ARM vs. Bicep

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.


2. Core Concepts in 60 Seconds

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)

3. Architecture Overview

 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:

  1. You write .tf files describing desired infrastructure
  2. On terraform plan or apply, Terraform acquires a lock on the remote state (Blob Lease) — no one else can modify it simultaneously
  3. Terraform Core pulls the state from Azure Blob Storage and reads your .tf files
  4. It computes a diff (plan) between desired state (.tf), known state (.tfstate), and actual Azure resources
  5. The Azure Provider translates the plan into Azure ARM API calls
  6. After apply, the updated state is pushed back to Azure Blob Storage and the lock is released

Why remote state in Azure Blob Storage?

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.


4. Environment Setup

4.1 Install the tools

# 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

4.2 Authenticate to Azure

# Interactive login (dev workstation)
az login

# Set your target subscription
az account set --subscription "MY-SUBSCRIPTION-ID"

# Verify
az account show --output table

For CI/CD, use a Service Principal instead:

az ad sp create-for-rbac --name "terraform-sp" --role Contributor \
    --scopes /subscriptions/MY-SUBSCRIPTION-ID

Then export ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID.

4.3 Project structure

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/

5. Your First Configuration

providers.tf — Tell Terraform to use Azure

terraform {
  required_version = ">= 1.5"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

variables.tf — Define inputs

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"
}

main.tf — Define resources

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"
    }
  }
}

outputs.tf — Export useful values

output "app_url" {
  value = "https://${azurerm_linux_web_app.main.default_hostname}"
}

output "resource_group_id" {
  value = azurerm_resource_group.main.id
}

terraform.tfvars

subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
environment     = "dev"
location        = "West Europe"

6. The Essential Commands

┌─────────────────────────────────────────────────────────┐
│                  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 cheat sheet

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

Typical session

# 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_url

7. State — Terraform's Memory

What is state?

State 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 ─┘

⚠️ Golden rules:

  • Never edit state manually — use terraform state commands
  • Never lose state — you lose track of what Terraform manages
  • Never commit state to git — it contains secrets (connection strings, passwords)

Local state (default)

By default, state lives as a file on your machine: terraform.tfstate. This is fine for learning, but dangerous for teams.

Remote state (production)

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

Setting up remote state on Azure

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 stterraformstate42

Step 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.

8. Key Patterns to Know

Use variables for everything that changes

variable "app_settings" {
  type = map(string)
  default = {
    "ENVIRONMENT" = "dev"
  }
}

Use locals for computed values

locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
    Project     = var.project
  }
}

Use count or for_each for multiples

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 {}
}

Use data sources to reference existing resources

data "azurerm_client_config" "current" {}

output "current_tenant_id" {
  value = data.azurerm_client_config.current.tenant_id
}

9. Common Pitfalls

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

10. Quick Reference — .gitignore

# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!example.tfvars
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraform.lock.hcl

11. Next Steps

  1. Try it — Deploy the example from Section 5 to your Azure subscription
  2. Add a database — Try azurerm_mssql_server + azurerm_mssql_database
  3. Modules — Extract reusable blocks into modules/ directories
  4. CI/CD — Run terraform plan in a pipeline (Azure DevOps or GitHub Actions)
  5. Read the docsregistry.terraform.io/providers/hashicorp/azurerm

Last updated: March 2026 — Terraform >= 1.5, AzureRM provider ~> 4.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment