Provisioning Real Azure Infrastructure
Why this matters
This is the payoff module. Everything before it was mechanics; here you
rebuild — declaratively — the exact infrastructure you created by hand in
tracks 5 and 7: a VNet with subnets and an NSG, then an AKS cluster with an
attached ACR. When you're done you'll have one terraform apply that stands
up what previously took a dozen carefully-ordered az commands, and one
terraform destroy that removes all of it. This is also the first
module that creates genuinely billable resources (an AKS cluster's node
VMs) — so the destroy discipline you've been practicing on free resources
now matters for real.
Cost warning. An AKS cluster bills for its worker-node VMs (and their disks, and any load balancers/public IPs) for as long as it exists, whether or not you run workloads on it — exactly as track 7 warned. Use a small node count and a burstable VM size, and
terraform destroythe moment you finish, oraz aks stopbetween sessions. Read every plan'sN to addbefore confirming.
Concepts
Mapping az resources to azurerm resource types
Each thing you made with az in tracks 5/7 has a direct azurerm
counterpart. The mapping you'll use in this module:
Track 5/7 az command |
azurerm resource type |
|---|---|
az network vnet create |
azurerm_virtual_network |
az network vnet subnet create |
azurerm_subnet |
az network nsg create + rules |
azurerm_network_security_group |
| (associate NSG to subnet) | azurerm_subnet_network_security_group_association |
az acr create |
azurerm_container_registry |
az aks create |
azurerm_kubernetes_cluster |
az aks update --attach-acr |
azurerm_role_assignment (AcrPull) |
The last two rows are the interesting ones — the AKS↔ACR attach that was a
single az aks update --attach-acr flag becomes an explicit role
assignment in Terraform, which is actually a more honest picture of what
that flag did under the hood (grant the cluster's kubelet identity AcrPull
on the registry).
The VNet + subnet + NSG stack, declaratively
This is track 5 modules 01-02 as code. The NSG rule fields map one-to-one to
the az network nsg rule create flags you used:
resource "azurerm_network_security_group" "web" {
name = "web-nsg"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "allow-https"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "web" {
subnet_id = azurerm_subnet.web.id
network_security_group_id = azurerm_network_security_group.web.id
}
Note the separate association resource: in azurerm, attaching an NSG
to a subnet is its own resource (azurerm_subnet_network_security_group_association),
not a field on the subnet. This mirrors the fact that in track 5 the NSG and
its subnet binding were distinct steps.
AKS as a resource
azurerm_kubernetes_cluster is track 7 module 01's az aks create. The
core arguments map directly to the flags you used:
resource "azurerm_kubernetes_cluster" "aks" {
name = "aks-tf-learn"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = "akstflearn"
default_node_pool {
name = "system"
node_count = 2
vm_size = "Standard_B2s"
}
identity {
type = "SystemAssigned"
}
}
default_node_pool— the system node pool (track 7's concept):vm_sizeis--node-vm-size,node_countis--node-count.identity { type = "SystemAssigned" }— gives the cluster a managed identity, the modern default and what the AcrPull assignment will target.dns_prefix— required; forms part of the cluster's API server FQDN.
The cluster's kubelet identity (a separate managed identity AKS creates for
pulling images) is exported as
azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id — you need it
for the ACR attach.
ACR and the AcrPull attach
azurerm_container_registry is az acr create. The attach is a role
assignment granting the cluster's kubelet identity AcrPull on the
registry — which is literally what az aks update --attach-acr does:
resource "azurerm_container_registry" "acr" {
name = "acrtflearn12345" # globally unique, alphanumeric
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "Basic"
admin_enabled = false
}
resource "azurerm_role_assignment" "aks_acr_pull" {
principal_id = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id
role_definition_name = "AcrPull"
scope = azurerm_container_registry.acr.id
skip_service_principal_aad_check = true
}
Because the role assignment references both the cluster's kubelet identity
and the ACR's ID, Terraform's dependency graph creates the cluster and
registry first, then wires them together — the ordering you had to do
manually with az is now automatic.
Getting kubeconfig out via an output
Track 7's az aks get-credentials has a Terraform analogue: the cluster
exports kube_config_raw (the full kubeconfig). You surface it as a
sensitive output and can write it to a file:
output "kube_config" {
value = azurerm_kubernetes_cluster.aks.kube_config_raw
sensitive = true
}
Then terraform output -raw kube_config > ~/.kube/aks-tf-config. It's
sensitive because it contains cluster credentials — and, as module 03
warned, it's still stored in plaintext in state, another reason your state
belongs in the locked remote backend from module 05.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform apply |
Builds the whole stack after confirmation | terraform apply |
terraform output -raw kube_config |
Extracts the raw kubeconfig from a sensitive output | terraform output -raw kube_config > ~/.kube/aks-tf |
terraform state show <addr> |
Inspects a provisioned resource's recorded attributes | terraform state show azurerm_kubernetes_cluster.aks |
az aks get-credentials |
Alternative way to fetch kubeconfig for the created cluster | az aks get-credentials -g rg-tf-infra -n aks-tf-learn |
kubectl get nodes |
Confirms the Terraform-built cluster's nodes are Ready | kubectl get nodes -o wide |
az acr repository list |
Confirms images can be pushed/pulled from the ACR | az acr repository list -n acrtflearn12345 |
terraform destroy |
Tears the entire billable stack down | terraform destroy |
Argument breakdown — azurerm_role_assignment for AcrPull:
principal_id— who gets the role: the cluster's kubelet managed identity (kubelet_identity[0].object_id), the identity that actually pulls images.role_definition_name = "AcrPull"— what they can do: pull (not push) from the registry.scope— where: the specific ACR's resource ID.skip_service_principal_aad_check = true— avoids a race where the identity isn't yet replicated in Entra ID when the assignment is created; standard for freshly-created managed identities.
Hands-on exercises
These create billable resources. Do them in one sitting and run the cleanup at the end. Use a remote backend if you finished module 05, or local state is fine for a single-sitting lab.
Set up. New directory; export subscription:
mkdir -p ~/tf-labs/06-infra && cd ~/tf-labs/06-infra export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"Create
main.tfwith the provider skeleton and a resource grouprg-tf-infraineastus. Addvariables.tfwith a variableacr_name(typestring, no default — ACR names are globally unique, so you'll supply your own) andoutputs.tf(empty for now).Build the network layer first. Add to
main.tfa VNet (10.0.0.0/16), one subnetweb-subnet(10.0.1.0/24), an NSG allowing inbound TCP 443, and the subnet↔NSG association — using the blocks from the Concepts section. Then:terraform init terraform planVerify:
Plan: 5 to add(RG, VNet, subnet, NSG, association). Apply it. Confirm withaz network nsg show -g rg-tf-infra -n web-nsg -o table— the same NSG you built by hand in track 5 module 02.Add the ACR. Append the
azurerm_container_registryblock (Basic SKU,admin_enabled = false), usingvar.acr_namefor its name. Plan and apply, supplying a unique name:terraform apply -var="acr_name=acrtflearn$RANDOM"Verify:
Plan: 1 to add. Confirm withaz acr show -n <your-acr-name> -o table. (Keep the exact name — pass the same-varon every subsequent command, or put it in aterraform.tfvarsso it's auto-loaded.)Add the AKS cluster. Append the
azurerm_kubernetes_clusterblock (2 ×Standard_B2s,SystemAssignedidentity, adns_prefix). Plan and read carefully:terraform plan -var="acr_name=<same-name>"Verify:
Plan: 1 to addfor the cluster. This is the billable one. Apply it — it takes several minutes, same asaz aks create. Confirm:az aks show -g rg-tf-infra -n aks-tf-learn -o tableshowsSucceeded.Wire up the AcrPull attach. Append the
azurerm_role_assignmentblock from Concepts. Plan and apply.Verify:
Plan: 1 to add. Confirm the attach worked the same way track 7 module 03 did:az role assignment list --scope $(az acr show -n <acr-name> --query id -o tsv) -o tableshows anAcrPullassignment for the cluster's kubelet identity.Get kubeconfig two ways. Add the sensitive
kube_configoutput, apply, then extract it:terraform output -raw kube_config > ~/.kube/aks-tf-config KUBECONFIG=~/.kube/aks-tf-config kubectl get nodes -o wideThen compare against the
azpath you know from track 7:az aks get-credentials -g rg-tf-infra -n aks-tf-learn --file ~/.kube/aks-tf-config2Verify: both show two
Readynodes. You've reproducedaz aks get-credentialsvia a Terraform output.Push an image to prove the attach. Confirm the cluster can actually pull from the ACR by pushing a test image (reusing track 2/7 skills):
az acr import -n <acr-name> --source mcr.microsoft.com/hello-world:latest --image hello-world:v1 az acr repository list -n <acr-name> -o tableVerify:
hello-worldappears. A pod referencing<acr-name>.azurecr.io/hello-world:v1would pull without an image pull secret, because the AcrPull role assignment (exercise 5) is in place — the whole point of the attach.Diagnose and fix: a resource deleted out-of-band. This is the classic "Terraform's confusion" scenario. Delete the NSG behind Terraform's back with
az:az network nsg rule delete -g rg-tf-infra --nsg-name web-nsg -n allow-httpsNow run
terraform plan -var="acr_name=<name>".Observe: Terraform detects the rule is gone (drift) and plans to recreate it to match config —
Plan: 0 to add, 1 to change(or a rule re-add), reasserting your code as the source of truth. Now do a harsher version — delete the whole NSG:az network nsg delete -g rg-tf-infra -n web-nsgterraform planagain. Terraform now wants to recreate the NSG and its association (the association depended on the NSG). Fix by applying —terraform applyrebuilds exactly what was deleted, which is precisely the recovery superpower IaC gives you over click-ops: reality is restored from code, not from memory.Diagnose and fix: an unexpected replacement. Change the cluster's
dns_prefix(an immutable field):dns_prefix = "akstflearn2"Run
terraform plan -var="acr_name=<name>".Observe:
-/+ destroy and recreateon the entire AKS cluster with# forces replacementondns_prefix. Applying this would delete your cluster and build a new one — minutes of downtime and a new identity, breaking the AcrPull assignment until it's re-created. This is the plan you never blind-apply. Fix by revertingdns_prefixand confirmingplanshows no changes.Clean up — this is the billable teardown. Destroy everything in one command:
terraform destroy -var="acr_name=<name>"Review the
Plan: 0 to add, 0 to change, N to destroysummary, confirm it lists the AKS cluster, thenyes. Verify nothing survives:az group show -n rg-tf-infra -o table az group list -o tableVerify:
rg-tf-infrais gone (ResourceGroupNotFound), and the AKS-managedMC_*group (track 7) is gone too — Terraform's destroy removed the cluster, which cascades to the node resource group. One command tore down what took a dozenazcommands to build. Confirmaz aks list -o tableis empty so you know nothing is still billing.
Independent challenge
Without copying the exercise blocks, reproduce the AKS + ACR setup using
the reusable network module you wrote in module 04 as the source of the
cluster's networking — i.e. don't inline the VNet/subnet again; call your
module and feed the cluster into a subnet it produces (AKS with the azure
CNI network plugin takes a vnet_subnet_id on its default node pool). Build
the network via the module, an ACR, an AKS cluster placed in the module's
subnet, and the AcrPull attach, all parameterized by variables with a
terraform.tfvars. Prove the nodes are Ready and that an image pushed to
the ACR is visible, then destroy everything and confirm az aks list is
empty. This integrates module 04 (modules), module 03 (variables/outputs),
and this module's provisioning — the shape of your capstone.
Stuck? One hint
To place AKS in a specific subnet you set
default_node_pool { ... vnet_subnet_id = module.network.subnet_ids[0] }
and configure network_profile { network_plugin = "azure" } on the
cluster. Your module 04 network module already outputs subnet_ids — that's
the value you pass in. Read the azurerm_kubernetes_cluster registry page
for the exact network_profile fields (e.g. network_plugin,
service_cidr, dns_service_ip) rather than guessing — with azure CNI
some of these have interdependencies. Keep the node pool small and destroy
promptly; this is a full billable cluster.
Common mistakes & troubleshooting
- Forgetting the separate NSG-association resource. In
azurerm, attaching an NSG to a subnet is its ownazurerm_subnet_network_security_group_associationresource, not a field on the subnet. Leaving it out means the NSG exists but protects nothing. - Non-unique ACR or storage names. ACR names are globally unique and alphanumeric-only. A collision fails at apply, not plan. Use a random suffix.
- Referencing
kubelet_identitywrong. It's a list —kubelet_identity[0].object_id. Usingidentity(the cluster's control -plane identity) instead ofkubelet_identityfor AcrPull grants the wrong principal and pulls silently fail. - Blind-applying a cluster replacement. Changing
dns_prefix,default_node_pool.name, or other immutable fields forces a full destroy-and-recreate of AKS. Always check for# forces replacementbefore applying a change to a cluster. - Committing state with the kubeconfig in it.
kube_config_rawlands in state in plaintext. Keep state in the locked remote backend (module 05) and never commit it. - Cost pitfall — the big one for this track. This module creates an AKS
cluster with real VMs. Leaving it running overnight or over a weekend is
the exact charge tracks 7 and this track keep warning about.
terraform destroywhen done (oraz aks stopbetween sessions), and always confirmaz aks list -o tableis empty afterward. Acount/for_eachmistake here (e.g. a node pool count fed by the wrong variable) multiplies VM cost immediately — read the plan.
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.
- What
azurermresource type corresponds toaz network vnet create, and which corresponds toaz aks create? - In
azurerm, how do you attach an NSG to a subnet — is it a field on the subnet? - What does
az aks update --attach-acrbecome in Terraform, and what three things does that resource specify? - Which identity do you grant
AcrPull— the cluster'sidentityor itskubelet_identity— and how is it addressed? - How do you get a usable kubeconfig out of a Terraform-built AKS cluster,
and why must that output be
sensitive? - You deleted the NSG in the portal by accident. What does
terraform planthen show, and how do you recover the NSG? - A plan shows
-/+on the whole AKS cluster after you edited itsdns_prefix. What will applying do, and what should you do instead? - After
terraform destroyof your infra config, which other resource group also disappears, and how do you confirm nothing is still billing?
Show answers
az network vnet create→azurerm_virtual_network;az aks create→azurerm_kubernetes_cluster.- Not a field on the subnet — it's a separate resource,
azurerm_subnet_network_security_group_association, referencing both the subnet ID and the NSG ID. - An
azurerm_role_assignmentgrantingAcrPull. It specifies the principal (the cluster's kubelet identity), the role (role_definition_name = "AcrPull"), and the scope (the ACR's resource ID). - The
kubelet_identity(the image-pulling identity), addressed asazurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id. The cluster'sidentityis the control-plane identity and is the wrong principal for pulls. - Surface
azurerm_kubernetes_cluster.aks.kube_config_rawas an output and write it withterraform output -raw kube_config > file. It must besensitivebecause it contains cluster credentials (and is stored in state in plaintext). - Terraform detects the drift and plans to recreate the NSG (and its
association) to match config; you recover by running
terraform apply, which rebuilds exactly what was deleted. - Applying would destroy and recreate the entire cluster (downtime, new
identity, broken AcrPull until re-created) because
dns_prefixis immutable. Instead, revert the change so the plan shows no changes. - The AKS-managed
MC_*node resource group disappears too (destroying the cluster cascades to it). Confirm withaz aks list -o table(empty) andaz group list -o tableshowing neither group remains.
Next
07 — Terraform in CI/CD & testing:
run fmt/validate/plan/apply from a pipeline instead of your laptop,
and add linting and policy checks (tflint, checkov, a taste of
terraform test).