Terraform Lesson 45 of 89

Terraform on Azure: Provisioning AKS — Node Pools, Managed Identity, RBAC, Add-ons & kubeconfig

The Azure portal will happily stand up an AKS cluster in eight clicks. Then someone asks for a second cluster — staging — that is identical except smaller, and a third for a different team, and a fourth in a second region, and an auditor wants to know exactly which RBAC mode and which CNI each one runs. The portal has no answer to “identical except.” Terraform does. A single azurerm_kubernetes_cluster resource, parameterised, is the difference between a cluster you can rebuild from code in twenty minutes and a snowflake nobody dares touch. This lesson builds that resource end to end — not a toy, but the shape a platform team actually ships: managed identity so there are no service-principal secrets to rotate, an autoscaling user node pool separated from a tainted system pool, Azure CNI with a deliberately sized subnet, Entra ID RBAC with the local admin account switched off, the Monitor and Key Vault CSI add-ons, workload identity for keyless pod auth, and an attached Azure Container Registry the kubelet can pull from.

AKS is unusual among Azure resources because provisioning it is only half the job. The other half is getting a credential to talk to the cluster and deploying something onto it — and that second half is where Terraform users get burned. The kube_config outputs are sensitive. The kubernetes and helm providers have to be configured from the cluster you are creating in the same code, which sounds convenient and is in fact the single most common way to produce a config that plans fine and applies broken. We will treat the credential handoff and the “don’t build the cluster and its apps in one apply” rule as first-class topics, not footnotes, because getting them wrong is what turns a clean demo into a two-hour debugging session.

By the end you will have a complete, copy-pasteable configuration you run yourself: terraform init → plan → apply, then az aks get-credentials and kubectl get nodes to see real nodes spread across three availability zones, then a second apply that lands a Helm release through provider chaining, then terraform destroy so you are not paying for an idle cluster overnight. Every argument is laid out in reference tables you will come back to — the cluster arguments, the three node-pool personalities (system, user, spot), kubenet versus Azure CNI, the add-on matrix, and a troubleshooting table for the five failures that actually happen: auth to the cluster, CNI IP exhaustion, node-pool upgrades, sensitive kubeconfig leaks, and provider ordering.

What you’ll build

The scenario is the one every team reaches on week three of “we should run this on Kubernetes”: a small but correctly shaped AKS cluster that a real workload could move onto without re-architecting. Concretely, one terraform apply produces a resource group, a virtual network with a single AKS subnet, a Log Analytics workspace, an Azure Container Registry, and the cluster itself — with a system node pool that runs only the platform add-ons and a separate user node pool that autoscales from one to four nodes as pods demand. The control plane authenticates to Azure with a SystemAssigned managed identity (no secrets), pods reach the pod network over Azure CNI, humans authenticate with Entra ID and are authorised by Azure RBAC (the built-in local admin account is disabled), and the cluster’s kubelet identity is granted AcrPull so it can pull private images from the registry without an imagePullSecret.

Why Terraform rather than the portal, az aks create, or an ARM/Bicep template? Because AKS is a resource you will provision more than once and change over time, and those two facts are exactly what declarative IaC is built for. The comparison is worth pinning down:

Approach Repeatable? Drift visible? Handles day-2 change Best for
Portal No — click path is not code No Manual, error-prone A one-off you will delete
az aks create Scriptable, but imperative No — script does not track state You write the diff by hand Quick experiments, glue scripts
ARM / Bicep Yes, declarative Only via what-if Good on Azure, Azure-only All-Azure shops standardised on Bicep
Terraform (azurerm) Yes, declarative plan shows drift plan/apply compute the diff Multi-cloud, module reuse, one workflow across every provider

Terraform’s edge here is not that it is “better than Bicep at Azure” — Bicep is excellent at Azure. It is that the same plan → apply → destroy workflow, the same module and state discipline, and the same CI pipeline cover AKS, the VNet it sits in, the DNS zone in front of it, and the AWS or GCP resources next door. You learn one workflow and apply it everywhere. This lesson assumes you already have that workflow from the course’s foundation tier — Terraform Fundamentals: HCL, Providers, State & Workflow — and that your provider auth and remote backend are set up per the companion lesson Getting Started on Azure: Provider Authentication & Remote Backend. The VNet the cluster lives in is built the way the Azure Virtual Network, Subnets, NSGs & Peering lesson lays out.

Here is the whole build as a table of resources, so you can see the moving parts before the code:

Resource Terraform type Role in the cluster
Resource group azurerm_resource_group Container for everything below
Virtual network azurerm_virtual_network The address space the cluster lives in
AKS subnet azurerm_subnet Where nodes and (under Azure CNI) pods get IPs
Log Analytics workspace azurerm_log_analytics_workspace Sink for Container Insights / Monitor
Container registry azurerm_container_registry Private image store the kubelet pulls from
The cluster azurerm_kubernetes_cluster Control plane + default (system) node pool
User node pool azurerm_kubernetes_cluster_node_pool Autoscaling pool for your workloads
ACR pull grant azurerm_role_assignment AcrPull on the registry → kubelet identity
Cluster-admin grant azurerm_role_assignment AKS RBAC Cluster Admin → you, at cluster scope

Left-to-right AKS provisioning architecture: Terraform applies an azurerm_kubernetes_cluster with a SystemAssigned managed identity and Entra RBAC control plane, a tainted system node pool plus an autoscaling user node pool spread across availability zones inside an Azure CNI virtual-network subnet, the OMS Monitor and Key Vault CSI add-ons, and an attached Azure Container Registry granted AcrPull on the kubelet identity

Read the diagram left to right: Terraform (badge 6 — the kube_config outputs are sensitive) applies the control plane (badge 4 — Entra RBAC with local accounts off), which owns a tainted system pool (badge 1) and an autoscaling user pool (badge 2) inside an Azure CNI subnet you must size for pod IPs (badge 3); the Monitor and Key Vault CSI add-ons (badge 5 — workload identity/OIDC) attach, and the kubelet identity is granted AcrPull on the registry. The six legend entries are the six decisions you will make in code below.

The AKS resource model in Terraform

azurerm_kubernetes_cluster is a large resource because AKS is a large service, but it decomposes cleanly. At the top level you set the control-plane properties — Kubernetes version, SKU tier, DNS prefix, the security switches. Then a handful of nested blocks configure the pieces: exactly one default_node_pool (the system pool, created inline), one identity block (how the control plane authenticates to Azure), one network_profile (the CNI and CIDRs), and optional blocks for Entra RBAC and each add-on. Everything else — extra node pools, role assignments — is a separate resource that references the cluster.

The mental model that keeps this straight: the cluster resource is the control plane plus its first node pool; everything you add later is its own resource pointed at the cluster. That is why the default node pool is inline (a cluster cannot exist without one) but the user pool is azurerm_kubernetes_cluster_node_pool (you can have zero or many).

Here are the top-level cluster arguments you will actually set, what each does, and the gotcha attached to it:

