Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Kubernetes Cluster Terraform Module

Manage a hardened Azure Kubernetes Service (AKS) cluster and its additional node pools as one unit β€” Entra-integrated, RBAC-authorized, managed-identity, and network-policy-enforced by default β€” on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • ☸️ Creates one azurerm_kubernetes_cluster with its required inline default_node_pool, hardened by default: local accounts disabled (Entra-only), Azure RBAC for Kubernetes, a system-assigned managed identity, Azure network policy enforced, the OIDC issuer and workload identity on, the Azure Policy add-on on, and Image Cleaner on.
  • 🧩 Manages azurerm_kubernetes_cluster_node_pool as a keyed for_each map of additional (user) pools that share the cluster's lifecycle.
  • πŸ”Œ Exposes the full surface as deeply-typed objects: network_profile, api_server_access_profile, auto_scaler_profile, maintenance_window*, oms_agent, microsoft_defender, key_management_service, key_vault_secrets_provider, service_mesh_profile, web_app_routing, and more.
  • πŸ”’ Never uses a service principal with a stored secret; identity is always a managed identity, and the cluster's kube_config is never emitted as an output.

πŸ’‘ Why it matters: the cluster is the control plane for everything running on it. An empty call that is Entra-only, RBAC-authorized, and network-policy-enforced means the safe posture is the default β€” every relaxation (public node IPs, run-command, an open API server) is a deliberate, reviewable opt-in.

❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!

πŸ—ΊοΈ Where this fits in the family

flowchart TD
  RG["terraform-azurerm-resource-group"]
  VNET["terraform-azurerm-virtual-network (subnet)"]
  AKS["terraform-azurerm-kubernetes-cluster"]
  CLUSTER["azurerm_kubernetes_cluster"]
  ACR["terraform-azurerm-container-registry"]
  LAW["terraform-azurerm-log-analytics-workspace"]
  RA["terraform-azurerm-role-assignments"]
  DIAG["terraform-azurerm-monitor-diagnostic-setting"]

  RG -->|"resource_group_name + location"| AKS
  VNET -->|"default_node_pool.vnet_subnet_id"| AKS
  AKS --> CLUSTER
  AKS -->|"kubelet_identity_object_id"| RA
  ACR -->|"scope for AcrPull"| RA
  LAW -->|"oms_agent.log_analytics_workspace_id"| AKS
  AKS -->|"id"| DIAG

  classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#fff,stroke:#004578;
  class AKS this;
  class CLUSTER key;
Loading

🧬 What this module builds

flowchart LR
  I1["name / resource_group_name / location"]
  I2["identity SystemAssigned<br/>AAD RBAC / local accounts disabled"]
  I3["default_node_pool (inline)"]
  I4["network_profile azure + policy"]
  I5["node_pools (map)"]

  AKS["azurerm_kubernetes_cluster.this"]
  NP["azurerm_kubernetes_cluster_node_pool.this (for_each)"]

  O1["id / name / fqdn"]
  O2["oidc_issuer_url / kubelet_identity_object_id"]
  O3["node_pool_ids"]

  I1 --> AKS
  I2 --> AKS
  I3 --> AKS
  I4 --> AKS
  I5 --> NP
  AKS -->|"kubernetes_cluster_id"| NP
  AKS --> O1
  AKS --> O2
  NP --> O3

  classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#fff,stroke:#004578;
  class AKS key;
  class NP this;
Loading

Resource inventory

