Search Tech Journey

Find topics, journeys and posts

6-month learning plan69 / 130
back to blog
systemsintermediate 15m read

S069 · Infrastructure as Code — Terraform / Bicep Basics

Click-ops doesn't scale.

Module M07: Systems & Infrastructure · Session 69 of 130 · Track: SYS 📐

What you'll be able to do after this session

  • Explain Infrastructure as Code to a friend in one minute, out loud, no notes.
  • Recognise it when you see it in real code / systems / papers.
  • Complete the hands-on exercise at the end without looking up help.

Prerequisites

If you can't answer these in 30 seconds each, redo the linked session first:


Pre-read (skim before the session)


(a) Intuition · 5 min

The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.

In one sentence: Click-ops doesn't scale.

"Click-ops" — the practice of building infrastructure by clicking around a cloud portal — is fine for one weekend project. It falls apart the moment you need a second environment (staging), a third (dev), reproducibility (someone deleted the RG, rebuild it), or a review process (which manager approved that ₹5 lakh VM?). Infrastructure as Code (IaC) replaces the mouse with text: your entire cloud footprint becomes files in a git repo. Someone opens a PR to add a database; you review the diff; CI runs it; if it goes wrong, you git revert.

Two big families:

  • Cloud-agnostic: Terraform (HashiCorp, HCL syntax, has "providers" for every cloud — AWS, Azure, GCP, GitHub, Datadog, PagerDuty, ...). One tool, one skill set, works everywhere. This is what most teams pick.
  • Cloud-native: Bicep (Azure), CloudFormation (AWS), Deployment Manager (GCP). Deeper integration with that one cloud, no state file to manage, but you learn a new DSL per cloud.

The core loop is the same regardless of tool: write (declare desired state in text), plan (tool diffs desired vs actual and prints changes), apply (tool makes API calls to reconcile). This is exactly the K8s control-loop pattern from S066 — declarative, converging, reviewable.

The subtle part is state. Terraform keeps a JSON file (terraform.tfstate) mapping "the azurerm_storage_account.blob resource in my code" to "the actual Azure resource ID /subscriptions/…/blobshop123". Without state, terraform plan couldn't tell what already exists. State must be stored remotely (Azure Storage, S3, Terraform Cloud) with locking, or two engineers running apply simultaneously will corrupt your infrastructure.

Forever-gotcha: If you change infrastructure manually in the portal, Terraform will "correct" it back on the next apply. This "drift" between real world and code is the #1 IaC gotcha. Enforce "no click-ops in prod" via IAM/RBAC — or use a terraform plan diff detector in CI that alerts on drift.


(b) Visual walkthrough · 15 min

Diagrams, worked examples, step-by-step visuals.

Terraform vs Bicep side-by-side (same resource):

Terraform (main.tf):

terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 3.100" }
  }
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "tfstateblog"
    container_name       = "state"
    key                  = "blog.tfstate"
  }
}
 
provider "azurerm" { features {} }
 
variable "location" { default = "centralindia" }
 
resource "azurerm_resource_group" "rg" {
  name     = "rg-blog-prod"
  location = var.location
  tags     = { env = "prod", owner = "chinni" }
}
 