Argument Type What it controls Note
name string Cluster resource name Not the DNS name
location / resource_group_name string Where it lives Region must support AZs if you use zones
dns_prefix string FQDN prefix of the API server Immutable — changing it recreates the cluster
kubernetes_version string Control-plane version Omit patch to auto-pick latest patch; check az aks get-versions
sku_tier string Free / Standard / Premium Standard adds the 99.95% Uptime SLA (~$0.10/hr); Free for labs
identity (block) Control-plane identity SystemAssigned or UserAssigned
default_node_pool (block) The inline system pool Required; see next section
network_profile (block) CNI, policy, CIDRs Immutable after create — plan it once
azure_active_directory_role_based_access_control (block) Entra integration v4 dropped the legacy fields
local_account_disabled bool Kill the built-in cert admin true in prod; needs an Azure RBAC grant to you
oidc_issuer_enabled bool Publish an OIDC issuer URL Prerequisite for workload identity
workload_identity_enabled bool Entra Workload Identity webhook Keyless pod → Azure auth
azure_policy_enabled bool Gatekeeper/Azure Policy add-on Governance guardrails
oms_agent (block) Container Insights / Monitor Points at a Log Analytics workspace
key_vault_secrets_provider (block) Secrets Store CSI driver Mount Key Vault secrets as volumes
automatic_upgrade_channel string Auto-upgrade cadence patch/stable/rapid/node-image; renamed in v4
role_based_access_control_enabled bool K8s RBAC on/off Defaults true; leave it on
tags map Azure tags Your cost/ownership metadata

Two version notes that will save you a confusing afternoon. First, the azurerm 4.x provider made subscription_id mandatory — either set it on the provider "azurerm" block or export ARM_SUBSCRIPTION_ID. Configurations that worked on 3.x error immediately on 4.x with subscription_id is a required provider property. Second, azurerm 4.0 renamed a batch of AKS arguments; if you are copying older HCL from a blog, translate it:

azurerm 3.x (old) azurerm 4.x (current) Where
enable_auto_scaling auto_scaling_enabled node pools
enable_host_encryption host_encryption_enabled node pools
enable_node_public_ip node_public_ip_enabled node pools
availability_zones zones node pools
automatic_channel_upgrade automatic_upgrade_channel cluster
node_os_channel_upgrade node_os_upgrade_channel cluster
azure_active_directory_role_based_access_control { managed = true, client_app_id, server_app_id, ... } block simplified — managed AAD only cluster
network_profile { docker_bridge_cidr = ... } removed cluster

The Entra block change is the one that bites hardest: in v4 the legacy (non-managed) Entra integration is gone, so managed, client_app_id, server_app_id, and server_app_secret are no longer valid arguments. Managed AAD is the only mode, and the block reduces to tenant_id, admin_group_object_ids, and azure_rbac_enabled.

The default (system) node pool and additional node pools

A node pool is a set of identical VMs — a Virtual Machine Scale Set under the hood — that Kubernetes schedules pods onto. AKS distinguishes two modes: a System pool runs the critical kube-system components (CoreDNS, metrics-server, the CSI drivers) and a cluster must always have at least one; User pools run your workloads. The default_node_pool you declare inline is always a System pool. You then add User pools as separate resources. The reason you separate them is blast radius and scheduling: you do not want a runaway workload starving CoreDNS, and you want to scale, taint, and upgrade application capacity independently of the platform’s.

The default_node_pool block carries the system pool’s shape:

Argument Example Meaning
name "system" 1–12 lowercase alphanumerics (Linux)
vm_size "Standard_D2s_v5" The VM SKU; 2 vCPU/8 GB is a sane floor
node_count 1 Fixed size when autoscaling is off
auto_scaling_enabled true Turn on the cluster autoscaler for this pool
min_count / max_count 1 / 3 Bounds when autoscaling is on
zones ["1","2","3"] Spread nodes across availability zones
vnet_subnet_id azurerm_subnet.aks.id Which subnet nodes (and CNI pods) draw IPs from
orchestrator_version "1.30" Node Kubernetes version (can trail the control plane)
os_disk_size_gb 64 OS disk size
os_disk_type "Managed" Managed (durable) or Ephemeral (faster, node-local)
os_sku "AzureLinux" Ubuntu, AzureLinux (formerly Mariner), or Windows SKUs
max_pods 30 Pods per node — drives Azure CNI subnet sizing
only_critical_addons_enabled true Taints the pool CriticalAddonsOnly=true:NoSchedule
temporary_name_for_rotation "systmp" Lets certain in-place changes cycle nodes safely
upgrade_settings (block) max_surge = "33%" Surge capacity during upgrades

node_count versus autoscaling is the decision people get wrong most often. They are mutually exclusive in intent: if auto_scaling_enabled = true, the cluster autoscaler owns the node count and will change it out from under Terraform, so you must stop Terraform fighting it. The two rules:

Setting With autoscaling OFF With autoscaling ON
node_count You set it; Terraform enforces it Initial size only; autoscaler then owns it
min_count / max_count Must be null Required; the scaling bounds
Drift risk None plan wants to “correct” node_count every run
Fix lifecycle { ignore_changes = [node_count] }

That ignore_changes on node_count is not optional — without it, every terraform plan after the autoscaler has moved will show a spurious change and every apply will scale you back to the initial count. This is the canonical example from the Meta-Arguments: count, for_each & lifecycle lesson, and AKS is where it earns its keep.

The default node pool has one more restriction worth memorising: it does not accept arbitrary node_taints. The only taint you can put on it is via only_critical_addons_enabled = true, which applies CriticalAddonsOnly=true:NoSchedule and thereby reserves the system pool for platform pods. If you need custom taints on system-adjacent capacity, make a second System-mode pool as an azurerm_kubernetes_cluster_node_pool.

Additional pools are their own resource. The three personalities you will build are user, spot, and occasionally a second system pool:

Property System pool User pool Spot pool
mode "System" "User" "User"
Runs kube-system + optionally apps Your workloads Interruptible batch/stateless
priority "Regular" (default) "Regular" "Spot"
eviction_policy n/a n/a "Delete" or "Deallocate"
spot_max_price n/a n/a -1 = pay up to on-demand
Can scale to zero No (min 1) Yes (min_count = 0) Yes (min_count = 0)
Typical taint CriticalAddonsOnly none kubernetes.azure.com/scalesetpriority=spot:NoSchedule
Deleting the last one Forbidden Fine Fine
Discount none none up to ~90% (evictable)

A spot pool is the cheapest capacity Azure sells, at the cost of being reclaimable with 30 seconds’ notice. Azure automatically applies the label kubernetes.azure.com/scalesetpriority=spot and the taint kubernetes.azure.com/scalesetpriority=spot:NoSchedule so that only pods that explicitly tolerate spot land there. Declaring that taint in Terraform too keeps the provider from seeing drift on every plan. Here is a spot pool in full, showing the taint/label pattern:

resource "azurerm_kubernetes_cluster_node_pool" "spot" {
  name                  = "spot"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.this.id
  vm_size               = "Standard_D4s_v5"
  mode                  = "User"

  priority        = "Spot"
  eviction_policy = "Delete"
  spot_max_price  = -1 # -1 means "up to the current on-demand price"

  auto_scaling_enabled = true
  min_count            = 0 # spot can scale to zero when idle
  max_count            = 5
  zones                = ["1", "2", "3"]

  # Azure adds these automatically; declaring them stops Terraform drift.
  node_labels = {
    "kubernetes.azure.com/scalesetpriority" = "spot"
  }
  node_taints = [
    "kubernetes.azure.com/scalesetpriority=spot:NoSchedule",
  ]

  lifecycle {
    ignore_changes = [node_count] # the autoscaler owns the count
  }

  tags = { environment = "dev" }
}

Labels versus taints is a distinction people blur. A label is metadata a pod’s nodeSelector/nodeAffinity can target (“put me on the GPU pool”). A taint is a repellent — a node says “nothing schedules here unless it tolerates me.” You use labels to attract specific pods and taints to reserve nodes for specific pods. A GPU pool typically has both: a label workload=gpu so GPU jobs can select it, and a taint sku=gpu:NoSchedule so ordinary pods stay off the expensive hardware.

