CAMPUX Cloud Bootcamp Lab · Infrastructure as Code ← All labs
Hands-On Lab · Intermediate
~40 min · Free tier · local or Cloud Shell
Terraform & Azure CLI · torn down at the end
Infrastructure as Code

Terraform on Azure: a landing zone with remote state.

Every cloud-engineer posting names Terraform. This is the lab that gets it onto your résumé honestly: stand up a landing zone, then move its state to a locked backend in Azure Storage — the one detail that separates a laptop demo from a team that ships.

Fig. 1 · Terraform with remote state
Managed resourcesread/write statemanagesTerraformplan / applyRemote backendstate file · lockedResource groupthe managed estatestate is the whole idea
● Screen walkthrough Not yet recorded · ~10 min
Reel · 00:00 / 10:00

plan then apply a landing zone with remote state, then trigger a state lock to see it protect a second apply.

Placeholder — the page below stands alone until the reel lands
Why

State is the whole idea

Terraform describes your infrastructure in .tf files and reconciles reality to match them — the same declarative promise as Bicep, but cloud-agnostic and, in most job postings, the default. The thing that trips up newcomers is state: Terraform keeps a JSON record of what it has created so it knows what to change next time. Left on your machine, that file is a single point of failure and a lock nobody else can see. Move it to a shared, locked backend and you have what a team actually runs. This lab does exactly that migration, on a landing zone small enough to reason about.

You will initialise a project, preview a plan, apply it to create a tagged resource group, then create a storage backend and migrate state into it — after which a second concurrent run is refused a lease, which is the safety you came for.

Local state is a demo. Remote, locked state is a team.

Before you begin — one-time setup

You need a free Azure account, the Azure CLI (az), and Terraform installed, then be signed in with az login. First lab? Do the 15-minute Set up your machine page once — it covers the free account, installing az and Terraform (winget / brew / apt), and signing in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), where terraform and az are preinstalled and already signed in.

The companion repository

This page walks the whole lab; the .tf files live at github.com/kloudcaptain/campux-labs in the lab-terraform-landing-zone folder.

Setup

A project and a provider

Open your terminal (signed in with az login) — or Azure Cloud Shell — and make a working folder. A Terraform project is just files in a directory; three blocks are enough to start — which provider, how it is configured, and what to create.

Reading that command — what the here-doc does

The next line looks cryptic, so here it is in plain English. mkdir -p ~/campux-tf-lz && cd ~/campux-tf-lz makes a folder in your home directory and steps into it. Then cat > main.tf <<'EOF' is a shell trick called a here-document: cat normally prints text, > main.tf redirects that text into a file called main.tf (creating it), and <<'EOF' means "take every line below, up to a line that's just EOF, as that text." So the whole block means "write everything between the two EOF markers into main.tf." EOF (end-of-file) is just the chosen end-marker — nothing magic, any word would work. The quotes in 'EOF' mean "write it literally"; when we leave the quotes off later (for backend.tf) the shell first fills in any $variables. Prefer a text editor? Paste the same lines into one and save as main.tf — the here-doc just builds the file in a single copy-paste.

# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux)
export MSYS_NO_PATHCONV=1

mkdir -p ~/campux-tf-lz && cd ~/campux-tf-lz
cat > main.tf <<'EOF'
terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
  }
}

provider "azurerm" {
  features {}
}

variable "location" { default = "eastus" }

resource "azurerm_resource_group" "lz" {
  name     = "campux-lab-lz-rg"
  location = var.location
  tags = {
    environment = "lab"
    owner       = "campux"
    managed_by  = "terraform"
  }
}

output "resource_group" {
  value = azurerm_resource_group.lz.name
}
EOF
Checkpoint You have one file, main.tf. Cloud Shell is already authenticated, so there are no credentials to configure — the azurerm provider uses your existing Azure CLI login.
Step 1

Init and plan — look before you leap

terraform init downloads the provider into the folder. terraform plan is Terraform's what-if: it shows exactly what would change, and nothing happens until you apply.

terraform init
terraform plan
Checkpoint init ends with Terraform has been successfully initialized. plan ends with Plan: 1 to add, 0 to change, 0 to destroy. — the one addition being your resource group. This is a preview only; nothing exists yet.
Step 2

Apply, then prove idempotency

terraform apply -auto-approve

Now run plan again against what you just built:

terraform plan
Checkpoint The apply reports Apply complete! Resources: 1 added and prints your resource_group output. The second plan reports No changes. Your infrastructure matches the configuration. — the declarative payoff: the file is the desired state, reality already matches, so a re-run does nothing.
Step 3

Build a remote backend

Right now the state sits in terraform.tfstate, a plain file in your folder. If it's lost, or two people change infrastructure at once, Terraform's record of what exists drifts from reality. The professional fix is to keep it in Azure Storage. We'll build that home in four small steps so each piece is clear, rather than pasting one wall of commands.