resource "azurerm_storage_account" "blob" {
  name                     = "blogprodblob"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

Bicep (main.bicep) — the equivalent:

param location string = 'centralindia'
 
resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {
  name: 'rg-blog-prod'
  location: location
  tags: { env: 'prod', owner: 'chinni' }
}
 
// Bicep RG-scope deployments handle the storage account

Plan output — what a diff looks like:

Terraform will perform the following actions:
 
  # azurerm_resource_group.rg will be created
  + resource "azurerm_resource_group" "rg" {
      + id       = (known after apply)
      + name     = "rg-blog-prod"
      + location = "centralindia"
      + tags     = { "env" = "prod", "owner" = "chinni" }
    }
 
  # azurerm_storage_account.blob will be created
  + resource "azurerm_storage_account" "blob" {
      + name                     = "blogprodblob"
      + account_tier             = "Standard"
      + account_replication_type = "LRS"
      ...
    }
 
Plan: 2 to add, 0 to change, 0 to destroy.

Worked example — modules for reuse. Rather than copy-pasting your VNet/subnet/NSG blocks in every project, wrap them in a Terraform module:

# modules/network/main.tf
variable "cidr" {}
variable "name" {}
resource "azurerm_virtual_network" "vnet" { ... }
resource "azurerm_subnet" "web" { ... }
output "web_subnet_id" { value = azurerm_subnet.web.id }

Then consume it in any project:

module "net" {
  source = "git::https://github.com/myorg/tf-modules.git//network?ref=v1.2.0"
  name   = "vnet-blog"
  cidr   = "10.20.0.0/16"
}

Version-pinned, git-tagged, review-able. This is how you build a company-wide "platform" from primitives.


(c) Hands-on · 20 min

Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.

We'll write a real Terraform config that creates a Resource Group + Storage Account in Azure, plan it, apply it, then destroy it — using local state to keep it simple (production would use remote state).

# Prereqs: az login done, Terraform installed (brew install terraform / apt)
mkdir tf-demo && cd tf-demo
 
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 3.100" }
    random  = { source = "hashicorp/random",  version = "~> 3.6" }
  }
}
provider "azurerm" { features {} }
EOF
 
cat > main.tf <<'EOF'
resource "random_string" "suffix" {
  length  = 6
  upper   = false
  special = false
}
 
resource "azurerm_resource_group" "rg" {
  name     = "rg-tf-demo-${random_string.suffix.result}"
  location = "centralindia"
  tags     = { managed_by = "terraform", owner = "chinni" }
}
 
resource "azurerm_storage_account" "blob" {
  name                     = "tfdemo${random_string.suffix.result}"
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  min_tls_version          = "TLS1_2"
  blob_properties {
    versioning_enabled = true
  }
}
 
output "rg_name"       { value = azurerm_resource_group.rg.name }
output "storage_name"  { value = azurerm_storage_account.blob.name }
output "primary_blob"  { value = azurerm_storage_account.blob.primary_blob_endpoint }
EOF
 
# Initialise providers + backend
terraform init
 
# Plan — see what will happen (no changes yet)
terraform plan -out=tfplan
 
# Apply — actually create it
terraform apply tfplan
 
# Inspect state (never edit this file manually!)
terraform state list
terraform state show azurerm_storage_account.blob
 
# Make a change: add a tag, plan again — see a targeted update
sed -i 's/owner = "chinni"/owner = "chinni-updated"/' main.tf
terraform plan   # should show "1 to change, 0 to add, 0 to destroy"
terraform apply -auto-approve
 
# Simulate drift: change tag in portal, then run plan again
az group update -n $(terraform output -raw rg_name) --set tags.drift_test=true
terraform plan   # will show the tag as needing removal (Terraform is the truth)
 
# Destroy everything
terraform destroy -auto-approve

Checklist — what to observe:

  1. terraform init downloads the azurerm provider binary into .terraform/ — this is a giant plugin architecture; every cloud is a "provider".
  2. terraform plan prints a colour-coded diff: + add, ~ change in place, -/+ replace (destroy + create), - destroy. Read every plan before applying.
  3. terraform.tfstate is a JSON file with everything TF knows about your resources. Never edit it by hand — use terraform state subcommands.
  4. Changing a tag = in-place update (fast). Renaming the resource = destroy + create (potentially data-loss!). Always read the plan for -/+ markers.
  5. Drift detection: manual portal changes show up as pending removals on terraform plan. In real teams, a nightly plan job alerts on drift.

Try this modification: convert the storage account into a reusable module in ./modules/storage/, expose name, location, rg_name as variables, and call it twice from your root main.tf to create two storage accounts with different names. This is the "DRY infrastructure" pattern.