AKS also sets a batch of well-known labels and taints you will see and select against — know which are Azure’s and which are yours:

Label / taint Set by Meaning / use
kubernetes.azure.com/mode=system|user AKS Pool mode; select add-ons vs apps
agentpool=<name> AKS Target a specific pool by name
topology.kubernetes.io/zone AKS The node’s availability zone
kubernetes.azure.com/scalesetpriority=spot AKS (spot pools) Marks a spot node (a label)
kubernetes.azure.com/scalesetpriority=spot:NoSchedule AKS (spot pools) Repels non-tolerating pods (a taint)
CriticalAddonsOnly=true:NoSchedule only_critical_addons_enabled Reserves the system pool
node_labels { ... } You Custom selectors on a pool
node_taints [ ... ] You Custom reservations on a pool

Cluster identity, kubelet identity & attaching ACR

AKS needs two distinct identities, and conflating them is a frequent source of “why can’t my cluster pull images” tickets. The control-plane identity is what the cluster uses to talk to Azure Resource Manager — to create the load balancer, attach disks, update the route table. The kubelet identity is what the node agents use to pull container images from a registry. They are separate principals with separate permissions.

Your first decision is SystemAssigned versus UserAssigned for the control plane:

Aspect SystemAssigned UserAssigned
Lifecycle Tied to the cluster; deleted with it Independent azurerm_user_assigned_identity
Secrets to manage None None
Reuse across clusters No Yes — one identity, many clusters
Role assignments survive rebuild No (new principal each time) Yes — grants persist
Extra requirement none Control-plane MI needs Managed Identity Operator on the kubelet identity
Best for Single clusters, labs, simplicity Fleets, pre-provisioned RBAC, GitOps

For this build we use SystemAssigned — it is simpler and there are no secrets either way (this is the whole point of managed identity: no service principal, no client secret to rotate, nothing to leak). The one wrinkle: because a SystemAssigned principal is recreated every time the cluster is recreated, any role assignments you make to it (the AcrPull grant below) must be managed by Terraform so they are re-created too. If you were running a large fleet you would switch to a UserAssigned control-plane identity plus a UserAssigned kubelet identity so the RBAC survives cluster rebuilds — the block becomes identity { type = "UserAssigned"; identity_ids = [azurerm_user_assigned_identity.cp.id] }.

The kubelet identity is the interesting one for registry access. With a SystemAssigned control plane, AKS auto-creates a separate user-assigned identity for the kubelet and exposes its object ID as a computed attribute: azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id. That object ID is the principal you grant AcrPull to. “Attaching ACR” is nothing more magical than an AcrPull role assignment on the registry scoped to the kubelet identity:

resource "azurerm_role_assignment" "aks_acr_pull" {
  scope                            = azurerm_container_registry.this.id
  role_definition_name             = "AcrPull"
  principal_id                     = azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id
  skip_service_principal_aad_check = true
}

That skip_service_principal_aad_check = true matters: the kubelet identity is brand new when this runs, and Entra replication can lag by a few seconds, so without the skip you sometimes get PrincipalNotFound. The flag tells Azure “trust me, this principal exists” and avoids the race. This one role assignment replaces the old, awful pattern of baking registry credentials into an imagePullSecret — the kubelet now authenticates to ACR with its managed identity and no secret exists anywhere.

Identity Terraform reference Grant it
Control-plane MI azurerm_kubernetes_cluster.this.identity[0].principal_id Network Contributor on a pre-existing VNet (if using one)
Kubelet MI azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id AcrPull on the registry
Your user/group data.azurerm_client_config.current.object_id AKS RBAC Cluster Admin on the cluster

Networking: kubenet vs Azure CNI, network policy & CIDRs

The network_profile block is the most consequential decision in the whole configuration, because it is immutable — you cannot switch a cluster from kubenet to Azure CNI, or change its service CIDR, after it is created. Get it right on paper first.

The core choice is the network plugin. Under kubenet, nodes get IPs from your subnet but pods get IPs from a separate, cluster-internal pod_cidr, reached via route-table hops; pods are effectively NAT’d behind their node. Under Azure CNI, every pod gets a real IP from the subnet, so pods are first-class citizens on your VNet — directly routable, directly addressable by peered networks and private endpoints. That routability is why most production clusters choose CNI, and it comes with a cost you must plan for: pods consume subnet IPs fast.

Dimension kubenet Azure CNI (node subnet) Azure CNI Overlay
Pod IP source Internal pod_cidr (NAT’d) The VNet subnet (real IP) Internal overlay CIDR
Subnet IPs consumed Nodes only Nodes + every pod Nodes only
Pods directly routable No Yes No (SNAT out)
Max nodes vs subnet Large (pods off-subnet) Bounded by subnet size Large
Network policy support Calico only Azure / Calico / Cilium Azure / Cilium
Windows nodes No Yes Yes
Best for Small clusters, IP-scarce VNets Pods needing VNet routability Large clusters wanting CNI features without IP burn

For this lesson we use Azure CNI (node subnet) because it is the mode whose IP math you must understand, and because it is what most enterprise clusters run. The network_profile arguments:

Argument Value here Rule
network_plugin "azure" azure = CNI, kubenet, or none (bring-your-own-CNI)
network_plugin_mode omit set to "overlay" for CNI Overlay
network_policy "azure" azure, calico, or cilium
service_cidr "10.100.0.0/16" Cluster-internal Service IPs; must not overlap the VNet
dns_service_ip "10.100.0.10" CoreDNS IP; must be inside service_cidr
pod_cidr omit (CNI node subnet) Only for kubenet or CNI Overlay
load_balancer_sku "standard" standard (zonal, required for AZs) or basic
outbound_type "loadBalancer" or userDefinedRouting / managedNATGateway

Network policy is the pod firewall — without it, every pod can talk to every other pod. Three engines are available, and they are not interchangeable:

Policy engine Enforces Notes
azure Kubernetes NetworkPolicy Azure’s native NPM; simplest, Linux only
calico NetworkPolicy + Calico global policies Works with kubenet or CNI; richer, community
cilium NetworkPolicy + eBPF, L7-aware Requires network_data_plane = "cilium"; highest performance

The number that sinks clusters is the Azure CNI subnet size. Because every pod takes a subnet IP, the addresses a pool can consume is (max nodes after surge) × (1 + max_pods) — the 1 is the node’s own IP. Plan the subnet against your maximum scale, not today’s:

Subnet Usable IPs max_pods = 30 caps ~ max_pods = 50 caps ~
/26 ~59 1 node 1 node
/24 ~251 ~8 nodes ~4 nodes
/22 ~1019 ~32 nodes ~19 nodes
/21 ~2043 ~65 nodes ~40 nodes
/20 ~4091 ~131 nodes ~80 nodes

We use a /22 here (1024 addresses) inside a /16 VNet, leaving headroom for other subnets. Azure reserves 5 IPs per subnet, and you must budget for surge nodes during upgrades (a max_surge = "33%" on a 30-node pool briefly needs ~10 extra nodes’ worth of IPs). Undersize the subnet and the symptom is unmistakable: new pods hang in ContainerCreating with FailedToAllocateAddress, and no amount of node capacity fixes it because the constraint is IPs, not CPU. If you want CNI’s routability without the IP burn, CNI Overlay (network_plugin_mode = "overlay") puts pods on an internal CIDR and only nodes consume subnet IPs — the modern default for large clusters.

Entra ID RBAC & disabling local accounts

