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.
- ☸️ Creates one
azurerm_kubernetes_clusterwith its required inlinedefault_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_poolas a keyedfor_eachmap 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_configis 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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;
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;
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. |
| 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, andlocationare effectively immutable — changing any forces cluster replacement.network_profile.network_plugin,network_policy,service_cidr,dns_service_ip, andpod_cidrare fixed at creation; changing them replaces the cluster.default_node_poolfields such asvm_size,os_sku,zones,vnet_subnet_id,pod_subnet_id,fips_enabled, andhost_encryption_enabledare force-new — settemporary_name_for_rotationto 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 wholewindows_profile.gmsablock,windows_profile.gmsa.dns_server,...root_domain,api_server_access_profile.subnet_id, and disablingoidc_issuer_enabled. Two more are not that shape:service_principal.client_idforces replacement when the OLD value was a managed identity, andnetwork_profile.network_data_planepermitsazure→ciliumin place and rebuilds on every other transition. None of the nine appears in the binary schema or in any force-new list — they areCustomizeDiffpredicates, and the plan reports each as an ordinary replacement. ⚠️ Changingnetwork_pluginfromkubenettoazureis in-place ONLY whennetwork_plugin_modeisoverlay. Setting just the plugin — the obvious half — rebuilds the cluster.private_cluster_enabled,dns_prefix,dns_prefix_private_cluster,private_dns_zone_id, andnode_resource_groupare set at creation and cannot be changed in place.default_node_poolis 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 = truethe provider managesnode_count; the module renders it asnullin that mode so the autoscaler owns the count and you avoid a perpetual diff. - 🔴
local_account_disableddefaults totruehere, 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 inadmin_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 refuseazure_rbac_enabled = falseon its own, which is a legitimate configuration when an admin group is supplied.
- 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_idsorkubelet_identity. - User Access Administrator (or Owner) at the container-registry scope to grant the kubelet identity
AcrPull.
- An existing resource group in a supported US Azure region (availability zones require an AZ-enabled region such as
eastus2,westus2, orcentralus). - The
Microsoft.ContainerServiceresource provider registered on the subscription; theEncryptionAtHostfeature registered if any pool setshost_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.
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
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.
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 |
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, anddns_service_ipare 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
AcrPullgrant — nodes pull images with a managed identity, no registry credentials stored anywhere.
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))
}))| 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_configcarry client certificates and keys and are not emitted as outputs — retrieve them out of band.
- 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_principalblock;identitydefaults toSystemAssignedand can be moved to user-assigned by supplyingidentity_ids. - The default pool is inline and required.
default_node_poolis part of the keystone (a system pool always exists). Additional pools are separateazurerm_kubernetes_cluster_node_poolresources 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. Usetemporary_name_for_rotationon the default pool to roll a force-new change without an outage. - Autoscaling and
node_count. Whenauto_scaling_enabled = true, the module rendersnode_count = nullso 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-emptyadmin_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. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| 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 |
— |
cd terraform-azurerm-kubernetes-cluster
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
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.
$ 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"
}| 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). |
- 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."