IaC Concepts & Terraform Setup
Why this matters
Every Azure resource you've built in tracks 5-7 was created by typing an
az command — imperative, one-off, and invisible the moment the command
scrolled off your screen. Infrastructure as Code (IaC) replaces that with a
declarative source file that describes what should exist, versioned in
Git and applied by a tool. This module explains why that shift is worth
making, installs Terraform, and gets the azurerm provider authenticated
against the same Azure login you already use — the foundation for
everything else in this track.
Concepts
Imperative vs. declarative — what actually changes
In track 5 you ran az network vnet create -g rg -n hub-vnet --address-prefixes 10.0.0.0/16. That's imperative: you told Azure the
exact steps, in order, right now. If you run it twice you get an error (it
already exists) or a second resource. There's no file that represents "the
VNet we want" — only the side effects of commands you've already run.
Terraform is declarative: you write a block that says "a VNet named
hub-vnet with address space 10.0.0.0/16 should exist," and Terraform
compares that desired state to reality and makes only the changes needed to
close the gap. Run it once, it creates the VNet. Run it again with no
changes, it does nothing (idempotent). Change the address space in the
file, it updates just that. The file is the infrastructure's definition.
Why declarative wins for infrastructure
Four concrete payoffs, each of which you've felt the absence of already:
- Repeatability. Standing up an identical dev/test/prod environment is
terraform applythree times against three configs, not re-typing dozens ofazcommands hoping you got every flag the same. - Reviewability. Because it's code in Git (track 8), an infrastructure change is a pull request: someone can read the diff, see exactly which resources change, and approve it before it touches Azure.
- Auditability.
git logon your Terraform repo is a complete history of who changed what infrastructure and when — something no amount ofazcommand history gives you. - Drift detection. Terraform can tell you when someone changed a resource in the portal behind your back (you'll see this in module 01).
HCL: the language you'll write
Terraform configs are written in HCL (HashiCorp Configuration
Language), in files ending .tf. The core building block is a block:
resource "azurerm_resource_group" "main" {
name = "rg-tf-learn"
location = "eastus"
}
resource is the block type; "azurerm_resource_group" is the resource
type (which provider + which kind of thing); "main" is a local name you
choose to refer to it elsewhere in your code; and the { ... } body holds
the arguments. You'll dissect this fully in module 02 — for now, just
recognize the shape.
Providers: how Terraform talks to Azure
Terraform's core knows nothing about Azure specifically. A provider is
a plugin that translates HCL resource blocks into API calls against a
particular platform. For Azure that's the azurerm provider
(maintained by HashiCorp), which under the hood calls the same Azure
Resource Manager APIs that az does. You declare which providers you need
and pin their versions in a terraform { required_providers { ... } }
block, then Terraform downloads them during terraform init (module 01).
There are actually several Azure providers — azurerm (the main one, and
all this track uses), azuread (Entra ID objects), and azapi (raw ARM
for brand-new resources azurerm hasn't added yet). Stick with azurerm.
Authenticating the azurerm provider
The azurerm provider needs credentials to call Azure. There are several
methods; two matter here:
- Azure CLI auth (what you'll use for learning). If you've run
az login, the provider reuses that session automatically — no secrets in your config. This is the recommended path for a human running Terraform locally, and it's what every exercise in this track assumes. - Service principal auth (what pipelines use). A non-human identity
(an Entra ID app registration) with a client ID + secret, or — better —
OIDC federation with no stored secret at all. You'll set this direction
up conceptually in module 07 (CI/CD). Never hard-code a service
principal secret into a
.tffile; it would end up in Git.
One azurerm v4 gotcha up front: the provider now requires a subscription ID to be set explicitly (via the provider block, or the
ARM_SUBSCRIPTION_IDenvironment variable). With Azure CLI auth it will often pick up your CLI's default subscription, but setting it explicitly avoids "which subscription am I even deploying to?" surprises.
A minimal, complete config
Putting it together, the smallest real Terraform config for Azure is two
blocks — a terraform block declaring the provider, and a provider
block configuring it:
terraform {
required_version = ">= 1.9.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
The features {} block is mandatory and must be present even when
empty — it's where per-resource-type behavior overrides live. Leaving it
out is the single most common first-config error. You'll add actual
resources to this skeleton in module 01.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform version |
Prints the Terraform CLI version (and provider versions once initialized) | terraform version |
terraform -help |
Lists top-level subcommands | terraform -help |
az login |
Logs the Azure CLI in; the azurerm provider reuses this session | az login |
az account show |
Shows the currently active subscription | az account show -o table |
az account set |
Sets the active subscription | az account set --subscription "<sub-id-or-name>" |
az account list |
Lists subscriptions your login can see | az account list -o table |
Flag/field breakdown — the terraform block above:
required_version = ">= 1.9.0"— refuses to run if the Terraform CLI is older than this. Guards against a teammate on an ancient version.source = "hashicorp/azurerm"— the provider's address in the public registry (registry.terraform.io/hashicorp/azurerm).version = "~> 4.0"— a pessimistic constraint: allows4.x(any4.y≥4.0) but not5.0. Pinning is covered fully in module 02.
Flag/field breakdown — the provider "azurerm" block:
features {}— required container for behavior overrides; must be present even if empty.subscription_id— which subscription to deploy into. Can instead be supplied via theARM_SUBSCRIPTION_IDenvironment variable (preferred, so the ID isn't hard-coded).
Hands-on exercises
You'll run these in WSL2 (your Linux environment), the same shell you
use for az. Terraform is a single static binary, so installing it there
is quick.
Confirm your Azure CLI login works. Terraform is going to piggyback on this session, so make sure it's healthy first:
az login az account show -o tableVerify:
az account showprints a subscription withIsDefaulttrue. Note its SubscriptionId — you'll need it in exercise 4.Install Terraform in WSL2. Add HashiCorp's apt repository and install (this is the current documented method for Debian/Ubuntu):
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 terraformIf apt gives you trouble, the alternative is to download the zip from the HashiCorp releases page, unzip it, and move the single
terraformbinary onto your$PATH— that's all "installing" Terraform is.Verify the install. Run:
terraform versionVerify: it prints something like
Terraform v1.9.x(or newer). If you get "command not found," the binary isn't on your$PATH.Create your first config. Make a clean directory and a single file:
mkdir -p ~/tf-labs/00-setup && cd ~/tf-labs/00-setupCreate
main.tfwith exactly the minimal config from the "A minimal, complete config" section above, but replace the placeholdersubscription_idwith your real subscription ID from exercise 1.Initialize the working directory. This downloads the
azurermprovider named in your config:terraform initVerify: output ends with
Terraform has been successfully initialized!and a note that it installedhashicorp/azurerm. A new hidden.terraform/directory and a.terraform.lock.hclfile now exist. You'll learn what those are in module 01 — for now, confirm they appeared.Confirm the provider version. Now that the directory is initialized, run:
terraform versionVerify: it now also lists
+ provider registry.terraform.io/ hashicorp/azurerm vX.Y.Z— proof the provider downloaded and your~> 4.0constraint resolved to a real4.xversion.Diagnose and fix: the missing
featuresblock. Deliberately break your config to learn its most common error. Delete thefeatures {}line from yourprovider "azurerm"block, save, and run:terraform validateVerify: it errors with something like
Insufficient features blocks; at least 1 "features" block is required. This is the error new Terraform-on-Azure users hit. Put thefeatures {}line back and runterraform validateagain — it should now reportSuccess! The configuration is valid.Set the subscription via environment variable instead (better practice). Remove the hard-coded
subscription_idline from your provider block, then export it from the shell:export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)" terraform validateVerify:
terraform validatestill succeeds. You've now kept the subscription ID out of the file entirely — the pattern you'll use for the rest of the track. (This export lasts only for the current shell session; you'd re-run it, or add it to your shell profile, next time.)No cleanup needed — nothing was created. Everything so far is local config and a provider download; you have not created any Azure resource yet (no
apply), so there's nothing billing and nothing to destroy. That changes in module 01.
Independent challenge
Without copying the exercise commands, get a second, completely separate
Terraform working directory initialized and validating against a
different subscription than your default one (if you only have one
subscription, use the same one but prove to yourself you could target
another). Your config should hard-code neither the subscription ID nor any
secret in a .tf file. Confirm with terraform validate that it's valid,
and confirm with the right az command which subscription that ID actually
belongs to. This draws on this module's auth and provider-config concepts
plus the subscription-switching you learned with az back in track 5/7.
Stuck? One hint
terraform validate only checks that your config is internally
consistent — it does not contact Azure, so it will pass even against a
subscription you can't actually reach. To confirm the subscription ID is
real and yours, resolve it with
az account list --query "[?id=='<the-id>']" -o table. The clean way to
feed the ID to Terraform without hard-coding is the ARM_SUBSCRIPTION_ID
environment variable from exercise 8.
Common mistakes & troubleshooting
- Omitting the empty
features {}block. The provider refuses to initialize without it. It must be present even when it contains nothing. - Assuming
terraform validatetalks to Azure. It doesn't — it only checks syntax and internal references. A config canvalidatefine and still fail atplan/applybecause a subscription or permission is wrong. Validation is necessary, not sufficient. - Hard-coding the subscription ID (or worse, a secret) in
.tffiles. These files go in Git. UseARM_SUBSCRIPTION_ID/ the otherARM_*environment variables, or CLI auth, so nothing sensitive is committed. - Not being logged into
azfirst. With CLI auth,az loginis what gives the provider its credentials. If you're not logged in,plan(module 01) fails with an authentication error, not a config error. - Editing files on the Windows side but running Terraform in WSL. Keep
your Terraform project inside the WSL filesystem (e.g.
~/tf-labs/...), not under/mnt/c/..., to avoid line-ending and file-watching quirks. - Cost note: nothing in this module bills — you never ran
apply. Installing Terraform and runninginit/validatecreate only local files and a provider download. The cost warnings start in module 01, the moment you firstapply.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- In one sentence each, what's the difference between an imperative
az network vnet createand a declarative Terraformresourceblock? - Name two concrete advantages of declarative IaC that you personally felt the absence of while doing tracks 5-7 by hand.
- What is a Terraform "provider," and which one does this track use for Azure?
- What does the empty
features {}block do, and what happens if you omit it? - When you run Terraform locally with Azure CLI auth, where do its credentials come from?
- What does
~> 4.0mean as a version constraint, and what would it not allow? - You've run
terraform initandterraform validatewith no errors. Have you created anything in Azure yet? How do you know?
Show answers
- Imperative: you tell Azure the exact action to perform now, and re-running errors or duplicates. Declarative: you describe the desired end state, and Terraform makes only the changes needed to reach it, idempotently.
- Any two of: repeatability (rebuilding an identical environment),
reviewability (infra change as a reviewable PR diff), auditability
(
git logof infra history), drift detection (knowing when someone changed a resource out-of-band). - A plugin that translates HCL resource blocks into API calls against a
specific platform. This track uses the
azurermprovider. - It's a required container for per-resource-type behavior overrides;
it must be present even when empty. Omitting it makes the provider
refuse to initialize (
at least 1 "features" block is required). - From the
az loginsession — the provider reuses the Azure CLI's cached credentials, so no secret is stored in your config. - It allows any
4.xversion at or above4.0but not5.0or higher (a "pessimistic" constraint locking the major version). - No.
initonly downloads providers andvalidateonly checks config syntax/references — neither contacts Azure to create anything. Nothing is created untilterraform apply, which you haven't run.
Next
01 — The core workflow & state:
run your first real plan/apply/destroy, create an actual resource
group in Azure, and meet the state file that makes it all work.