Out of the box, an AKS cluster ships with a local admin account — a client certificate baked into kube_admin_config that grants full cluster-admin and bypasses Entra entirely. It is convenient and it is a liability: anyone who can read that certificate (from state, from az aks get-credentials --admin, from a leaked kubeconfig) owns the cluster, with no MFA, no conditional access, no audit trail tied to a human. Production clusters disable it and route all human access through Entra ID.

The azure_active_directory_role_based_access_control block turns on managed Entra integration:

azure_active_directory_role_based_access_control {
  azure_rbac_enabled     = true
  admin_group_object_ids = var.admin_group_object_ids # Entra group → cluster-admin
  tenant_id              = data.azurerm_client_config.current.tenant_id
}
local_account_disabled = true

There are two layers of authorization here, and understanding the split prevents the classic self-lockout:

Control What it does Who authorizes
Entra authentication Proves who you are (token from Entra) Entra ID
admin_group_object_ids Members get cluster-admin via a K8s ClusterRoleBinding AKS-managed binding
azure_rbac_enabled = true Authorize with Azure role assignments, not K8s RBAC Azure RBAC
local_account_disabled = true Removes the certificate bypass

With azure_rbac_enabled = true, you stop managing RoleBinding/ClusterRoleBinding objects inside the cluster and instead grant Azure built-in roles scoped to the cluster: Azure Kubernetes Service RBAC Reader, Writer, Admin, and Cluster Admin. This is the elegant part — cluster access becomes ordinary Azure RBAC, visible in the same place as every other permission, revocable the same way.

The trap: if you set local_account_disabled = true and forget to grant yourself an Azure RBAC role on the cluster, you have locked everyone out — there is no certificate to fall back on. So the configuration must grant the running identity cluster-admin at cluster scope:

resource "azurerm_role_assignment" "me_cluster_admin" {
  scope                = azurerm_kubernetes_cluster.this.id
  role_definition_name = "Azure Kubernetes Service RBAC Cluster Admin"
  principal_id         = data.azurerm_client_config.current.object_id
}

Now az aks get-credentials (no --admin) followed by kubectl authenticates you through Entra and authorises you through that Azure role. The built-in roles map as follows:

Azure built-in role Cluster capability
AKS RBAC Reader Read most objects (no Secrets) in namespaces you are scoped to
AKS RBAC Writer Read/write most objects (no cluster-admin resources)
AKS RBAC Admin Admin within a namespace scope
AKS RBAC Cluster Admin Full cluster-admin across the cluster
AKS Cluster Admin Role (control-plane) Can pull the local admin kubeconfig (--admin) — deny in prod
AKS Cluster User Role (control-plane) Can run az aks get-credentials (non-admin)

Add-ons: Monitor, Azure Policy, Key Vault CSI & Workload Identity

Add-ons are Azure-managed components AKS installs and lifecycles for you — you enable them with an argument or a block instead of running Helm charts yourself. The four you almost always want:

Add-on Terraform What it gives you Cost driver
Monitor / Container Insights oms_agent { log_analytics_workspace_id } Node/pod metrics + logs in Azure Monitor Log Analytics ingestion (GB)
Azure Policy azure_policy_enabled = true Gatekeeper policies (deny privileged pods, etc.) Free (compute only)
Key Vault Secrets Provider key_vault_secrets_provider { ... } Secrets Store CSI driver — mount KV secrets as files Free (compute only)
Workload Identity oidc_issuer_enabled + workload_identity_enabled Keyless pod → Entra federation Free
Defender for Containers microsoft_defender { log_analytics_workspace_id } Runtime threat detection Defender plan per vCPU

The Monitor add-on wires the cluster to a Log Analytics workspace. Prefer the managed-identity auth path (msi_auth_for_monitoring_enabled = true) over the legacy workspace-key method:

oms_agent {
  log_analytics_workspace_id      = azurerm_log_analytics_workspace.this.id
  msi_auth_for_monitoring_enabled = true
}

The Key Vault Secrets Provider installs the Secrets Store CSI driver so a pod can mount a Key Vault secret as a file (and optionally sync it to a Kubernetes Secret). secret_rotation_enabled makes the driver poll for new versions:

key_vault_secrets_provider {
  secret_rotation_enabled  = true
  secret_rotation_interval = "2m"
}

Workload Identity is the modern, keyless way for a pod to authenticate to Azure services (Key Vault, Storage, SQL) with no secret at all. Two switches enable the plumbing:

oidc_issuer_enabled       = true
workload_identity_enabled = true

oidc_issuer_enabled publishes an OIDC issuer URL (exposed as the output oidc_issuer_url) that Entra trusts. workload_identity_enabled installs the mutating webhook that projects a federated token into pods. The full loop — which you would build in a follow-up — federates a Kubernetes ServiceAccount to an Entra identity:

Piece Resource Role
OIDC issuer cluster oidc_issuer_url output The trust anchor Entra federates against
User-assigned identity azurerm_user_assigned_identity The Azure identity the pod becomes
Federated credential azurerm_federated_identity_credential Binds system:serviceaccount:<ns>:<sa> to the identity
Pod annotation azure.workload.identity/client-id on the SA Tells the webhook which identity to project

The result: a pod annotated with the right ServiceAccount gets an Entra token automatically, and the KV CSI driver uses that token to pull secrets — no stored credential anywhere in the chain. This is the pattern that replaces the old AAD Pod Identity and the even older practice of mounting a service-principal secret into every pod.

kubeconfig, provider chaining & the split-apply rule

You have a cluster. Now you need to talk to it — and this is where Terraform-provisioned AKS diverges sharply from, say, a VM you SSH into. The credential lives in the cluster’s outputs, and it is radioactive.

azurerm_kubernetes_cluster exposes several credential attributes. Know which is which:

Attribute Contents When populated Sensitive
kube_config_raw Full kubeconfig YAML (user/Entra) Always Yes
kube_config[0].host API server URL Always Yes
kube_config[0].client_certificate / client_key User certs Empty when Entra RBAC is on Yes
kube_config[0].cluster_ca_certificate Cluster CA Always Yes
kube_admin_config_raw Full admin kubeconfig YAML Only when local_account_disabled = false Yes
kube_admin_config[0].* Admin certs/host/CA Only when local account enabled Yes
oidc_issuer_url OIDC issuer When oidc_issuer_enabled No

Every credential attribute is sensitive, and Terraform enforces it: if you output kube_config_raw without sensitive = true, the plan errors. Mark them and understand what “sensitive” does and does not buy you:

output "kube_config_raw" {
  value     = azurerm_kubernetes_cluster.this.kube_config_raw
  sensitive = true
}

sensitive = true only redacts the value from CLI output. The credential is still stored in plaintext in your state file. That is the real reason the remote backend is non-negotiable for AKS: the state must live in an encrypted, access-controlled, leased blob — never on a laptop, never in git. Anyone with read access to state can read the cluster credential.

Provider chaining

To deploy onto the cluster from Terraform you configure the kubernetes and helm providers from the cluster’s outputs — “provider chaining.” Which attribute you feed them depends on your auth mode:

Cluster auth Provider config source How
No Entra (local account) kube_config[0].* Static client certs
Entra RBAC, local account enabled kube_admin_config[0].* Admin certs (bypass)
Entra RBAC, local account disabled kube_config[0].host + CA + exec kubelogin token via exec plugin

For an Entra cluster with the local account still enabled, chain from kube_admin_config:

provider "kubernetes" {
  host                   = azurerm_kubernetes_cluster.this.kube_admin_config[0].host
  client_certificate     = base64decode(azurerm_kubernetes_cluster.this.kube_admin_config[0].client_certificate)
  client_key             = base64decode(azurerm_kubernetes_cluster.this.kube_admin_config[0].client_key)
  cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.this.kube_admin_config[0].cluster_ca_certificate)
}

