Skip to content
Kumar Chandrachooda
Azure & DevOps

The AKS Module: The Wrapper That Broke the Conventions

The catalog's most divergent template - a different module-source depth, extra pipeline-fed variables, a computed cluster name, CMK from a second Key Vault and a renamed provider alias - and what its dated Kubernetes pins say about version governance. Part 10 of the Terraform Module Catalog.

By Kumar Chandrachooda 05 Apr 2026 6 min read
Mostly consistent is a kind of inconsistent

Fifteen templates in this library follow the house conventions so faithfully you can diff two of them and see only the service-specific middle. Then there's AKS. Same chassis, same intent — and a different module-source depth, three input variables where every sibling has one, a state key derived from a different pipeline variable, a provider alias that changes its name at the module boundary, and the only hardcoded subscription reference in the catalog. None of these are large. Together they make AKS the estate's best case study in what “mostly consistent” actually costs.

This is Part 10 of the Terraform Module Catalog, companion to the main Terraform on Azure series; the conventions AKS diverges from are that series' Part 1.

The copy-paste trap, measured in ../

Every template's main.tf sources the golden module from a sibling directory the pipeline checks out:

# every other template:
source = "../../../infra-repo"

# the AKS template:
source = "../../infra-repo"

Two levels up instead of three — correct for the repo layout this template was born in, wrong for the azure/<service>/ layout every sibling assumes. The comment above it even instructs users to hand-count: "source should be the number of directories in current repo including the repo directory itself." That sentence is the defect. A relative path whose correctness depends on where a copier pastes the folder is a contract with no enforcement, and the error message when you miscount (Unreadable module directory) names a path, not the convention you violated. If your stacks must compute a hop count by hand, the count will eventually be wrong — and it was, in the one template teams copy for their most complex workload.

Three variables, and a pipeline that feeds them

The house style is a single input variable (platform_vars_json). AKS declares three:

variable "platform_vars_json" { type = string }
variable "cluster_name_base"  { type = string }
variable "agent_builddirectory" {
  description = "Root of the pipeline workspace - used to locate shell script paths."
  type        = string
}

fed by the pipeline as TF_VAR_cluster_name_base and TF_VAR_agent_builddirectory on every step. agent_builddirectory is a confession in variable form: the underlying module shells out to scripts during apply and needs to know where the checkout lives — the same load-bearing-escape-hatch story as the main series' Part 9, here leaking all the way up into the root module's interface. And cluster_name_base does triple duty: it names the cluster, it prefixes a secure-file the pipeline pulls from the project library, and it is the state key — this template's state file is <cluster-name-base>.terraform.tfstate where every sibling uses a dedicated stack-name variable. Reasonable in isolation; in a fleet, it means the one automation script that inventories state files by convention has exactly one exception to special-case forever.

The name itself is computed, and the expression is worth keeping:

cluster_name = replace("${var.cluster_name_base}-${local.env}", ".", "-")

Append the environment (so one pvars value serves all workspaces), then swap dots for dashes — because the base name arrives from a file-naming convention where dots are legal, and an AKS cluster name becomes a DNS label where they are not. One replace() at the seam between two naming regimes, instead of a rule in a wiki. Sanitize at boundaries, not in documentation.

The full-object node pool

Where other templates map scalars, AKS maps a complete node-pool object per environment:

default_node_pool_list = {
  prod = {
    name                   = "default"
    vm_size                = "Standard_DS2_v2"
    availability_zones     = [1, 2, 3]
    enable_auto_scaling    = false
    enable_node_public_ip  = false
    node_count             = 3
    os_disk_size_gb        = 50
    max_pods               = 15
    max_surge              = 1
    enable_host_encryption = null
  }
  # dev and staging: same shape, different numbers
}

The defaults encode a posture: three nodes across three availability zones (zone redundancy from day one), no public IPs on nodes ever, max_surge = 1 so upgrades roll one extra node at a time, and host encryption present as an explicit knob rather than an omission. The one eyebrow-raiser in the shipped numbers: dev allowed max_pods = 20 while staging and prod allowed 15 — the density ceiling loosest in the environment that least resembles production. Whatever fits in dev should be what fits in prod; a pod-density difference is a scheduling behavior difference, and it belongs in the same one-line-diff review as a SKU change.

Version policy sits alongside, twice over:

kubernetes_version_list    = { dev = "1.19.7", staging = "1.19.7", prod = "1.19.7" }
automatic_channel_upgrades = { dev = null, staging = null, prod = null }   # null | "patch" | "rapid" | "stable"

Pinned exactly, auto-upgrade off. As a decision that's defensible — clusters shouldn't surprise you mid-sprint. But this template still said 1.19.7 (and its provider still pinned an azurerm 2.x from the same era) long after both had aged out of support, and AKS is not a service where you may politely decline to upgrade: old minor versions get deprecated for you, on Azure's schedule. Pinning without a bump cadence doesn't freeze time; it curates a museum whose exhibits eventually stop being deployable. If you turn the channel off, the calendar entry that replaces it is part of the design.

CMK from a second vault, admin by group, and a renamed alias

Three architecture choices round out the wrapper. Disk encryption keys come from a different Key Vault than the application-secrets vault the other stacks use — the platform JSON carries a parallel set of facts for it. That separation is deliberate: infrastructure encryption keys and application secrets have different audiences, and a compromise or purge of one vault shouldn't touch the other. Cluster admin arrives as an AAD group object ID from platform JSON, wired into AKS's AAD/RBAC integration — membership in one directory group is the admin list, auditable and revocable without touching Terraform. And the module wiring includes the catalog's only alias rename:

providers = {
  azurerm.hub = azurerm.shared
}

The stack calls its second provider shared; the module declares it as hub. Terraform is explicitly fine with this — that's the point of the providers map — but in a copy-paste estate the two names for one subscription mean every grep, every runbook and every onboarding conversation needs a translation table. Naming conventions only pay when they survive module boundaries.

Which brings me to the anonymized defect, and it's the catalog's most instructive: in the estate this series draws from, this template's shared-provider block didn't read the hub subscription from platform JSON like every sibling — it hardcoded a subscription ID in the provider block. The stack worked perfectly and would have kept working right up until a subscription migration, at which point fifteen templates would follow the variable repo and one would silently keep deploying its private-DNS records into the old world. A literal where a fact belongs is drift with a delay timer. (Smaller finds in the same file: a try() with no fallback argument — which protects nothing, since single-argument try re-raises the error — and a computed local for the backend storage account that no resource ever consumed. Dead code and no-op guards survive copy-paste generations precisely because they never fail.)

The lesson of the whole module: conventions are only as strong as their least conventional member. Every divergence here had a local reason and a fleet-wide cost, and the fleet is where platform teams live.

That closes the catalog's compute-and-data arc. Next, the series turns to the edge — the module where everything is a list of objects, and an intentionally invalid IP address is a feature: Part 11, Application Gateway.