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)
- 🎥 Intuition (5–15 min) · Terraform Explained (Fireship) — 100 seconds + a 10-min follow-up on why declarative infra is a superpower.
- 🎥 Deep dive (30–60 min) · Terraform Course for Beginners (TechWorld with Nana) — full walkthrough with AWS, providers, state, modules.
- 🎥 Hands-on demo (10–20 min) · Bicep in 15 minutes (John Savill) — Microsoft's Azure-native IaC, side-by-side with ARM JSON.
- 📖 Canonical article · Terraform Docs — Introduction — the reference on providers, state, plan/apply.
- 📖 Book chapter / tutorial · Terraform Up & Running (Yevgeniy Brikman) — sample chapter — the canonical book; the intro chapter is free.
- 📖 Engineering blog · Gruntwork — Terraform Best Practices — the essay that convinced most of the industry.
(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 accountPlan 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-approveChecklist — what to observe:
terraform initdownloads theazurermprovider binary into.terraform/— this is a giant plugin architecture; every cloud is a "provider".terraform planprints a colour-coded diff:+add,~change in place,-/+replace (destroy + create),-destroy. Read every plan before applying.terraform.tfstateis a JSON file with everything TF knows about your resources. Never edit it by hand — useterraform statesubcommands.- Changing a tag = in-place update (fast). Renaming the resource = destroy + create (potentially data-loss!). Always read the plan for
-/+markers. - Drift detection: manual portal changes show up as pending removals on
terraform plan. In real teams, a nightlyplanjob 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.tfvarswith real passwords. UseTF_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
planruns, blast-radius disasters. Split by lifecycle boundary: one state per environment, or per service. - Cross-state references. Use
terraform_remote_statedata source or (better) stack outputs via cloud-native metadata so you don't couple states. - CI/CD: always run
terraform planin PR, post as a comment, gateapplybehind approval on merge tomain. 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 importbrings 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
- What does
terraform.tfstatecontain and why can't you delete it? - Explain the difference between
terraform planandterraform apply. - What's the danger of manually editing a resource in the cloud portal after Terraform created it?
- When would you use Bicep instead of Terraform?
- Give two things you should never put in git alongside your
.tffiles.
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:
- What is Infrastructure as Code? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S070): CAP & PACELC — the Actual Trade-Offs
- Previous (S068): Azure Cloud — Identity, Storage, Networking, App Service
- Hub: The 6-Month Learning Plan
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 →