When you disable the local account (the production posture), kube_admin_config is empty and you must authenticate the provider with an exec block that shells out to kubelogin:

provider "kubernetes" {
  host                   = azurerm_kubernetes_cluster.this.kube_config[0].host
  cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.this.kube_config[0].cluster_ca_certificate)
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "kubelogin"
    args = [
      "get-token", "--login", "azurecli",
      "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630", # the AKS Entra server app ID (constant)
    ]
  }
}

The split-apply rule (the gotcha that matters most)

Here is the rule you must internalise: do not create the cluster and its in-cluster resources in the same terraform apply. It is seductive — one config, one apply, cluster and app together — and it produces configurations that are fragile at best and un-destroy-able at worst. The reasons:

Problem Why it happens Symptom
Provider config uses unknown values The kubernetes provider is configured from cluster attributes not known until apply Provider configuration ... depends on resource attributes that cannot be determined until apply
kubernetes_manifest needs a live API at plan It does a server-side dry-run against the cluster dial tcp ...: connect: connection refused during plan
Destroy ordering inverts Terraform may plan to destroy the cluster before the workloads that need it Hung or errored destroy
One blast radius An app typo forces re-plan of the whole cluster Slow, scary applies

The fix is architectural: two root configurations (two states). The first builds the cluster and platform (this lesson). The second reads the cluster — via a remote-state data source or data "azurerm_kubernetes_cluster" — configures the kubernetes/helm providers from that, and deploys workloads. The cluster’s lifecycle and the apps’ lifecycle are now independent, plan-time API access is available because the cluster already exists, and destroy is clean in each layer. We build both layers below.

Hands-on: build it with Terraform

Time to run it. This is a complete, self-contained configuration; paste each file into a directory (say aks-cluster/) and follow the numbered steps. ⚠️ This provisions real, billable resources — node VMs, a load balancer, a registry, log ingestion. Do the destroy at the end.

Step 1 — versions.tf (providers + backend)

terraform {
  required_version = ">= 1.6"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }

  # Remote state — an Azure Storage blob with lease-based locking.
  # Create this backend once per the "Getting Started on Azure" lesson.
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateaksdemo01" # must be globally unique
    container_name       = "tfstate"
    key                  = "aks/dev.tfstate"
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id # required by azurerm 4.x
}

data "azurerm_client_config" "current" {}

Step 2 — variables.tf

variable "subscription_id" {
  description = "Target Azure subscription ID"
  type        = string
}

variable "prefix" {
  description = "Name prefix for all resources"
  type        = string
  default     = "kv-aks-dev"
}

variable "location" {
  description = "Azure region (must support availability zones)"
  type        = string
  default     = "centralindia"
}

variable "kubernetes_version" {
  description = "Control-plane version; check `az aks get-versions -l <region> -o table`"
  type        = string
  default     = "1.30"
}

variable "zones" {
  description = "Availability zones to spread nodes across"
  type        = list(string)
  default     = ["1", "2", "3"]
}

variable "system_vm_size" {
  type    = string
  default = "Standard_D2s_v5" # 2 vCPU / 8 GB
}

variable "user_vm_size" {
  type    = string
  default = "Standard_D2s_v5"
}

variable "user_min_count" {
  type    = number
  default = 1
}

variable "user_max_count" {
  type    = number
  default = 4
}

variable "vnet_cidr" {
  type    = string
  default = "10.20.0.0/16"
}

variable "aks_subnet_cidr" {
  type    = string
  default = "10.20.0.0/22" # 1024 IPs — Azure CNI needs one per pod
}

variable "service_cidr" {
  type    = string
  default = "10.100.0.0/16" # must NOT overlap the VNet
}

variable "dns_service_ip" {
  type    = string
  default = "10.100.0.10" # must be inside service_cidr
}

variable "admin_group_object_ids" {
  description = "Entra group object IDs granted cluster-admin"
  type        = list(string)
  default     = []
}

variable "local_account_disabled" {
  description = "Disable the built-in local admin (true in prod)"
  type        = bool
  default     = false # kept false for the lab so `--admin` works; flip to true for prod
}

variable "tags" {
  type = map(string)
  default = {
    environment = "dev"
    managed_by  = "terraform"
    course      = "terraform-zero-to-hero"
  }
}

Step 3 — main.tf (the resources)

resource "random_string" "suffix" {
  length  = 6
  special = false
  upper   = false
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.prefix}"
  location = var.location
  tags     = var.tags
}

# ---- Network ---------------------------------------------------------------
resource "azurerm_virtual_network" "this" {
  name                = "${var.prefix}-vnet"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  address_space       = [var.vnet_cidr]
  tags                = var.tags
}

resource "azurerm_subnet" "aks" {
  name                 = "snet-aks"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [var.aks_subnet_cidr]
}

# ---- Observability + registry ---------------------------------------------
resource "azurerm_log_analytics_workspace" "this" {
  name                = "${var.prefix}-law"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  sku                 = "PerGB2018"
  retention_in_days   = 30
  tags                = var.tags
}

resource "azurerm_container_registry" "this" {
  name                = "${replace(var.prefix, "-", "")}acr${random_string.suffix.result}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  sku                 = "Standard"
  admin_enabled       = false # never use the admin user — the kubelet MI pulls
  tags                = var.tags
}

# ---- The cluster -----------------------------------------------------------
resource "azurerm_kubernetes_cluster" "this" {
  name                = "${var.prefix}-aks"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  dns_prefix          = "${var.prefix}-aks"
  kubernetes_version  = var.kubernetes_version
  sku_tier            = "Free" # "Standard" adds the Uptime SLA in prod

  local_account_disabled    = var.local_account_disabled
  oidc_issuer_enabled       = true
  workload_identity_enabled = true
  azure_policy_enabled      = true

  default_node_pool {
    name                         = "system"
    vm_size                      = var.system_vm_size
    node_count                   = 1
    zones                        = var.zones
    vnet_subnet_id               = azurerm_subnet.aks.id
    orchestrator_version         = var.kubernetes_version
    os_sku                       = "AzureLinux"
    os_disk_size_gb              = 64
    max_pods                     = 30
    only_critical_addons_enabled = true # taint CriticalAddonsOnly — apps go to the user pool

    upgrade_settings {
      max_surge = "33%"
    }
  }

  identity {
    type = "SystemAssigned"
  }

  network_profile {
    network_plugin    = "azure" # Azure CNI
    network_policy    = "azure"
    service_cidr      = var.service_cidr
    dns_service_ip    = var.dns_service_ip
    load_balancer_sku = "standard"
  }

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled     = true
    admin_group_object_ids = var.admin_group_object_ids
    tenant_id              = data.azurerm_client_config.current.tenant_id
  }

  oms_agent {
    log_analytics_workspace_id      = azurerm_log_analytics_workspace.this.id
    msi_auth_for_monitoring_enabled = true
  }

  key_vault_secrets_provider {
    secret_rotation_enabled  = true
    secret_rotation_interval = "2m"
  }

  tags = var.tags
}

# ---- Autoscaling user node pool -------------------------------------------
resource "azurerm_kubernetes_cluster_node_pool" "user" {
  name                  = "user"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.this.id
  vm_size               = var.user_vm_size
  mode                  = "User"

  auto_scaling_enabled = true
  min_count            = var.user_min_count
  max_count            = var.user_max_count
  zones                = var.zones
  vnet_subnet_id       = azurerm_subnet.aks.id
  orchestrator_version = var.kubernetes_version
  os_sku               = "AzureLinux"
  max_pods             = 30

  node_labels = {
    "workload" = "general"
  }

  lifecycle {
    ignore_changes = [node_count] # the cluster autoscaler owns the count
  }

  tags = var.tags
}