Resource Cardinality Role
azurerm_kubernetes_cluster.this 1 (keystone) The hardened cluster, including the inline default_node_pool.
azurerm_kubernetes_cluster_node_pool.this 0..N (for_each) Additional (user) node pools attached to the cluster.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite

  • name, resource_group_name, and location are effectively immutable β€” changing any forces cluster replacement.
  • network_profile.network_plugin, network_policy, service_cidr, dns_service_ip, and pod_cidr are fixed at creation; changing them replaces the cluster.
  • default_node_pool fields such as vm_size, os_sku, zones, vnet_subnet_id, pod_subnet_id, fips_enabled, and host_encryption_enabled are force-new β€” set temporary_name_for_rotation to roll them without downtime.
  • πŸ”΄ NINE arguments are force-new only CONDITIONALLY, and SEVEN of them fire when the field is CLEARED β€” so removing a line from a working configuration destroys and recreates the cluster. They are default_node_pool.upgrade_settings.drain_timeout_in_minutes, ...undrainable_node_behavior, the whole windows_profile.gmsa block, windows_profile.gmsa.dns_server, ...root_domain, api_server_access_profile.subnet_id, and disabling oidc_issuer_enabled. Two more are not that shape: service_principal.client_id forces replacement when the OLD value was a managed identity, and network_profile.network_data_plane permits azure β†’ cilium in place and rebuilds on every other transition. None of the nine appears in the binary schema or in any force-new list β€” they are CustomizeDiff predicates, and the plan reports each as an ordinary replacement.
  • ⚠️ Changing network_plugin from kubenet to azure is in-place ONLY when network_plugin_mode is overlay. Setting just the plugin β€” the obvious half β€” rebuilds the cluster.
  • private_cluster_enabled, dns_prefix, dns_prefix_private_cluster, private_dns_zone_id, and node_resource_group are set at creation and cannot be changed in place.
  • default_node_pool is a required block (min 1); a system pool always exists. Additional pools are separate resources so they can be added or removed independently.
  • With auto_scaling_enabled = true the provider manages node_count; the module renders it as null in that mode so the autoscaler owns the count and you avoid a perpetual diff.
  • πŸ”΄ local_account_disabled defaults to true here, which removes the local cluster-admin kubeconfig β€” so Entra becomes the ONLY way in. Entra admits an administrator two ways: azure_rbac_enabled = true, or a group listed in admin_group_object_ids. With neither, nobody can administer the cluster, and the credential you would use to repair that is the one that was disabled. This module refuses that specific combination at plan time. It does NOT refuse azure_rbac_enabled = false on its own, which is a legitimate configuration when an admin group is supplied.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Azure Kubernetes Service Contributor (or a custom role with Microsoft.ContainerService/managedClusters/*) on the target resource group to create/update the cluster and node pools.
  • Network Contributor on the node/pod subnet when using Azure CNI, so the cluster identity can join the VNet.
  • Managed Identity Operator on any user-assigned identity passed to identity.identity_ids or kubelet_identity.
  • User Access Administrator (or Owner) at the container-registry scope to grant the kubelet identity AcrPull.

Azure Prerequisites

  • An existing resource group in a supported US Azure region (availability zones require an AZ-enabled region such as eastus2, westus2, or centralus).
  • The Microsoft.ContainerService resource provider registered on the subscription; the EncryptionAtHost feature registered if any pool sets host_encryption_enabled = true.
  • Any referenced subnets, Log Analytics workspace, container registry, Key Vault key, and user-assigned identities already exist (their ids are inputs).
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

πŸ“ Module Structure

terraform-azurerm-kubernetes-cluster/
β”œβ”€β”€ providers.tf     # required_version + azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf     # name/rg/location, security posture, identity, default_node_pool, network_profile, add-ons, node_pools
β”œβ”€β”€ main.tf          # azurerm_kubernetes_cluster.this (inline default_node_pool) + for_each node pools
β”œβ”€β”€ outputs.tf       # id, name, fqdn, oidc_issuer_url, kubelet_identity_object_id, node_pool_ids
β”œβ”€β”€ README.md        # this document
β”œβ”€β”€ SCOPE.md         # cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore       # canonical Terraform ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "aks" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"
  name                = "aks-platform-prod-eus2"
  resource_group_name = "rg-platform-prod-eastus2"
  location            = "eastus2"
  dns_prefix          = "aks-platform-prod"
}

ℹ️ The empty call is Entra-only (local accounts disabled), Azure-RBAC-authorized, system-assigned identity, Azure network policy enforced, OIDC issuer + workload identity on, Azure Policy add-on on. Pin the module by tag (?ref=v1.0.0), never a branch.

πŸ”Œ Cross-Module Contract

Consumes

Input Type From
resource_group_name string terraform-azurerm-resource-group (name)
location string caller / resource group (location)
default_node_pool.vnet_subnet_id string terraform-azurerm-virtual-network subnet (id)
oms_agent.log_analytics_workspace_id string terraform-azurerm-log-analytics-workspace (id)
key_management_service.key_vault_key_id string terraform-azurerm-key-vault key (id)
identity.identity_ids list(string) terraform-azurerm-user-assigned-identity (id)

Emits

Output Description Consumed by
id Cluster Resource ID role assignments, diagnostics, downstream
name Cluster name reference
location / resource_group_name Cluster region and resource group backup instances and any sibling needing the CLUSTER's own placement
fqdn / private_fqdn API-server FQDNs kubeconfig / private connectivity
node_resource_group / node_resource_group_id Node resource group scoping node-resource operations
oidc_issuer_url OIDC issuer URL federated workload identity
kubelet_identity_object_id Kubelet identity object ID AcrPull grant on a registry
node_pool_ids map key β†’ node-pool ID reference

πŸ“š Example Library

1 Β· Minimal (hardened)
module "aks" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"
  name                = "aks-min-eus2"
  resource_group_name = "rg-platform-eastus2"
  location            = "eastus2"
  dns_prefix          = "aks-min"
}

πŸ”’ Local accounts disabled, Azure RBAC for Kubernetes, system-assigned identity, Azure network policy, OIDC + workload identity, Azure Policy add-on β€” all on by default.

2 Β· Autoscaling default pool
module "aks" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"
  name                = "aks-auto-eus2"
  resource_group_name = "rg-platform-eastus2"
  location            = "eastus2"
  dns_prefix          = "aks-auto"

  default_node_pool = {
    name                 = "system"
    vm_size              = "Standard_D4s_v5"
    auto_scaling_enabled = true
    min_count            = 2
    max_count            = 5
    zones                = ["1", "2", "3"]
  }
}

πŸ’‘ With autoscaling on, the module lets the provider own node_count (no perpetual diff).

3 Β· Azure CNI on a delegated subnet
module "aks" {
  # ...
  default_node_pool = {
    name           = "system"
    vm_size        = "Standard_D4s_v5"
    node_count     = 3
    vnet_subnet_id = var.node_subnet_id
  }

  network_profile = {
    network_plugin = "azure"
    network_policy = "azure"
    service_cidr   = "10.240.0.0/16"
    dns_service_ip = "10.240.0.10"
  }
}

⚠️ network_plugin, network_policy, service_cidr, and dns_service_ip are force-new β€” set them correctly at creation.

4 Β· Azure CNI Overlay + Cilium data plane
network_profile = {
  network_plugin      = "azure"
  network_plugin_mode = "overlay"
  network_policy      = "cilium"
  network_data_plane  = "cilium"
  pod_cidr            = "10.244.0.0/16"
}

πŸ’‘ Overlay decouples pod IPs from the VNet address space; Cilium provides the network policy engine.

5 Β· Private cluster
module "aks" {
  # ...
  dns_prefix_private_cluster = "aks-priv"
  private_cluster_enabled    = true
  private_dns_zone_id        = "System"
}

πŸ”’ The API server gets a private IP only; you need private network line-of-sight (VPN, jumpbox, or self-hosted runner) to run kubectl.

6 Β· Restrict a public API server by IP
module "aks" {
  # ...
  api_server_access_profile = {
    authorized_ip_ranges = ["203.0.113.0/24", "198.51.100.10/32"]
  }
}

πŸ”’ When you keep a public API server, restrict who can reach it. Empty means anywhere β€” set the ranges.

7 Β· Additional user node pools (for_each map)
module "aks" {
  # ...
  node_pools = {
    apps = {
      vm_size     = "Standard_D8s_v5"
      node_count  = 3
      mode        = "User"
      node_labels = { workload = "apps" }
    }
    spot = {
      vm_size              = "Standard_D4s_v5"
      priority             = "Spot"
      eviction_policy      = "Delete"
      spot_max_price       = -1
      auto_scaling_enabled = true
      min_count            = 0
      max_count            = 10
      node_taints          = ["kubernetes.azure.com/scalesetpriority=spot:NoSchedule"]
    }
  }
}

πŸ’‘ Keyed by a stable identifier so removing one pool never re-indexes the others.

8 Β· Entra admin groups (cluster-admin via groups)
azure_active_directory_role_based_access_control = {
  azure_rbac_enabled     = true
  admin_group_object_ids = [var.platform_admins_group_id]
}

πŸ”’ Cluster-admin is granted to an Entra group, not to individuals β€” auditable and least-privilege.

9 Β· Container Insights (oms_agent)
module "aks" {
  # ...
  oms_agent = {
    log_analytics_workspace_id      = var.law_id
    msi_auth_for_monitoring_enabled = true
  }
}
10 Β· Microsoft Defender for Containers
module "aks" {
  # ...
  microsoft_defender = {
    log_analytics_workspace_id = var.law_id
  }
}

πŸ”’ Runtime threat detection for the cluster, streamed to a workspace.

11 Β· KMS etcd encryption with a Key Vault key
module "aks" {
  # ...
  key_management_service = {
    key_vault_key_id         = var.kms_key_id
    key_vault_network_access = "Public"
  }
}

πŸ”’ Encrypts Kubernetes secrets in etcd with a customer-managed key.

12 Β· Maintenance windows (auto-upgrade + node OS)
module "aks" {
  # ...
  automatic_upgrade_channel = "stable"

  maintenance_window_auto_upgrade = {
    frequency   = "Weekly"
    interval    = 1
    duration    = 4
    day_of_week = "Sunday"
    start_time  = "02:00"
    utc_offset  = "+00:00"
  }

  maintenance_window_node_os = {
    frequency   = "Weekly"
    interval    = 1
    duration    = 4
    day_of_week = "Saturday"
    start_time  = "02:00"
    utc_offset  = "+00:00"
  }
}
13 Β· User-assigned identities (control plane + kubelet)
module "aks" {
  # ...
  identity = {
    type         = "UserAssigned"
    identity_ids = [var.control_plane_identity_id]
  }

  kubelet_identity = {
    user_assigned_identity_id = var.kubelet_identity_id
    client_id                 = var.kubelet_client_id
    object_id                 = var.kubelet_object_id
  }
}

ℹ️ The control-plane identity needs Managed Identity Operator on the kubelet identity.

14 Β· Opt out of hardening (documented relaxations)
module "aks" {
  # ...
  local_account_disabled = false   # re-enable static admin kubeconfig
  run_command_enabled    = true    # allow az aks command invoke

  default_node_pool = {
    name                   = "system"
    vm_size                = "Standard_D4s_v5"
    node_public_ip_enabled = true  # public IPs on nodes
  }
}

⚠️ Each line here weakens the default posture β€” use only with justification and review.

15 Β· πŸ—οΈ End-to-end composition

A resource group, a VNet subnet, a Log Analytics workspace, a private ACR, a hardened AKS cluster with a user pool, and the kubelet identity granted AcrPull on the registry.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-platform-prod-eastus2"
  location = "eastus2"
}

module "vnet" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
  name                = "vnet-platform-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.0.0.0/16"]
  subnets = {
    nodes = { name = "snet-aks-nodes", address_prefixes = ["10.0.1.0/24"] }
  }
}

module "law" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
  name                = "law-platform-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "acr" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-container-registry.git?ref=v1.0.0"
  name                = "acrplatformprodeus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "aks" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"
  name                = "aks-platform-prod-eus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
  dns_prefix          = "aks-platform-prod"

  default_node_pool = {
    name                 = "system"
    vm_size              = "Standard_D4s_v5"
    auto_scaling_enabled = true
    min_count            = 2
    max_count            = 5
    zones                = ["1", "2", "3"]
    vnet_subnet_id       = module.vnet.subnet_ids["nodes"]
  }

  network_profile = {
    network_plugin = "azure"
    network_policy = "azure"
  }

  oms_agent = {
    log_analytics_workspace_id = module.law.id
  }

  node_pools = {
    apps = {
      vm_size        = "Standard_D8s_v5"
      node_count     = 3
      vnet_subnet_id = module.vnet.subnet_ids["nodes"]
    }
  }
}

module "acr_pull" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope  = module.acr.id
  role_assignments = {
    kubelet-acrpull = {
      role_definition_name = "AcrPull"
      principal_id         = module.aks.kubelet_identity_object_id
    }
  }
}

πŸ’‘ The kubelet identity's object ID drives the AcrPull grant β€” nodes pull images with a managed identity, no registry credentials stored anywhere.

πŸ“₯ Inputs

Identity & required

Name Type Required Default Description
name string βœ… β€” Cluster name (1–63). Immutable.
resource_group_name string βœ… β€” Containing resource group. Immutable.
location string βœ… β€” Azure region. Immutable.
dns_prefix / dns_prefix_private_cluster string β€” null Provide one. Immutable.

Core & security posture

Name Type Default Description
kubernetes_version string null Control-plane version (AKS default when null).
sku_tier string "Standard" Free / Standard / Premium.
local_account_disabled bool true Entra-only access.
role_based_access_control_enabled bool true Kubernetes RBAC.
azure_policy_enabled bool true Azure Policy add-on.
oidc_issuer_enabled / workload_identity_enabled bool true Workload identity federation.
image_cleaner_enabled bool true Remove stale images.
run_command_enabled bool false az aks command invoke.
private_cluster_enabled bool false Private API server.

Nested blocks & children

Name Type Default Description
identity object { type = "SystemAssigned" } Managed identity (never a service principal secret).
azure_active_directory_role_based_access_control object { azure_rbac_enabled = true } Entra integration.
default_node_pool object {} (hardened defaults) Required system pool (inline).
network_profile object { network_plugin = "azure", network_policy = "azure" } Cluster networking.
api_server_access_profile / auto_scaler_profile / maintenance_window* object null Optional tuning.
oms_agent / microsoft_defender / monitor_metrics object null Observability add-ons.
key_management_service / key_vault_secrets_provider object null Secrets encryption / CSI driver.
node_pools map(object) {} Additional user node pools.
tags map(string) {} Tags.
timeouts object null Optional timeouts.
Full variable schemas (default_node_pool & node_pools)
default_node_pool = object({
  name                         = optional(string, "system")
  vm_size                      = optional(string, "Standard_D4s_v5")   # force-new
  node_count                   = optional(number, 2)                   # ignored when autoscaling
  auto_scaling_enabled         = optional(bool, false)
  min_count / max_count        = optional(number)                      # required when autoscaling
  max_pods                     = optional(number)
  os_disk_size_gb              = optional(number)
  os_disk_type                 = optional(string, "Managed")           # Managed | Ephemeral (validated)
  os_sku                       = optional(string)                      # force-new
  type                         = optional(string, "VirtualMachineScaleSets")  # validated
  zones                        = optional(list(string), [])            # force-new
  vnet_subnet_id / pod_subnet_id = optional(string)                    # force-new
  node_labels                  = optional(map(string), {})
  only_critical_addons_enabled = optional(bool, false)
  host_encryption_enabled      = optional(bool, false)                 # force-new; needs EncryptionAtHost
  fips_enabled                 = optional(bool, false)                 # force-new
  node_public_ip_enabled       = optional(bool, false)
  temporary_name_for_rotation  = optional(string)                      # roll force-new changes
  scale_down_mode              = optional(string, "Delete")
  upgrade_settings             = optional(object({ max_surge = string, drain_timeout_in_minutes = optional(number), node_soak_duration_in_minutes = optional(number) }))
  # ... orchestrator_version, ultra_ssd_enabled, kubelet_disk_type, *_group_id, gpu_instance, snapshot_id
})

node_pools = map(object({
  name                    = optional(string)        # defaults to map key
  vm_size                 = string                  # force-new
  node_count              = optional(number, 1)
  auto_scaling_enabled    = optional(bool, false)
  min_count / max_count   = optional(number)        # required when autoscaling
  mode                    = optional(string, "User")     # User | System (validated)
  os_type                 = optional(string, "Linux")    # Linux | Windows (validated); force-new
  priority                = optional(string, "Regular")  # Regular | Spot (validated)
  eviction_policy         = optional(string)
  spot_max_price          = optional(number)
  zones / vnet_subnet_id / pod_subnet_id = force-new
  node_labels             = optional(map(string), {})
  node_taints             = optional(list(string), [])
  node_public_ip_enabled  = optional(bool, false)
  host_encryption_enabled / fips_enabled = optional(bool, false)  # force-new
  upgrade_settings        = optional(object({ max_surge = string, ... }))
  tags                    = optional(map(string))
}))

🧾 Outputs

Output Description Notes
id Cluster Resource ID Emitted first.
name Cluster name β€”
location Cluster region Normalized by the provider. Pass this to a module that needs the cluster's region rather than typing one.
resource_group_name Cluster resource group Not the node resource group β€” see node_resource_group.
fqdn / private_fqdn API-server FQDNs private_fqdn set for private clusters.
node_resource_group / node_resource_group_id Node resource group β€”
oidc_issuer_url OIDC issuer URL Empty when oidc_issuer_enabled = false.
identity_principal_id Control-plane identity principal ID Present for system-assigned.
kubelet_identity_object_id Kubelet identity object ID Grant AcrPull on a registry.
node_pool_ids map key β†’ node-pool ID Child map.

ℹ️ kube_config / kube_admin_config carry client certificates and keys and are not emitted as outputs β€” retrieve them out of band.

🧠 Architecture Notes

  • Hardened empty call. Local accounts disabled, Azure RBAC for Kubernetes, a system-assigned managed identity, Azure network policy, the OIDC issuer + workload identity, the Azure Policy add-on, and Image Cleaner are all defaults; each is a documented opt-out.
  • Managed identity, never a service-principal secret. The module deliberately omits the service_principal block; identity defaults to SystemAssigned and can be moved to user-assigned by supplying identity_ids.
  • The default pool is inline and required. default_node_pool is part of the keystone (a system pool always exists). Additional pools are separate azurerm_kubernetes_cluster_node_pool resources keyed by a stable map key, so adding or removing one never re-indexes the rest.
  • Force-new fields bite. vm_size, os_sku, zones, vnet_subnet_id, and encryption/FIPS toggles recreate a pool; network_plugin/network_policy/CIDRs recreate the cluster. Use temporary_name_for_rotation on the default pool to roll a force-new change without an outage.
  • Autoscaling and node_count. When auto_scaling_enabled = true, the module renders node_count = null so the provider (and the cluster autoscaler) own the count β€” this avoids the classic perpetual diff.
  • Cross-field safety. With local_account_disabled = true, the module checks that at least one Entra path in actually exists β€” Azure RBAC enabled, or a non-empty admin_group_object_ids β€” and refuses the combination that leaves no administrator at all. (An earlier version of this check tested only whether the Entra block was present, which could never fail: the variable carries a non-null default, so presence was never in question.)
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out
Local (static) admin accounts local_account_disabled = true set false
Kubernetes authorization Entra + azure_rbac_enabled = true set the AAD block to null (and re-enable local accounts)
Kubernetes RBAC role_based_access_control_enabled = true set false
Cluster identity system-assigned managed identity user-assigned (identity_ids)
Network policy network_profile.network_policy = "azure" calico / cilium / omit
Azure Policy add-on azure_policy_enabled = true set false
Workload identity oidc_issuer_enabled + workload_identity_enabled = true set false
Image Cleaner image_cleaner_enabled = true set false
Run-command run_command_enabled = false set true
Node public IPs node_public_ip_enabled = false set true
API server exposure public (restrict via authorized_ip_ranges) or private_cluster_enabled = true β€”

πŸš€ Runbook

cd terraform-azurerm-kubernetes-cluster
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

πŸ§ͺ Testing

The offline proof gate β€” terraform init -backend=false, terraform validate, terraform fmt -check β€” proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the sku_tier, network_plugin, network_policy, node-pool priority/os_type/mode, and autoscaling validations) and canonically formatted, with no cloud calls. What it does not exercise: whether the deploying identity holds the required roles, VM SKU/quota availability in the region, and whether force-new fields trigger replacement β€” those surface only under terraform plan/apply against real credentials from CI.

πŸ’¬ Example Output

$ terraform output
id                         = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-platform-prod-eastus2/providers/Microsoft.ContainerService/managedClusters/aks-platform-prod-eus2"
name                       = "aks-platform-prod-eus2"
fqdn                       = "aks-platform-prod-xxxxxxxx.hcp.eastus2.azmk8s.io"
node_resource_group        = "MC_rg-platform-prod-eastus2_aks-platform-prod-eus2_eastus2"
oidc_issuer_url            = "https://eastus2.oic.prod-aks.azure.com/00000000-0000-0000-0000-000000000000/11111111-1111-1111-1111-111111111111/"
kubelet_identity_object_id = "22222222-2222-2222-2222-222222222222"
node_pool_ids = {
  "apps" = "/subscriptions/.../managedClusters/aks-platform-prod-eus2/agentPools/apps"
}

πŸ” Troubleshooting

Symptom Cause Fix
A plan-time refusal naming local_account_disabled and cluster administration Local accounts disabled, azure_rbac_enabled = false, and no admin_group_object_ids β€” so no administrator can reach the cluster Enable azure_rbac_enabled, or supply at least one admin_group_object_ids entry, or set local_account_disabled = false. Any one of the three is sufficient.
403 running kubectl Azure RBAC for Kubernetes on but no role assignment Grant Azure Kubernetes Service RBAC ... roles at the cluster scope.
Nodes cannot pull images Kubelet identity lacks AcrPull Assign AcrPull to kubelet_identity_object_id at the registry.
Cluster wants replacement on edit Touched a force-new field (network plugin, subnet, vm_size) Use temporary_name_for_rotation, or accept replacement in non-prod.
Perpetual node_count diff Autoscaling on but count also managed The module already nulls node_count under autoscaling β€” remove any external count management.
EncryptionAtHost error Feature not registered Register the EncryptionAtHost feature on the subscription.
Private cluster unreachable No private line-of-sight to API server Run from within the VNet (VPN, jumpbox, private runner).

πŸ”— Related Docs

  • Provider resources: azurerm_kubernetes_cluster, azurerm_kubernetes_cluster_node_pool
  • Sibling modules: terraform-azurerm-resource-group, terraform-azurerm-virtual-network, terraform-azurerm-container-registry, terraform-azurerm-log-analytics-workspace, terraform-azurerm-role-assignments, terraform-azurerm-user-assigned-identity, terraform-azurerm-key-vault, terraform-azurerm-monitor-diagnostic-setting
  • This module's cross-module contract: SCOPE.md

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."