1 · Name things. A storage account name is globally unique across all of Azure and must be lowercase letters and digits only — so we tack a random number on the end to avoid a clash with someone else's.

RG_TF="campux-lab-tfstate-rg"
SA="campuxtf$RANDOM"
echo "$SA"        # <- this is your unique storage account name

2 · A separate resource group for state. State goes in its own group, apart from the landing zone it tracks. That separation is deliberate: when you destroy the landing zone at the end, its state store isn't sitting in the same group getting deleted with it.

az group create -n "$RG_TF" -l eastus

3 · The storage account. The account that will physically hold the state file. --allow-blob-public-access false keeps it private — state is sensitive.

az storage account create -n "$SA" -g "$RG_TF" -l eastus --sku Standard_LRS --allow-blob-public-access false

4 · A container to hold the file. A container is a folder-like bucket inside the account; the state file lives in it. Creating a container is a data-plane action, and a brand-new account hasn't yet granted your user the blob-data role — so instead of fighting that, we authenticate with the account's own access key, which always works. We grab the key once and reuse it.

KEY=$(az storage account keys list -g "$RG_TF" -n "$SA" --query "[0].value" -o tsv)
az storage container create -n tfstate --account-name "$SA" --account-key "$KEY"
Checkpoint The last command returns "created": true. You now have a dedicated resource group, a private storage account, and a tfstate container — the state's new home, kept apart from the infrastructure it tracks. (If keys list errors with "key based authentication is not permitted", your subscription disables shared keys; then use Cloud Shell or ask your admin — but on a personal account it's on by default.)
Step 4

Migrate state into the backend

1 · Point Terraform at the backend. Add a backend "azurerm" block naming the account you just made. This heredoc uses an unquoted EOF, so the shell substitutes your real $SA value as it writes the file — no copy-pasting names by hand.

cat > backend.tf <<EOF
terraform {
  backend "azurerm" {
    resource_group_name  = "campux-lab-tfstate-rg"
    storage_account_name = "$SA"
    container_name       = "tfstate"
    key                  = "landing-zone.tfstate"
  }
}
EOF

2 · Give Terraform the key. To write state into the account, the azurerm backend reads an access key from the ARM_ACCESS_KEY environment variable. Set it to the same key from before (re-fetched here so this step stands on its own):

export ARM_ACCESS_KEY=$(az storage account keys list -g "$RG_TF" -n "$SA" --query "[0].value" -o tsv)

3 · Re-initialise. Terraform sees the new backend and offers to copy your existing local state up to Azure. Answer yes.

terraform init -migrate-state
Checkpoint Terraform reports Successfully configured the backend "azurerm" and that it copied state. Confirm the local file is now empty of real state and the blob exists:

az storage blob list --container-name tfstate --account-name "$SA" \
  --account-key "$ARM_ACCESS_KEY" --query "[].name" -o tsv   # -> landing-zone.tfstate
Step 5

The payoff: state locking

The azurerm backend takes a blob lease while it runs, so two people cannot corrupt state by applying at once. Simulate it: start a plan that holds the lease in the background, then try a second apply immediately.

terraform plan -lock-timeout=0s & \
  sleep 1; terraform apply -auto-approve -lock-timeout=0s; wait
Checkpoint The second command fails fast with Error acquiring the state lock and a * lease already present reason. That refusal is the feature — the backend just prevented two concurrent runs from clobbering each other. On a real team this is what stops a Friday-afternoon incident.
Down

Tear it down

Destroy the landing zone with Terraform, then delete the backend group by hand (Terraform will not delete its own state store).

terraform destroy -auto-approve
az group delete -n campux-lab-tfstate-rg --yes
az group exists -n campux-lab-lz-rg        # -> false
Checkpoint terraform destroy reports Destroy complete! Resources: 1 destroyed, and both resource groups are gone. Your subscription is back where it started.
End

What you can now honestly claim

You wrote a Terraform configuration, previewed it with plan, applied it, and — the part that matters — migrated state to a locked remote backend in Azure Storage and watched the lease refuse a concurrent run. That is the difference between "I followed a tutorial" and "I can run Terraform the way a team does." The natural next step is to hand this pipeline the keys without storing a secret — the next lab wires GitHub Actions to Azure with OIDC, so a workflow deploys this exact kind of code and no credential is ever written down.

Footnotes
  1. The azurerm provider is pinned to the version 4 line so the lab reads the same next year; a newer major version can rename arguments. Pinning the provider is itself a professional habit — reproducible builds beat "latest" surprises.
  2. State locking on the azurerm backend uses an Azure Blob lease and needs no extra service — unlike the AWS pattern, there is no separate lock table to create. One fewer moving part, one fewer thing to tear down.