Azure AKS Reference Platform
Terraform-based AKS platform with private-by-default networking in prod and optional Flux GitOps.
Summary
A Terraform reference platform for Azure Kubernetes Service (AKS) shows how a production-shaped Kubernetes estate is provisioned, networked, and handed off to GitOps on Azure — with emphasis on the AKS baseline, hub-and-spoke egress through Azure Firewall, private endpoints, and customer-managed keys where compliance needs them.
The work centres on platform engineering on Azure: small first-party modules (network, cluster, registry, keys, database, alerting) composed into dev and prod roots, plus bootstrap for remote state and GitHub Actions OIDC. In-cluster software lives in gitops/ and is reconciled by Flux, not by a Kubernetes provider in the same apply as the cluster.
It is the Azure sibling of aws-eks-reference-platform and gcp-gke-reference-platform: same portfolio story (modules + environments, secure defaults, GitOps boundary, honest scope), different cloud primitives. Azure Firewall and private DNS sit in the hub. The cluster uses AKS-managed Cilium (CNI Overlay) and the cluster autoscaler, not self-managed Cilium or Karpenter.
Overview
| Platform | AKS on Azure — dev: single VNet, NAT egress, public API; prod: hub/spoke, Azure Firewall egress, private API |
| IaC | Terraform ≥ 1.11.1, azurerm 5.x — first-party modules under modules/ (no provider blocks inside modules) |
| Environments | environments/dev and environments/prod — separate state (dev.tfstate / prod.tfstate) |
| GitOps | Flux (optional AKS extension) and gitops/ in this repo; sample workload with workload identity and network policies |
| CI | terraform.yml on 1.11.1 and latest (mocked plan, no Azure login). deploy-production.yml plans and applies with OIDC once the tenant variable is set; it skips until then |
| Focus of this work | Baseline topology, module boundaries, Entra-only auth, CMK in prod, alerting, ADRs, quality gates |
| Default region | Code defaults are westeurope, Kubernetes 1.37. The committed prod example is the smoke test: northeurope, 1.36, zone 2 |
| Status | Dev applied and destroyed (2026-10-23). Prod smoke applied and destroyed (2026-10-30, 102 resources, PostgreSQL and Flux off). Reference implementation — verify in your subscription before production use. |
Context
Teams adopting AKS often mix landing-zone networking, the cluster, and Helm in one root. Platform teams usually want:
- Lifecycle-separated modules (network, cluster, registry, keys, Postgres) so a cluster rebuild does not drag the registry or the database
- Controlled egress in production (Firewall and the AKS FQDN tag), not default outbound
- Private-by-default data plane: ACR, Key Vault, Postgres, and in prod the cluster API
- Identity over secrets — Entra groups for people, workload identity for apps, local accounts and storage keys off
- Terraform stops at the cluster boundary — Flux owns day-2 in-cluster config
On AWS, the sister repo self-manages Cilium and Karpenter. On GCP, Dataplane V2 and the cluster autoscaler are Google-managed. On Azure, AKS already runs Cilium as CNI Overlay and scales explicit node pools. This repo does not install Cilium or Karpenter. The prod smoke test used a Spot apps pool (Standard_D2s_v3 in North Europe). Dev uses a Regular Standard_D2s_v3 apps pool; an earlier dev attempt on B2s Spot had no capacity.
What was done
Repository layout
| Layer | Path | Role |
|---|---|---|
| Modules | modules/ |
Reusable Azure building blocks. Callers configure providers |
| Environments | environments/dev, environments/prod |
Deployable roots. Separate backend key per folder |
| Bootstrap | bootstrap/github-actions-identity, modules/storage-backend/examples/bootstrap |
One-off OIDC identities and the state storage account |
| GitOps | gitops/ |
Flux manifests (cert-manager, sample API). Not applied by the environment Terraform |
| Decisions | docs/adr/ |
Hub/spoke, CMK opt-in, OIDC, module split |
| Runbooks | docs/runbooks/ |
Index only. Upgrades, restore, key rotation, and break-glass are listed, not written |
Internal modules
These modules call azurerm directly. The AWS and GCP sisters wrap terraform-aws-modules and terraform-google-modules because those are the standard cluster modules. On Azure the equivalent is Azure Verified Modules. This repo follows that split — one capability per module, ADR 0001 — and does not call the published modules, so each default is visible in the code. A platform team would usually start from AVM instead.
| Module | Purpose |
|---|---|
| hub-network | Hub VNet, Azure Firewall and policy, central private DNS, optional Bastion, optional point-to-site VPN (off unless vpn_gateway is set) |
| network | Spoke or standalone VNet, NSG per subnet, NAT or a firewall route, hub peering |
| aks | Public or private API, Entra RBAC, workload identity, monitoring, optional Flux, optional CMK for disk and etcd |
| container-registry | Premium ACR, private endpoint, Entra-only, optional CMK |
| key-vault | RBAC Key Vault, purge protection, rotating HSM keys |
| postgresql-flexible-server | Private Postgres, Entra auth, HA, optional CMK |
| storage-backend | Entra-only, versioned state storage |
| alerting | Metric and activity alerts, plus a budget. Wired in prod |
| entra-app-registration | App registration with a federated credential or a rotating secret |
Keycloak (modules/keycloak) is not part of the Azure platform. It configures a realm for applications that need their own login. It may move to its own repository.
Environments
| Root | Topology | Tested |
|---|---|---|
| dev | Single VNet, NAT egress, public API limited to authorized IPs, Regular apps pool, Postgres off unless postgres_enabled |
Yes — North Europe, 2026-10-23, then destroyed. kubectl worked after kubelogin and Azure Kubernetes Service RBAC Cluster Admin |
| prod | Hub + spoke, Firewall egress, private cluster, private ACR and Key Vault, CMK, alerting. Postgres is on by default and off in the committed smoke-test example. Flux turns on only when gitops_repository_url is set |
Smoke test — North Europe, 2026-10-30, 102 resources, then destroyed. PostgreSQL and Flux were off. Laptop kubectl did not resolve the private API |
Resource groups are split by lifecycle (network, aks, data, security, ops, connectivity).
The prod variable defaults are the larger West Europe layout. The firewall stays non-zonal there too, because zones = [] is set in environments/prod/network.tf, not in tfvars. environments/prod/terraform.tfvars.example is the smaller smoke test. Copy it and replace the placeholder Entra group and the documentation IP before a real apply.
Networking and egress
- Prod hub
10.0.0.0/22: firewall subnet, optional Bastion, optional GatewaySubnet. Central private DNS for ACR, Key Vault, AKS, and Postgres. Spokes resolve those zones through the firewall DNS proxy. - Prod spoke
10.10.0.0/16: node subnet default route0.0.0.0/0to the firewall, delegated API-server subnet, optional Postgres subnet, private-endpoint subnet. - Firewall allow-lists the AKS FQDN tag and denies with threat intel. The committed prod root sets
zones = []because North Europe rejected a zonal Standard firewall. The module default remains zonal. - Dev has no hub. Egress is a NAT gateway. The API is public and restricted to authorized IPs, including the NAT address so nodes can reach it.
- Point-to-site VPN on the hub is opt-in (
vpn_gateway_enabled, default false). It was added after the smoke test and was not applied. It does not publish hub private DNS to the laptop, sokubectlstill needs a resolver.
Identity and data plane
- Cluster access is Entra ID with Azure RBAC. Local accounts are off. An Entra group is the admin; membership alone is not enough — the group or user also needs Azure Kubernetes Service RBAC Cluster Admin.
- Apps use workload identity. The sample API in
gitops/is the pattern. - Prod encrypts disk, etcd, ACR, and Postgres with customer-managed keys in Key Vault. Dev does not, so a sandbox does not pay for Premium Key Vault.
- ACR, Key Vault, and Postgres have no public endpoint in the prod layout. Postgres auth is Entra-only unless password auth is turned on explicitly.
Node scaling
- System pool is Regular. The
appspool has min and max and the cluster autoscaler. - Dev uses a Regular
Standard_D2s_v3apps pool. Spot on that size works (the prod smoke test used it), but a Spot apps pool is taintedkubernetes.azure.com/scalesetpriority=spot:NoSchedule, so pods need that toleration. The system pool stays Regular because AKS does not allow Spot there.
Flux GitOps
- The prod root can install the AKS Flux extension when
gitops_repository_urlis set. The committed example leaves it null, so Flux stays off. gitops/holds the Flux tree (controllers and a sample API). A separate workflow,gitops.yml, runskustomize buildand kubeconform. It does not apply Azure and does not need a cluster.- Day-2 sync of that tree onto a live cluster has not been done. A later step is a fleet repository of its own, the same split the AWS and GCP pages describe.
Remote state and CI
- State backend is Azure Storage with Entra auth. The storage-account bootstrap is a one-off example. Both live runs used a local state file.
terraform.ymlchecks every module and both environments on Terraform 1.11.1 and on latest: fmt, validate, mockedterraform test, tflint, terraform-docs, Trivy. No Azure credentials. Green as of 2026-10-31.1.11.0is excluded because it cannot encode a sensitive write-only attribute.deploy-production.ymlplans on pull requests and applies onmainafter an environment approval, using OIDC to the identities inbootstrap/github-actions-identity. Both jobs skip while the repository variableAZURE_TENANT_IDis empty. Leave it empty until that bootstrap is applied and the subscription, state storage, client IDs, andTFVARS_PRODUCTIONsecret are set. A private Key Vault needs a self-hosted runner (TERRAFORM_RUNNER); hosted runners cannot read the key data plane.
What is intentionally not claimed
- A standing production estate. Both applies were destroyed the same day.
- Laptop
kubectlto the private API. The nameprivatelink.northeurope.azmk8s.iodid not resolve. VPN is optional and still needs private DNS. - Flux sync, the OIDC apply, and remote state against this subscription.
- The four runbooks. The folder is a list.
- Keycloak as part of the Azure platform.
Design rationale
| Area | Decision | Rationale |
|---|---|---|
| Modules | First-party, one concern each | Registry and database outlive a cluster rebuild. Callers own providers and names |
| Module code | Own azurerm resources, AVM-shaped split |
Azure Verified Modules are the maintained base and the better starting point for a real platform. This reference writes the resources so private endpoints, Entra-only auth, and CMK are readable in the module. |
| Prod egress | Spoke route to Azure Firewall | Allow-list the AKS FQDN tag. Deny by default with threat intel |
| Dev egress | NAT gateway, public API | A sandbox without a hub. Authorized IPs replace a private API |
| CMK | On in the prod layout, off in dev | Compliance versus cost. Relaxing a default is an explicit input |
| Dataplane | AKS CNI Overlay + Cilium | Microsoft operates the CNI. The EKS repo installs Cilium because the default VPC CNI path is different |
| Node scaling | Pool min/max and the cluster autoscaler | No Karpenter on AKS in this repo. Same idea as the GKE sister |
| GitOps | Flux extension optional; manifests in gitops/ |
Terraform does not own the app. The check workflow does not assume a cluster exists |
| CI auth | OIDC, plan and apply identities | No long-lived Azure secret in GitHub. The tenant variable is the switch until bootstrap exists |
| Firewall zones | Module default zonal; this prod root zones = [] |
North Europe rejected Standard firewall zones 1–3. The tested layout stays non-zonal |
Architecture
Platform stack (prod)
flowchart LR
internet((Internet))
subgraph hub[Hub VNet]
afw[Azure Firewall
DNS proxy and threat intel]
dns[Private DNS zones]
end
subgraph spoke[Spoke VNet]
nodes[Node subnet
default route to firewall]
api[API server subnet]
pe[Private endpoints]
end
subgraph workloads[Workloads]
aks[Private AKS
Entra RBAC, workload identity,
CNI Overlay and Cilium]
acr[ACR Premium]
pg[PostgreSQL Flexible
optional]
end
subgraph sec[Security and ops]
kv[Key Vault CMK]
law[Log Analytics]
end
spoke <-- peering --> hub
nodes --> afw --> internet
aks --- nodes
aks --- api
acr -. private endpoint .- pe
kv -. private endpoint .- pe
dns -.-> spoke
kv -. CMK .-> aks
kv -. CMK .-> acr
kv -. CMK .-> pg
aks --> law
Dev is not a smaller copy of this diagram. It is one VNet, a NAT gateway, and a public API. There is no hub, firewall, Key Vault, or registry.
Terraform composition
Solid lines are wired in the prod root. Dashed lines are off unless the input is set.
flowchart TB
subgraph env[environments — two roots]
dev[dev]
prod[prod]
end
subgraph mod[modules]
hub[hub-network]
net[network]
aksm[aks]
acrm[container-registry]
kvm[key-vault]
pgm[postgresql-flexible-server]
alrt[alerting]
end
dev --> net
dev --> aksm
dev -.-> pgm
prod --> hub
prod --> net
prod --> aksm
prod --> acrm
prod --> kvm
prod --> alrt
prod -.-> pgm
prod -. Flux extension .-> aksm
How to read it: cd into environments/dev or environments/prod and apply that root. Dev calls network and aks, and calls Postgres only when postgres_enabled is true. Prod always calls the hub, the spoke, AKS, ACR, Key Vault, and alerting. Postgres and the Flux extension follow their own inputs. The smoke-test example turns Postgres and Flux off.
Bootstrap sequence
sequenceDiagram participant Op as Operator participant ST as State storage participant TF as environments/prod participant AZ as Azure participant GH as GitHub Actions participant FX as Flux Op->>ST: Apply the storage bootstrap once Op->>TF: terraform apply TF->>AZ: Hub, spoke, AKS, ACR, Key Vault Note over TF,AZ: Postgres and Flux only if those inputs are set Op->>GH: Apply github-actions-identity, then set AZURE_TENANT_ID GH->>AZ: Plan on pull request, apply on main FX->>AZ: Reconcile gitops/ after the extension is installed
The two live runs skipped the state account and the GitHub identity and used local state. Destroy removed the estate the same day. A Key Vault with purge protection cannot be purged after destroy; the next apply uses a new name.
Technology stack
| Layer | Technologies |
|---|---|
| Cloud | AKS, Virtual Network, Azure Firewall, NAT Gateway, Bastion, Private DNS, ACR, Key Vault, PostgreSQL Flexible Server, Log Analytics, Azure Monitor |
| IaC | Terraform ≥ 1.11.1, azurerm ≥ 5 and < 6, azuread 3 |
| Networking | Hub/spoke or a single VNet. CNI Overlay, Cilium dataplane and network policy, private endpoints in prod |
| Autoscaling | Cluster autoscaler on the apps pool. Spot is optional |
| GitOps | Flux (AKS extension, off until a Git URL is set). gitops/ checked with kustomize and kubeconform |
| Identity | Entra ID, Azure RBAC for Kubernetes, workload identity. OIDC for GitHub Actions |
| Secrets and keys | Key Vault RBAC, purge protection, customer-managed keys in the prod layout |
| CI | fmt, validate, terraform test, tflint, terraform-docs, Trivy, actionlint. Production plan and apply pinned to Terraform 1.11.1 |
Design principles
- Secure by default. No public data-plane endpoint, Entra-only, local accounts off. A sandbox relaxes that with an explicit input, as dev does for the API and for CMK.
- Modules are a library, environments are roots. One module, two deployment shapes. Names and providers stay with the caller.
- Plan-time conditions only.
countandfor_eachdepend on inputs known at plan time, not on resource IDs that appear during apply. - Platform versus application. Terraform builds Azure. Flux reconciles what runs on the cluster.
- Honest pairing with the other clouds. Same story as the EKS and GKE repos, without pretending AKS needs a self-managed Cilium or Karpenter install.
- Record what was actually applied. The prod example is the smoke test. The larger West Europe layout stays in the variable defaults and in comments.
Relation to other work
| Project | Role |
|---|---|
| aws-eks-reference-platform | AWS sister — Cilium, Karpenter, optional Flux |
| gcp-gke-reference-platform | GCP sister — Dataplane V2, layered state, Flux or Argo CD |
| tiho-banking-platform | Application layer — Kustomize overlays that can run on a cluster bootstrapped here |
A realistic end-to-end story: Terraform provisions AKS (this repo) → Flux reconciles platform and app manifests → an application overlay runs on the cluster. The Flux half of that story has not been run against a live cluster yet.
Sister repos
| AWS (EKS) | GCP (GKE) | Azure (this repo) | |
|---|---|---|---|
| Cluster | EKS | GKE Standard, regional | AKS, private in prod |
| CNI / dataplane | Cilium (Helm), kube-proxy replacement | Dataplane V2 | AKS CNI Overlay + Cilium |
| Node scaling | Karpenter and a system node group | Cluster autoscaler on one pool | Cluster autoscaler, system pool plus apps |
| Environments | cilium-karpenter (tested), default (reference) |
dev / prod × foundation / platform layers |
dev / prod roots |
| GitOps | Flux optional in the Cilium env | Flux layer, or Argo CD as an example | Flux extension optional; gitops/ in-repo |
| Data | Optional RDS module | Not in the repo yet | PostgreSQL Flexible Server, optional |
| Keys | Not the focus | Not the focus | Key Vault CMK in the prod layout |
| Bootstrap pain | Cilium before nodes, CoreDNS API access | Terraform provider order, so two state layers | Private API and private Key Vault: DNS, and a runner inside the network |
| Modules | Wrap terraform-aws-modules |
Wrap terraform-google-modules |
Own azurerm resources, split like Azure Verified Modules |
Cost (lab)
Azure Firewall bills for every hour it exists, including after the cluster is quiet. Zone-redundant PostgreSQL and Premium ACR and Key Vault dominate the rest of a full prod apply. The 2026-10-30 smoke test left PostgreSQL off, used a small Spot pool, and was destroyed the same day so the firewall would not keep billing. Dev is the cheap shape: NAT, no firewall, no Key Vault.
Attribution and license
Infrastructure code and documentation: Tycho-1/az-aks-reference-platform, MIT. Modules are first-party Terraform on the azurerm provider. Flux is the upstream project, installed only when the GitOps input is set.