Providers, Resources & the azurerm Provider
Why this matters
You've run the workflow; now you need to actually write configurations that build more than a lone resource group. Real infrastructure is many resources that reference each other — a subnet belongs to a VNet, an NSG attaches to a subnet. This module covers the anatomy of a resource block, how resources reference each other by address (which is also how Terraform figures out what order to create things in), and how version pinning keeps your builds reproducible across a team.
Concepts
Anatomy of a resource block
Every resource block has the same four-part header and a body of arguments:
resource "azurerm_virtual_network" "hub" {
name = "hub-vnet"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
address_space = ["10.0.0.0/16"]
}
resource— the block type."azurerm_virtual_network"— the resource type: the provider prefix (azurerm) plus the specific kind. This maps to an Azure resource type and is defined by the provider, not by you."hub"— the local name, chosen by you, unique within this type in this module. It's how you refer to this resource elsewhere. It is not the Azure name.- The body — arguments (
name,address_space, …). Which arguments exist, which are required, and their exact spellings come from the provider's registry documentation. This VNet is the same one you built withaz network vnet createin track 5, module 01 — here's its declarative form.
Resource addressing and references
A resource's address is <type>.<local_name> — e.g.
azurerm_resource_group.main. To use one resource's attribute in another,
you write <type>.<local_name>.<attribute>:
resource_group_name = azurerm_resource_group.main.name
This says "use the name attribute of the resource group I named main."
Referencing an attribute does two things at once: it wires the value
through, and it tells Terraform that this resource depends on that one.
Implicit dependencies and apply ordering
Because azurerm_virtual_network.hub references
azurerm_resource_group.main.name, Terraform knows the resource group must
exist first. It builds a dependency graph from these references and
creates resources in the correct order automatically (and destroys them in
reverse). You almost never specify order manually — you just reference
attributes and let the graph sort it out. This is why you write
azurerm_resource_group.main.name instead of hard-coding the string
"rg-tf-learn": the hard-coded string would create the same value but
lose the dependency, and Terraform might try to build the VNet before the
group exists.
Explicit dependencies with depends_on
Occasionally two resources depend on each other in a way that isn't
expressed through an attribute reference (e.g. a role assignment that must
exist before some data plane operation, with no direct attribute link).
For those rare cases, depends_on = [azurerm_role_assignment.example]
forces ordering explicitly. Reach for it only when there's no natural
attribute reference to use — overusing it makes your graph rigid.
Reading the registry docs (don't invent arguments)
The authoritative reference for every resource type is the Terraform
Registry: registry.terraform.io/providers/hashicorp/azurerm/latest/docs.
Each resource page lists its arguments (required vs optional), its exported
attributes (values you can reference elsewhere, like an id), and
examples. Do not guess argument names. Azure resources have specific,
sometimes surprising argument spellings (address_space is a list;
address_prefixes on a subnet is also a list; a network security rule uses
destination_port_range, singular, or destination_port_ranges, plural —
you pick one). When unsure of an exact current argument name, check the
resource's registry page rather than inventing one.
Provider configuration and version pinning
Two different version numbers matter, and confusing them is common:
- The Terraform CLI version — pinned with
required_versionin theterraformblock. - The provider version — pinned per-provider in
required_providers.
The constraint operators you'll use:
| Constraint | Allows | Typical use |
|---|---|---|
= 4.37.0 |
exactly that version | rarely; too rigid |
>= 4.0.0 |
that version or newer | risky — allows major bumps |
~> 4.0 |
>= 4.0, < 5.0 (any 4.x) |
pin the major version |
~> 4.37.0 |
>= 4.37.0, < 4.38.0 |
pin major+minor, allow patches |
Pin the major version (~> 4.0) at minimum, so a 5.0 provider with
breaking changes never sneaks in on a routine terraform init -upgrade.
The .terraform.lock.hcl file (module 01) then records the exact version
resolved, so the whole team is byte-identical.
Multiple provider configurations (aliases)
Sometimes you deploy to two subscriptions or two regions in one config. You
give a provider block an alias and reference it per-resource with
provider = azurerm.<alias>:
provider "azurerm" {
features {}
subscription_id = var.primary_subscription_id
}
provider "azurerm" {
alias = "dr"
features {}
subscription_id = var.dr_subscription_id
}
A resource then opts into the aliased provider with
provider = azurerm.dr. You won't need this until multi-region work, but
recognize the pattern.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform init -upgrade |
Re-resolves provider versions to the newest allowed by constraints, updating the lock file | terraform init -upgrade |
terraform providers |
Shows the providers this configuration requires | terraform providers |
terraform providers lock |
Records provider checksums for multiple platforms in the lock file | terraform providers lock -platform=linux_amd64 |
terraform graph |
Emits the dependency graph in DOT format | terraform graph |
terraform state list |
Lists tracked resource addresses | terraform state list |
terraform fmt -recursive |
Formats all .tf files in this and subdirectories |
terraform fmt -recursive |
terraform validate |
Checks config syntax and internal references | terraform validate |
Flag breakdown — terraform init -upgrade:
-upgrade— ignores the currently locked versions and re-selects the newest versions permitted by yourrequired_providersconstraints, then rewrites.terraform.lock.hcl. Run it intentionally when you want to move up; a plainterraform initrespects the existing lock.
Flag breakdown — terraform providers lock -platform=linux_amd64:
-platform=<os_arch>— adds checksums for a specific OS/architecture to the lock file. Run it for each platform your team + CI use (e.g.linux_amd64,darwin_arm64,windows_amd64) soinitnever fails a checksum verification on someone else's machine.
Hands-on exercises
These create free resources (resource group, VNet, subnet). Destroy at the end regardless.
Set up. New directory, provider skeleton, subscription exported:
mkdir -p ~/tf-labs/02-resources && cd ~/tf-labs/02-resources export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"Create
main.tfwith the module 00 provider skeleton and a resource group namedrg-tf-resourcesineastus.Add a VNet that references the resource group. Append:
resource "azurerm_virtual_network" "hub" { name = "hub-vnet" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location address_space = ["10.0.0.0/16"] }Run
terraform initthenterraform plan.Verify:
Plan: 2 to add. Note that even though the VNet block is written after the resource group, Terraform will create the group first because of the reference — confirm by reading the plan.Apply and inspect the dependency graph. Run
terraform apply(yes), then:terraform state listVerify: both
azurerm_resource_group.mainandazurerm_virtual_network.hubare listed. Confirm in Azure withaz network vnet show -g rg-tf-resources -n hub-vnet -o table— this is the same VNet you'd have built withaz network vnet create.Add a subnet referencing the VNet. Append:
resource "azurerm_subnet" "web" { name = "web-subnet" resource_group_name = azurerm_resource_group.main.name virtual_network_name = azurerm_virtual_network.hub.name address_prefixes = ["10.0.1.0/24"] }Run
terraform planthenapply.Verify:
Plan: 1 to add. The subnet references the VNet'sname, so Terraform orders it after the VNet automatically.Prove the reference creates a real dependency. Run:
terraform state show azurerm_subnet.webVerify: the subnet's
idembeds the VNet and resource group names — exactly the values you referenced, wired through by Terraform rather than hard-coded.Read the registry to add a correct argument. Suppose you want the VNet to have custom DNS servers. Before writing anything, open the
azurerm_virtual_networkpage on the Terraform Registry and find the correct argument name (it'sdns_servers, a list). Add:dns_servers = ["10.0.0.4", "10.0.0.5"]Run
terraform plan.Verify:
Plan: 0 to add, 1 to change(~update in place). The point of this exercise is the habit of confirming the argument name in the docs instead of guessing.Diagnose and fix: a made-up argument. Now do the wrong thing on purpose. Add a plausible-but-fake argument to the VNet:
enable_ddos = trueRun
terraform validate.Verify: it errors with
An argument named "enable_ddos" is not expected here. Terraform validates argument names against the provider schema, catching invented arguments. Remove the line. (The real DDoS setting is configured via a separateddos_protection_planblock — another reason to read the docs.)Diagnose and fix: an unpinned provider bites you (thought experiment + real command). Look at your current constraint (
~> 4.0) and run:terraform providersVerify: it prints
provider[registry.terraform.io/hashicorp/azurerm] ~> 4.0. Now imagine you'd written>= 4.0.0instead and a5.0provider shipped with breaking changes: a routineterraform init -upgradewould pull it and your config could break. The fix is exactly what you have — pin the major version with~>. Confirm the lock file recorded a specific version:grep version .terraform.lock.hcl.Format your code. Deliberately mangle the indentation in
main.tf, then:terraform fmtVerify:
fmtprints the filename it fixed and the indentation is now canonical. Runterraform fmt -check— it exits 0 (clean). This is the check CI will run in module 07.Clean up. Everything here is free, but destroy anyway:
terraform destroyConfirm with
az group show -n rg-tf-resources -o tablethat it's gone (ResourceGroupNotFound).
Independent challenge
Without copying the exercise blocks, write a single configuration that
builds a resource group, a VNet with two subnets, and adds a second,
non-overlapping address space to the VNet — reproducing declaratively what
you did imperatively in track 5, module 01 (the multi-subnet VNet with a
second address space). Every cross-resource value must be a reference, not
a hard-coded string. Before applying, predict the exact "N to add" number
and confirm the plan matches. After applying, use terraform state show to
prove the subnets' IDs embed the VNet you created. Destroy when done. This
draws on this module's addressing/dependency concepts and track 5's VNet
subnetting rules.
Stuck? One hint
A VNet's address_space is a list, so a second address space is just a
second element: address_space = ["10.0.0.0/16", "10.10.0.0/16"]. Each
subnet is its own azurerm_subnet resource with a unique local name, both
referencing azurerm_virtual_network.<name>.name. Read the plan's summary
line — you should be predicting 4 to add (group + VNet + 2 subnets).
Common mistakes & troubleshooting
- Hard-coding a value instead of referencing it. Writing
resource_group_name = "rg-tf-resources"produces the right value but drops the dependency edge, so Terraform may try to build in the wrong order. Always referenceazurerm_resource_group.main.name. - Inventing argument names. The provider schema is exact. Guessing
(
enable_ddos,subnet_nameon a VNet, etc.) failsvalidate. Read the resource's registry page. - Confusing the local name with the Azure name.
"hub"inazurerm_virtual_network.hubis your Terraform label; the Azure name is thename = "hub-vnet"argument. They can differ and often do. - Not pinning the provider major version.
>= 4.0.0invites a breaking5.0on the next-upgrade. Use~> 4.0, and commit the lock file. - Overusing
depends_on. If you can express a dependency through an attribute reference, do that instead — it's clearer and keeps the graph accurate. Reservedepends_onfor genuinely reference-less ordering. - Cost pitfall: these specific resources (RG, VNet, subnets) are free, but the same pattern in module 06 creates billable AKS/load-balancer resources. The destroy habit you're building now is what stops those from lingering.
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.
- Break down the header
resource "azurerm_subnet" "web"— what is each of the three parts? - What's the difference between a resource's local name and its Azure
nameargument? - When you write
resource_group_name = azurerm_resource_group.main.name, what two things does that reference accomplish? - How does Terraform decide what order to create resources in? Do you usually set it manually?
- What does
~> 4.0allow and disallow, and why is it better than>= 4.0.0? - You're not sure whether a subnet argument is
address_prefixoraddress_prefixes. What's the right way to find out? - When is
depends_onactually necessary, given implicit dependencies exist?
Show answers
resource= block type;"azurerm_subnet"= the resource type (provider + kind, defined by the provider);"web"= the local name you chose to reference it by.- The local name is your Terraform-internal label (used in addresses and
references); the
nameargument is the actual name Azure gives the resource. They're independent and often different. - It passes the resource group's name into the subnet as a value, and it creates a dependency edge so Terraform creates the group before the subnet (and destroys in reverse).
- From the dependency graph it builds out of attribute references. You
usually don't set order manually — references imply it;
depends_onis the rare exception. - It allows any
4.x(>= 4.0, < 5.0) and disallows5.0+. It's better than>= 4.0.0because it prevents a breaking major-version bump from being pulled in automatically. - Read the
azurerm_subnetpage in the Terraform Registry docs — it lists exact argument names. (It'saddress_prefixes, a list.) Don't guess. - Only when two resources must be ordered but have no attribute reference linking them (e.g. a permission that must exist before a data-plane action). If an attribute reference can express it, use that instead.
Cumulative review
Closed-book. Cover the answers and write out each one before checking — this mixes everything from modules 00-02.
- Contrast imperative (
az) and declarative (Terraform) infrastructure management, and give one advantage of declarative you experienced the absence of in tracks 5-7. - What are the two mandatory blocks in a minimal Azure Terraform config, and what one easy-to-forget empty block must the provider block contain?
- Put these in the order you'd run them the first time and say what each
does:
apply,plan,init,destroy. - What is the state file, why must it never be committed to Git, and what related file should be committed?
- In a plan, what do
+,~, and-/+each mean, and which one should make you stop before applying? - A teammate changed a Terraform-managed NSG rule in the portal. What is this called, how does Terraform surface it, and what are your two valid responses?
- Why do you write
resource_group_name = azurerm_resource_group.main.namerather than the literal string, in terms of the dependency graph? - Your CI must apply exactly the plan a reviewer approved, with no re-computation. Which two commands, in order, achieve that?
- What does
~> 4.0mean, and what disaster does it prevent compared to>= 4.0.0?
Show answers
- Imperative issues ordered commands that mutate state immediately and
don't leave a reusable definition; declarative describes desired end
state that Terraform reconciles idempotently. Advantages felt earlier:
no reviewable/auditable record of infra changes, and no easy repeatable
rebuild — you re-typed
azcommands by hand. - The
terraformblock (withrequired_providers) and theprovider "azurerm"block; the latter must contain an emptyfeatures {}block. init(download providers / set up backend), thenplan(preview changes), thenapply(make and record them), thendestroy(tear everything down).- It's the JSON mapping of config addresses to real Azure objects
(Terraform's memory). Never commit it: it can hold plaintext secrets and
is machine-managed. The
.terraform.lock.hcllock file should be committed. +create,~update in place,-/+destroy-and-recreate.-/+should make you stop — it can mean data loss or a changed identity/IP.- Drift. Terraform surfaces it during
plan's refresh as a diff that would revert the change. Valid responses: apply to revert (config wins), or update config /apply -refresh-onlyto adopt the change. - The reference both passes the value through and creates a dependency edge, so Terraform creates the resource group before the resource that references it; a literal string would pass the value but lose the ordering guarantee.
terraform plan -out=tfplanthenterraform apply tfplan.~> 4.0allows any4.xbut not5.0+. It prevents a breaking major-version provider upgrade from being pulled automatically, which>= 4.0.0would permit.
Next
03 — Variables, outputs & data sources: stop hard-coding names and regions, expose useful values after apply, and reference existing Azure resources you didn't create with Terraform.