# ---- Role assignments ------------------------------------------------------
# Kubelet identity may pull from the registry (no imagePullSecret needed).
resource "azurerm_role_assignment" "aks_acr_pull" {
  scope                            = azurerm_container_registry.this.id
  role_definition_name             = "AcrPull"
  principal_id                     = azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id
  skip_service_principal_aad_check = true
}

# You get cluster-admin via Azure RBAC (essential when local_account_disabled = true).
resource "azurerm_role_assignment" "me_cluster_admin" {
  scope                = azurerm_kubernetes_cluster.this.id
  role_definition_name = "Azure Kubernetes Service RBAC Cluster Admin"
  principal_id         = data.azurerm_client_config.current.object_id
}

Step 4 — outputs.tf

output "cluster_name" {
  value = azurerm_kubernetes_cluster.this.name
}

output "resource_group" {
  value = azurerm_resource_group.this.name
}

output "acr_login_server" {
  value = azurerm_container_registry.this.login_server
}

output "oidc_issuer_url" {
  value = azurerm_kubernetes_cluster.this.oidc_issuer_url
}

output "kubelet_identity_object_id" {
  value = azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id
}

# ⚠️ Sensitive — the full kubeconfig. Terraform requires sensitive = true.
output "kube_config_raw" {
  value     = azurerm_kubernetes_cluster.this.kube_config_raw
  sensitive = true
}

Step 5 — init, plan, apply

Set the subscription and initialise:

export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
terraform init
Initializing the backend...
Successfully configured the backend "azurerm"!
Initializing provider plugins...
- Installing hashicorp/azurerm v4.x.x...
- Installing hashicorp/random v3.6.x...
Terraform has been successfully initialized!

Plan it (pass the subscription via a var or the env var above):

terraform plan -out=aks.plan
Terraform will perform the following actions:

  # azurerm_kubernetes_cluster.this will be created
  + resource "azurerm_kubernetes_cluster" "this" {
      + dns_prefix                = "kv-aks-dev-aks"
      + kubernetes_version        = "1.30"
      + local_account_disabled    = false
      + oidc_issuer_enabled       = true
      + workload_identity_enabled = true
      + kube_config_raw           = (sensitive value)
      + default_node_pool { name = "system"; vm_size = "Standard_D2s_v5"; ... }
      + identity { type = "SystemAssigned" }
      + network_profile { network_plugin = "azure"; service_cidr = "10.100.0.0/16"; ... }
    }
  # azurerm_kubernetes_cluster_node_pool.user will be created
  # azurerm_role_assignment.aks_acr_pull will be created
  # ... (resource group, vnet, subnet, workspace, registry, role assignment)

Plan: 9 to add, 0 to change, 0 to destroy.

Apply (the cluster typically takes 4–8 minutes):

terraform apply aks.plan
azurerm_kubernetes_cluster.this: Still creating... [4m30s elapsed]
azurerm_kubernetes_cluster.this: Creation complete after 5m12s
azurerm_kubernetes_cluster_node_pool.user: Creation complete after 1m40s
azurerm_role_assignment.aks_acr_pull: Creation complete after 8s

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:
acr_login_server = "kvaksdevacrb3f9k2.azurecr.io"
oidc_issuer_url  = "https://centralindia.oic.prod-aks.azure.com/<tenant>/<guid>/"

Step 6 — get a kubeconfig and verify

Pull credentials and prove the cluster. Because local_account_disabled = false in the lab, the quickest verification uses the local admin credential:

az aks get-credentials \
  --resource-group "$(terraform output -raw resource_group)" \
  --name "$(terraform output -raw cluster_name)" \
  --admin --overwrite-existing

kubectl get nodes -o wide
NAME                             STATUS   ROLES   AGE     VERSION   ZONE
aks-system-2043...-vmss000000    Ready    <none>  6m      v1.30.x   centralindia-1
aks-user-7781...-vmss000000      Ready    <none>  4m      v1.30.x   centralindia-2

Two nodes: the single system node and one user node (the autoscaler’s minimum). Note the different zones — HA is working. The production-correct path (no --admin) uses the Azure RBAC grant you created; it authenticates through Entra via kubelogin:

az aks get-credentials -g "$(terraform output -raw resource_group)" \
  -n "$(terraform output -raw cluster_name)" --overwrite-existing
kubectl get nodes   # opens an Entra login the first time

Confirm the add-ons landed and the system pool is tainted:

kubectl get pods -n kube-system | grep -E "ama-logs|azure-policy|secrets-store"
kubectl describe node -l kubernetes.azure.com/mode=system | grep Taints
# Taints: CriticalAddonsOnly=true:NoSchedule

Run through the full smoke-test checklist so you know every piece of the build actually landed:

Check Command Expect
Nodes ready across zones kubectl get nodes -o wide 2 nodes, different ZONE
Add-on pods running kubectl get pods -n kube-system ama-logs, azure-policy, secrets-store-csi-driver
System pool is tainted kubectl describe node -l kubernetes.azure.com/mode=system CriticalAddonsOnly=true:NoSchedule
OIDC issuer published terraform output oidc_issuer_url an https://...oic... URL
Autoscaler bounds set az aks nodepool show -g RG --cluster-name NAME -n user --query '{min:minCount,max:maxCount}' 1 / 4
ACR pull authorised kubectl run t --image=<acr>.azurecr.io/<img> --restart=Never Running (no ImagePullBackOff)

Step 7 — deploy an app via provider chaining (the SECOND apply)

Per the split-apply rule, workloads live in a separate configuration. Create aks-apps/ with its own state. It reads the cluster as a data source and chains the kubernetes/helm providers from it:

# aks-apps/versions.tf
terraform {
  required_providers {
    azurerm    = { source = "hashicorp/azurerm", version = "~> 4.0" }
    helm       = { source = "hashicorp/helm", version = "~> 2.17" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.35" }
  }
  backend "azurerm" { # separate state key!
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateaksdemo01"
    container_name       = "tfstate"
    key                  = "aks/apps.tfstate"
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

data "azurerm_kubernetes_cluster" "this" {
  name                = "kv-aks-dev-aks"
  resource_group_name = "rg-kv-aks-dev"
}

provider "kubernetes" {
  host                   = data.azurerm_kubernetes_cluster.this.kube_admin_config[0].host
  client_certificate     = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].client_certificate)
  client_key             = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].client_key)
  cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].cluster_ca_certificate)
}

provider "helm" {
  kubernetes {
    host                   = data.azurerm_kubernetes_cluster.this.kube_admin_config[0].host
    client_certificate     = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].client_certificate)
    client_key             = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].client_key)
    cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.this.kube_admin_config[0].cluster_ca_certificate)
  }
}
# aks-apps/main.tf — a Helm release + a raw manifest, as a taste
resource "helm_release" "podinfo" {
  name             = "podinfo"
  repository       = "https://stefanprodan.github.io/podinfo"
  chart            = "podinfo"
  version          = "6.7.1"
  namespace        = "demo"
  create_namespace = true

  set {
    name  = "replicaCount"
    value = "2"
  }
}