(d) Production reality · 10 min

How this shows up in real systems, gotchas, war stories, common bugs.

War story #1 — the state-file catastrophe. A team stored terraform.tfstate in git. Two engineers ran apply at the same time from different branches. State merged badly, Terraform tried to "recreate" resources that already existed, then failed halfway. It took 3 days to reconcile by hand. Rule: always use remote state with locking (Azure Storage with blob lease, S3 + DynamoDB, or Terraform Cloud). Never git.

War story #2 — the "small" refactor. Someone renamed a resource in HCL (azurerm_storage_account.blob.storage). Terraform saw the old name as gone and the new name as new → planned "destroy blob, create storage". Nobody read the plan carefully, someone typed yes, production data was wiped. Fix: use terraform state mv for renames; enforce PR review that a human eyeball the plan; use prevent_destroy = true lifecycle on critical resources.

War story #3 — the provider upgrade. Upgrading azurerm from 2.x to 3.x renamed dozens of properties. terraform plan after the upgrade wanted to "recreate" half the fleet. Fix: pin provider versions in required_providers, upgrade in a scratch environment first, use migration guides. Never blindly terraform init -upgrade in prod.

Common gotchas & how top teams handle them:

  • Secrets in tfvars. People commit terraform.tfvars with real passwords. Use TF_VAR_* env vars, or fetch from a vault (data "azurerm_key_vault_secret"), or use Terraform Cloud sensitive variables. Never plain values in git.
  • Massive monolith state. One state file with 1000+ resources = 10-minute plan runs, blast-radius disasters. Split by lifecycle boundary: one state per environment, or per service.
  • Cross-state references. Use terraform_remote_state data source or (better) stack outputs via cloud-native metadata so you don't couple states.
  • CI/CD: always run terraform plan in PR, post as a comment, gate apply behind approval on merge to main. Tools like Atlantis, Spacelift, Terraform Cloud, and env0 handle this.
  • Policy as code: OPA / Sentinel to block things like "public storage" or "VM SKUs bigger than D8s". Enforced in the CI plan step.
  • Modules that are too clever. A module with 50 optional variables and nested conditionals becomes unreadable. Prefer many small, sharp modules over one big configurable one.
  • Import existing resources. terraform import brings click-ops resources under IaC control. Tedious but the only path from legacy to declarative.

Gruntwork, HashiCorp, and Google's SRE book all treat IaC as a required practice for any infrastructure that touches production. The rule of thumb: if you can't recreate your infrastructure from git in ≤1 day, you don't have infrastructure — you have a museum.


(e) Quiz + exercise · 10 min

  1. What does terraform.tfstate contain and why can't you delete it?
  2. Explain the difference between terraform plan and terraform apply.
  3. What's the danger of manually editing a resource in the cloud portal after Terraform created it?
  4. When would you use Bicep instead of Terraform?
  5. Give two things you should never put in git alongside your .tf files.

Stretch problem (connects to S066/S067 — K8s): Terraform can provision a Kubernetes cluster (AKS/EKS/GKE) and deploy K8s resources into it via the kubernetes provider. Explain why bundling both in the same state file is a common anti-pattern, and sketch a two-stage design (infra state vs app state) with how you'd pass the cluster credentials between them. ~8 lines.


Explain-out-loud test

If you can't teach these 3 things to a friend without notes, redo the session:

  1. What is Infrastructure as Code? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.

More from M07 · Systems & Infrastructure

all modules →
  1. 060OS Basics — Processes, Threads, Memory, FDs
  2. 061Networking I — TCP/IP, DNS, Sockets
  3. 062Networking II — Load Balancers L4 vs L7, Reverse Proxies
  4. 063Caching — Cache-Aside, Write-Through, TTLs, Invalidation
  5. 064CDN — Edge, Cache Hierarchies, Cache-Control
  6. 065Docker — Images, Layers, Dockerfile, Networking