# kubernetes_manifest needs a LIVE cluster at plan time — only works because
# the cluster already exists (built by the other config). Never colocate this
# with the cluster resource.
resource "kubernetes_manifest" "demo_quota" {
  manifest = {
    apiVersion = "v1"
    kind       = "ResourceQuota"
    metadata   = { name = "demo-quota", namespace = "demo" }
    spec       = { hard = { "requests.cpu" = "2", "requests.memory" = "2Gi" } }
  }
  depends_on = [helm_release.podinfo]
}
cd ../aks-apps && terraform init && terraform apply
# helm_release.podinfo: Creation complete after 25s
kubectl get pods -n demo

⚠️ helm provider version note: the nested kubernetes { ... } block shown is the helm 2.x syntax. The helm 3.x provider (2025) flattens this configuration — if you pin ~> 3.0, move the connection settings to the provider’s top level. Both run identically on OpenTofu.

Step 8 — destroy & clean up

⚠️ Tear down apps first (they depend on the cluster), then the cluster:

cd aks-apps  && terraform destroy -auto-approve
cd ../aks-cluster && terraform destroy -auto-approve
kubectl config delete-context "$(terraform output -raw cluster_name)-admin" 2>/dev/null || true
Destroy complete! Resources: 9 destroyed.

This is exactly why the two configs matter: destroying aks-apps first cleanly removes the workloads while the cluster is still up, then the cluster config tears down everything else. Colocated, Terraform would have wrestled the ordering.

Variables, outputs & making it reusable

The demo already parameterises the important knobs, but a real platform team wraps this in a module and drives the number and shape of user pools from data, not copy-paste. The pattern is for_each over a map of pool definitions — the same technique the Meta-Arguments lesson teaches, applied to node pools:

variable "user_node_pools" {
  description = "Map of user node pools to create"
  type = map(object({
    vm_size   = string
    min_count = number
    max_count = number
    spot      = optional(bool, false)
    labels    = optional(map(string), {})
    taints    = optional(list(string), [])
  }))
  default = {
    general = { vm_size = "Standard_D2s_v5", min_count = 1, max_count = 4 }
    batch   = { vm_size = "Standard_D4s_v5", min_count = 0, max_count = 6, spot = true }
  }
}

resource "azurerm_kubernetes_cluster_node_pool" "pools" {
  for_each = var.user_node_pools

  name                  = each.key
  kubernetes_cluster_id = azurerm_kubernetes_cluster.this.id
  vm_size               = each.value.vm_size
  mode                  = "User"
  auto_scaling_enabled  = true
  min_count             = each.value.min_count
  max_count             = each.value.max_count
  zones                 = var.zones
  vnet_subnet_id        = azurerm_subnet.aks.id

  priority        = each.value.spot ? "Spot" : "Regular"
  eviction_policy = each.value.spot ? "Delete" : null
  spot_max_price  = each.value.spot ? -1 : null

  node_labels = each.value.labels
  node_taints = each.value.taints

  lifecycle {
    ignore_changes = [node_count]
  }
}

Now adding a GPU pool or a Windows pool is a two-line map entry, not a new resource block. Wrap the cluster, network, and this for_each into a module with clear inputs, and you have a reusable AKS building block — the discipline from Authoring Terraform Modules.

Should you roll your own or use the registry module? The community Azure/aks/azurerm module is comprehensive and battle-tested, and for many teams it is the right answer. The trade-off:

Consideration Roll your own Azure/aks/azurerm (registry)
Control / transparency Total — you own every line Abstracted behind inputs
Surface area Only what you need Large; many optional features
Upgrades You track provider changes Module version bumps (with churn)
Learning value High Low — it hides the mechanics
Best for Opinionated platforms, learning Fast standardisation, big fleets

The honest recommendation: build it yourself once (this lesson) so you understand every argument, then decide whether the registry module’s convenience outweighs the abstraction for your team. You cannot sensibly consume the module until you know what it is hiding. See Module Sources & Composition for pinning and consuming registry modules safely.

Common mistakes and troubleshooting

The failures below are the ones that actually page people. Scan the table, then read the prose on the five nastiest.

Symptom Likely cause Fix
kubectlerror: You must be logged in to the server (Unauthorized) Entra RBAC on but no Azure role granted to you Assign AKS RBAC Cluster Admin at cluster scope; re-run get-credentials
az aks get-credentials --adminForbidden local_account_disabled = true Use the non-admin path with kubelogin; you cannot get admin certs
Pods stuck ContainerCreating, FailedToAllocateAddress Azure CNI subnet out of IPs Subnet too small — resize/rebuild with a bigger prefix (immutable)
Plan shows node_count change every run Autoscaler moved the count Add lifecycle { ignore_changes = [node_count] }
Error: subscription_id is a required provider property azurerm 4.x Set subscription_id or export ARM_SUBSCRIPTION_ID
Changing vm_size forces full pool replace Node pool fields are immutable Set temporary_name_for_rotation to cycle in place
kubernetes_manifestconnection refused at plan Cluster/app in one apply, no live API Split into two configs; the cluster must pre-exist
ACR pull 401 Unauthorized / ImagePullBackOff AcrPull not granted to kubelet MI Add the role assignment on kubelet_identity[0].object_id
network_profile change wants to recreate cluster CNI/CIDRs are immutable Plan networking once; rebuild to change it
PrincipalNotFound on the role assignment Entra replication lag on a new MI skip_service_principal_aad_check = true
Cluster create fails: dns_service_ip not in service_cidr Misconfigured CIDRs dns_service_ip must sit inside service_cidr
Cannot delete the last system node pool Tried to remove/convert the only system pool Keep ≥1 System-mode pool at all times

Auth to the cluster is the number-one first-day failure, and it is almost always the self-lockout: you enabled azure_rbac_enabled and/or local_account_disabled but never granted yourself an Azure RBAC role, so Entra authenticates you and then Azure RBAC denies you. The fix is the me_cluster_admin role assignment in the demo. If you are already locked out and local accounts are still enabled, --admin is your escape hatch; if they are disabled, you need someone with Owner/User Access Administrator on the cluster to grant you the role.

CNI IP exhaustion is insidious because it looks like a capacity problem when it is an addressing problem. Nodes are Ready, CPU is fine, yet pods will not start — kubectl describe pod shows FailedToAllocateAddress. You ran out of subnet IPs, and because the subnet prefix is effectively immutable for a live cluster, the real fix is to have sized it correctly up front, or to migrate to CNI Overlay so pods stop consuming subnet IPs. Always compute (max nodes + surge) × (1 + max_pods) before you apply.

Node-pool upgrades trip people when they change an immutable field. Editing vm_size, os_disk_size_gb, zones, or the subnet on an existing pool forces a replace, and by default Terraform destroys the pool before creating the new one — a hard outage for that capacity. Setting temporary_name_for_rotation on the default node pool lets AKS spin up a temporary pool, drain onto it, and cycle the original in place. For version upgrades specifically, bump orchestrator_version (and kubernetes_version for the control plane) — that is an in-place, surge-based rolling upgrade, not a replacement.

Sensitive kubeconfig is a footgun that is easy to fire. kube_config_raw and every kube_*_config attribute are cluster credentials. They are stored in plaintext in state, so: keep state remote and encrypted, never output them without sensitive = true, never echo terraform output kube_config_raw into a log or a CI artifact, and prefer az aks get-credentials (which fetches a fresh, scoped credential) over passing the Terraform output around. If a kubeconfig leaks, rotate the cluster certificates (az aks rotate-certs).

Provider ordering — the split-apply rule — deserves repeating because the failure is confusing. If you colocate the cluster and a kubernetes/helm resource, on a clean apply Terraform must configure the kubernetes provider from cluster attributes that do not exist yet, and either errors or (worse) succeeds fragilely and then breaks on the next change or destroy. kubernetes_manifest is the strictest: it contacts the API server at plan time, so it simply cannot exist in the same run that creates the cluster. Two states, always.

Cost, cleanup & production notes

AKS’s own control plane is free in the Free SKU tier — you pay for the nodes and the surrounding resources, not the managed masters. Rough Central India pay-as-you-go figures for this demo, to make the “destroy it” case concrete:

Component Rate (approx) Left running ~24h
AKS control plane (Free tier) ₹0 ₹0
2 × Standard_D2s_v5 nodes ~₹8/hr each ~₹380
Standard Load Balancer + public IP ~₹2/hr ~₹48
ACR (Standard) ~₹55/day ~₹55
Log Analytics (Container Insights) per-GB ingest ~₹40–150
Managed OS disks (2 × 64 GB) small ~₹20
Rough total ~₹550–650/day

Switching sku_tier to Standard adds the Uptime SLA at roughly ₹8/hr (~₹6,000/month) — worth it for production, wasteful for a lab. The single biggest lever is simply not leaving it running: terraform destroy both configs when you finish. For the cheapest possible lab, drop ACR to Basic, use one Standard_B2ms node, and set Log Analytics retention to the 30-day minimum (as here).

Five production-hardening notes beyond the demo:

  1. State is the crown jewels. The kubeconfig lives in it. Use the remote azurerm backend with RBAC on the storage account, soft-delete/versioning on the container, and network restrictions. Never local state for a cluster.
  2. Disable local accounts and lean on Entra + Azure RBAC. Flip local_account_disabled = true, drive access through Entra groups and Azure role assignments, and require MFA/conditional access. The certificate bypass is a standing risk.
  3. Right-size and future-proof the CNI subnet, or adopt CNI Overlay. IP exhaustion is a rebuild-to-fix problem; a /22/21 with Overlay under consideration is cheap insurance.
  4. Turn on managed upgrades and maintenance windows. automatic_upgrade_channel = "patch" plus a maintenance_window keeps you patched without surprise reboots, and Standard tier gives you the SLA to back it.
  5. Tag everything and watch drift. Consistent tags, plus scheduled terraform plan in CI (drift detection), catch out-of-band portal changes before they compound — the discipline from the drift-detection lesson.

The AWS equivalent of this whole build is EKS — a cluster plus VPC plus managed node groups, with IRSA playing the role Azure Workload Identity plays here; that lives in the AWS track of this course (an eks cluster provisioned with aws_eks_cluster + aws_eks_node_group, granting pods AWS permissions via OIDC-federated IAM roles instead of Entra federated credentials). The shape rhymes: a control plane, node pools/groups, an OIDC issuer, and keyless pod identity.

Cheat-sheet

Task HCL / command
Cluster resource resource "azurerm_kubernetes_cluster" "this" { ... }
Inline system pool default_node_pool { name vm_size node_count zones vnet_subnet_id }
Extra pool resource "azurerm_kubernetes_cluster_node_pool" "user" { kubernetes_cluster_id = ... }
Autoscale auto_scaling_enabled = true + min_count/max_count + ignore_changes=[node_count]
Spot pool priority="Spot" eviction_policy="Delete" spot_max_price=-1
System-only taint only_critical_addons_enabled = true
Control-plane identity identity { type = "SystemAssigned" }
Kubelet identity ref azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id
Azure CNI network_profile { network_plugin="azure" service_cidr dns_service_ip }
Entra RBAC azure_active_directory_role_based_access_control { azure_rbac_enabled=true }
Disable local admin local_account_disabled = true
Monitor add-on oms_agent { log_analytics_workspace_id msi_auth_for_monitoring_enabled=true }
Key Vault CSI key_vault_secrets_provider { secret_rotation_enabled=true }
Workload identity oidc_issuer_enabled=true + workload_identity_enabled=true
Attach ACR azurerm_role_assignment AcrPull on registry → kubelet MI
Get admin kubeconfig az aks get-credentials -g RG -n NAME --admin
Get Entra kubeconfig az aks get-credentials -g RG -n NAME (uses kubelogin)
List K8s versions az aks get-versions -l <region> -o table
Sensitive output output "kc" { value = ...kube_config_raw sensitive = true }
Chain provider provider "kubernetes" { host = ...kube_admin_config[0].host ... }

Interview and exam questions

1. Why must you set lifecycle { ignore_changes = [node_count] } on an autoscaling node pool? Because the cluster autoscaler changes node_count at runtime; without the ignore, every plan sees drift and every apply resets the pool to its initial count, fighting the autoscaler.

2. What is the difference between the control-plane identity and the kubelet identity? The control-plane (managed) identity authenticates the cluster to Azure Resource Manager (create LBs, disks, update routes); the kubelet identity authenticates node agents to pull images from registries. You grant AcrPull to the kubelet identity, not the control plane.

3. Under Azure CNI, how do you size the AKS subnet? (max nodes after upgrade surge) × (1 + max_pods), because every pod takes a subnet IP. A /22 at 30 pods/node caps roughly 33 nodes. Undersizing causes FailedToAllocateAddress.

4. What changed for azure_active_directory_role_based_access_control in azurerm 4.x? The legacy (non-managed) fields — managed, client_app_id, server_app_id, server_app_secret — were removed. Managed Entra is the only mode; the block reduces to tenant_id, admin_group_object_ids, azure_rbac_enabled.

5. Why should you not build the cluster and its Helm/kubernetes resources in one apply? The kubernetes/helm providers are configured from cluster outputs unknown until apply; kubernetes_manifest needs a live API at plan time; and destroy ordering inverts. Split into two configurations/states — cluster, then apps.

6. What does local_account_disabled = true do, and what is the risk? It removes the built-in certificate admin bypass, forcing all access through Entra. The risk is self-lockout: if you have not granted yourself an Azure RBAC role on the cluster, no one can get in — there is no certificate fallback.

7. When would you choose kubenet over Azure CNI? When the VNet is IP-constrained and pods do not need to be directly routable on the VNet — kubenet keeps pods on an internal pod_cidr behind the node, consuming far fewer subnet IPs. CNI Overlay is the modern middle ground.

8. How does a pod authenticate to Azure Key Vault with no stored secret? Workload Identity: oidc_issuer_enabled + workload_identity_enabled on the cluster, a user-assigned identity, and a federated credential binding system:serviceaccount:<ns>:<sa> to that identity. The pod’s projected token is exchanged for an Entra token — no secret anywhere.

9. (Terraform Associate) Why are kube_config_raw outputs marked sensitive = true, and what does that actually protect? It redacts the value from CLI/plan output; it does not encrypt it in state. The value is still plaintext in the state file, which is why remote encrypted state with access control is mandatory.

10. (Terraform Associate) You changed vm_size on a node pool and plan shows the pool will be destroyed and recreated. How do you avoid an outage? vm_size is immutable, forcing replacement; set temporary_name_for_rotation so AKS provisions a temporary pool, drains onto it, and cycles the original in place instead of destroy-before-create.

11. What is skip_service_principal_aad_check = true on the AcrPull role assignment for? The kubelet identity is newly created and Entra replication can lag; the flag skips the existence check so the assignment does not fail with PrincipalNotFound.

12. How do you deploy onto a cluster whose local account is disabled, from the Terraform kubernetes provider? kube_admin_config is empty, so configure the provider with the API host + CA and an exec block that calls kubelogin get-token --login azurecli, obtaining an Entra token at runtime.

Key takeaways

TerraformazurermAKSKubernetesNode PoolsManaged IdentityEntra IDAzure CNIRBACWorkload IdentityACRIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments