Kubernetes IaC for AKS: Comprehensive Reference¶
Core Principle: Separation of Concerns¶
Platform IaC (Terraform/Bicep for AKS cluster, VNet, ACR, Key Vault) and Application IaC (Helm/Kustomize for workload manifests) operate on completely different lifecycles, use different tools, require different credentials, and must live in separate repositories with separate pipelines.
| Dimension | Platform IaC | Application IaC |
|---|---|---|
| Changes | Infrequently (cluster upgrades, networking) | Daily (app deployments) |
| Blast radius | High — can destroy the cluster | Namespace-scoped |
| Credentials | Cloud-provider credentials (Terraform/Bicep) | Cluster credentials only |
| Owner | Platform team | Application teams |
| Tools | Terraform, Bicep | Helm, Kustomize |
Never mix platform and app resources in one Terraform state. An application change must never be able to accidentally destroy the cluster.
GitOps: The Operational Model¶
GitOps extends declarative IaC to operations via four principles: 1. Git as single source of truth for all desired state 2. Pull-based deployment — in-cluster agent pulls changes (eliminates stored cluster credentials in CI) 3. Continuous reconciliation — automatically corrects drift 4. Declarative descriptions for everything
Flux CD v2 vs ArgoCD — When to Choose Each¶
| Aspect | Flux CD v2 | ArgoCD |
|---|---|---|
| UI | None (CLI only) | Rich web dashboard with app visualization, diff views |
| Architecture | Modular Kubernetes-native controllers | Monolithic with CRDs |
| SOPS secrets | Native integration | Requires plugins |
| Kustomize + Helm | kustomize-controller as post-renderer (unique capability) | Separate paths |
| Multi-cluster | Supported | ApplicationSets with generators (Git directory, cluster, list, matrix) |
| After Weaveworks shutdown | Continues under AWS, Microsoft, community | Stable, CNCF graduated |
Choose ArgoCD for teams wanting UI-driven operations and easier onboarding. Choose Flux for platform teams wanting composable, Kubernetes-native controllers with tighter GitOps purity and native SOPS encryption.
GitOps Repository Structure¶
Separate application source repos from GitOps config repos: - App repo: source code + Dockerfile; CI builds images and updates the config repo - Config repo: Kubernetes manifests; GitOps agent deploys from here
Directory-based promotion on a single branch (not branch-per-environment):
config-repo/
├── envs/
│ ├── dev/ # kustomization.yaml pointing to base + dev overlay
│ ├── staging/
│ └── prod/
├── base/ # shared manifests
└── platform/ # cluster-level config (RBAC, namespaces, network policies)
Promotion = copying version information between directories via PR. Clear visibility, easy rollback via git revert, no merge conflicts between environments.
Helm Chart Design¶
Chart Structure¶
my-chart/
├── Chart.yaml # metadata, version, appVersion
├── values.yaml # defaults
├── values.schema.json # validation schema (ALWAYS include)
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── _helpers.tpl # reusable named templates
│ └── NOTES.txt
├── charts/ # chart dependencies
└── .helmignore
values.schema.json — Catch Misconfigurations Early¶
Helm validates values against this schema at install/upgrade time before anything reaches the cluster:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" }
},
"required": ["repository", "tag"]
}
},
"required": ["replicaCount", "image"]
}
_helpers.tpl — Naming and Labels¶
Define common labels, selectors, and naming in _helpers.tpl and reference with {{ include "mychart.labels" . | nindent 4 }}. This ensures consistency and enables upgrades to find existing resources.
Versioning and Registries¶
- Follow SemVer 2.0.0 strictly: MAJOR for breaking value schema changes, MINOR for new features, PATCH for fixes
- Push charts to OCI-based registries (ACR, GHCR):
helm push mychart-1.0.0.tgz oci://myregistry.azurecr.io/charts - Use
helm registry loginwith Workload Identity or managed identity credentials
Anti-Patterns to Avoid¶
- Over-templating: parameterizing everything "just in case" creates unmaintainable charts
- God charts: one chart for an entire platform with dozens of conditionally-enabled components
:latesttags in default values — breaks reproducibility and rollback- Not using
.helmignore— test files bloat packaged charts
Kustomize: Overlays and Patches¶
Base + Overlay Pattern¶
kubernetes/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── dev/
│ ├── kustomization.yaml # references ../base
│ └── patches/
│ └── deployment-replicas.yaml
├── staging/
│ └── kustomization.yaml
└── prod/
└── kustomization.yaml
base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
overlays/prod/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: myapp-prod
resources:
- ../../base
patches:
- path: patches/deployment-prod.yaml
images:
- name: myapp
newTag: "1.2.3"
Keep namespace declarations out of base resources. Specify namespaces in overlay kustomization.yaml files so the base is reusable across environments without modification.
ConfigMapGenerator — Automatic Rolling Updates¶
ConfigMapGenerator creates resources with content-hash suffixed names, automatically triggering rolling updates when content changes:
configMapGenerator:
- name: app-config
envs:
- .env.prod
literals:
- LOG_LEVEL=info
secretGenerator:
- name: db-credentials
files:
- password.txt
This is a critical advantage over manually managed ConfigMaps — no more "did the deployment pick up the config change?"
Strategic Merge Patches¶
# patches/deployment-prod.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
template:
spec:
containers:
- name: myapp
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
memory: "512Mi"
Kustomize + Helm Combined¶
Flux's kustomize-controller can post-render Helm output with Kustomize patches — combine both tools in a single pipeline. Use Helm for third-party charts (Prometheus, NGINX, cert-manager) and Kustomize for overlaying environment-specific patches.
Terraform for AKS Provisioning¶
Module Structure (Preferred Over Workspaces)¶
Directory-based separation is preferred over workspaces for production — workspaces create risk of applying to the wrong environment.
infrastructure/
├── modules/
│ ├── vnet/ # VNet, subnets, NSGs
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── aks/ # AKS cluster, node pools
│ ├── acr/ # Azure Container Registry
│ ├── keyvault/ # Key Vault + RBAC
│ └── monitoring/ # Log Analytics, Managed Prometheus
└── environments/
├── dev/
│ └── main.tf # composes modules with dev values
├── staging/
└── prod/
Each environment root module composes the modules with different variable values. Bridge environments via Terraform outputs consumed by application configuration.
Remote State in Azure Blob Storage¶
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate"
container_name = "tfstate"
key = "prod/aks.tfstate"
}
}
Azure Blob Storage natively supports state locking via blob leases — no extra configuration needed. Enable blob versioning for rollback. One state file per environment prevents cross-environment blast radius.
OIDC Authentication for CI/CD (No Stored Secrets)¶
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "=4.14.0" # Pin exactly; commit .terraform.lock.hcl
}
}
}
provider "azurerm" {
features {}
use_oidc = true # Uses OIDC federated credentials from GitHub Actions
}
AKS Cluster Module Example¶
module "aks" {
source = "../../modules/aks"
resource_group_name = azurerm_resource_group.main.name
location = var.location
cluster_name = "aks-${var.environment}"
# System node pool
system_node_count = 3
system_vm_size = "Standard_D4s_v5"
availability_zones = ["1", "2", "3"]
# Networking — Azure CNI Overlay with Cilium (recommended for new clusters)
vnet_subnet_id = module.vnet.aks_subnet_id
network_plugin = "azure"
network_plugin_mode = "overlay"
network_dataplane = "cilium"
# Identity — user-assigned for pre-provisioned role assignments
identity_type = "UserAssigned"
user_assigned_identity_id = azurerm_user_assigned_identity.aks.id
# Security
enable_oidc_issuer = true
enable_workload_identity = true
azure_rbac_enabled = true # Azure RBAC for Kubernetes authorization
private_cluster_enabled = true
# Add-ons
enable_azure_policy = true
enable_key_vault_secrets_provider = true
enable_monitor = true
}
Azure Verified Modules¶
The Azure Verified Modules (Azure/avm-ptn-aks-production/azurerm, released October 2024) provide enterprise-grade, Microsoft-supported AKS provisioning. Use as the starting point for production clusters.
module "aks_production" {
source = "Azure/avm-ptn-aks-production/azurerm"
version = "~> 0.1"
resource_group_name = azurerm_resource_group.main.name
location = var.location
name = "aks-prod"
}
Bicep: Azure-Native Alternative¶
Bicep requires no state management — Azure Resource Manager tracks state directly. Eliminates an entire category of operational concerns (state locking, corruption, storage). Day-zero support for new Azure features.
When to choose Bicep: Azure-only teams wanting the simplest experience, teams without Terraform investment.
When to choose Terraform: Multi-cloud environments, non-Azure resources (Datadog, GitHub, DNS providers), or teams with existing Terraform investment.
param location string = resourceGroup().location
param clusterName string
resource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {
name: clusterName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${aksIdentity.id}': {}
}
}
properties: {
agentPoolProfiles: [
{
name: 'system'
count: 3
vmSize: 'Standard_D4s_v5'
mode: 'System'
osType: 'Linux'
availabilityZones: ['1', '2', '3']
enableAutoScaling: true
minCount: 3
maxCount: 9
nodeTaints: ['CriticalAddonsOnly=true:NoSchedule']
}
]
networkProfile: {
networkPlugin: 'azure'
networkPluginMode: 'overlay'
networkDataplane: 'cilium'
}
oidcIssuerProfile: { enabled: true }
securityProfile: { workloadIdentity: { enabled: true } }
enableRBAC: true
aadProfile: {
managed: true
enableAzureRBAC: true
}
}
}
Use az deployment group what-if for plan-equivalent previews before applying.
AKS Security Hardening¶
Pod Security Standards (Replace Deprecated PSPs)¶
Enforce the Restricted profile on all production namespaces:
apiVersion: v1
kind: Namespace
metadata:
name: myapp-prod
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.28
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
A Restricted-compliant pod security context:
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
Migrate gradually: set warn and audit modes first, then switch to enforce after validating workloads.
Network Policies — Default Deny¶
Apply a default deny-all NetworkPolicy to every namespace, then explicitly allow required communication:
# 1. Default deny all ingress and egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
# 2. Allow DNS (always required)
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
With Cilium on AKS, leverage CiliumNetworkPolicy for L7 and FQDN-based filtering (Azure CNI Overlay + Cilium is the recommended stack for new clusters).
Workload Identity Setup (Replaces Deprecated Pod Identity)¶
# Enable on cluster
az aks update \
--resource-group myRG \
--name myAKS \
--enable-oidc-issuer \
--enable-workload-identity
# Create user-assigned managed identity
az identity create \
--name myapp-identity \
--resource-group myRG
# Get OIDC issuer URL
OIDC_ISSUER=$(az aks show --resource-group myRG --name myAKS \
--query "oidcIssuerProfile.issuerUrl" -o tsv)
# Create federated credential
az identity federated-credential create \
--name myapp-federated \
--identity-name myapp-identity \
--resource-group myRG \
--issuer "$OIDC_ISSUER" \
--subject "system:serviceaccount:myapp:myapp-sa" \
--audiences api://AzureADTokenExchange
Kubernetes service account annotation:
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-sa
namespace: myapp
annotations:
azure.workload.identity/client-id: "<managed-identity-client-id>"
Pod label to trigger token injection:
OPA Gatekeeper / Azure Policy¶
Azure Policy for AKS enforces governance at admission time using built-in initiative including Deployment Safeguards. Key policies to enforce:
- Require resource limits on containers
- Disallow privileged containers
- Require non-root user
- Require readOnlyRootFilesystem
- Restrict allowed registries (only pull from ACR)
- Enforce image signing (via Ratify + cosign)
Enable via Terraform:
Container Scanning — Trivy¶
Run Trivy in CI/CD pipeline to fail builds on CRITICAL/HIGH findings:
- name: Scan container image
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: 'sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
if: always()
Deploy Trivy Operator for continuous in-cluster scanning of running workloads.
Private Cluster Configuration¶
Production AKS clusters should use API server VNet integration — control plane accessible only from private network:
resource "azurerm_kubernetes_cluster" "main" {
private_cluster_enabled = true
private_cluster_public_fqdn_enabled = false
api_server_access_profile {
vnet_integration_enabled = true
subnet_id = azurerm_subnet.apiserver.id
}
}
For management access use Azure Bastion for SSH tunneling, or az aks command invoke to run kubectl without direct network access.
Node Pool Design¶
Production AKS clusters must separate system and user node pools:
# System node pool — only critical add-ons
resource "azurerm_kubernetes_cluster" "main" {
default_node_pool {
name = "system"
vm_size = "Standard_D4s_v5"
node_count = 3
zones = ["1", "2", "3"]
only_critical_addons_enabled = true # Taint: CriticalAddonsOnly=true:NoSchedule
os_sku = "AzureLinux"
}
}
# User node pool — application workloads
resource "azurerm_kubernetes_cluster_node_pool" "user" {
kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
name = "user"
vm_size = "Standard_D8s_v5"
min_count = 3
max_count = 20
enable_auto_scaling = true
zones = ["1", "2", "3"]
os_sku = "AzureLinux"
}
Azure Linux 2.0 reaches EOL in November 2025. Migrate to AzureLinux 3 before March 2026.
Kubenet is deprecated with removal scheduled March 2028. Azure CNI Overlay powered by Cilium is the recommended networking stack for new clusters — 30% reduction in service routing latency, replaces kube-proxy with eBPF, built-in network policy.
Upgrade Strategy¶
Use both cluster auto-upgrade and node OS auto-upgrade with separate maintenance windows:
resource "azurerm_kubernetes_cluster" "main" {
automatic_upgrade_channel = "stable" # Auto-upgrade to latest patch of N-1 minor version
node_os_upgrade_channel = "NodeImage"
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 = "Wednesday"
start_time = "02:00"
utc_offset = "+00:00"
}
}
Configure surge upgrades on node pools (max_surge = "33%") to provision extra nodes during rolling upgrades. Always define PodDisruptionBudgets — misconfigured PDBs block the entire upgrade process.
Environment Promotion Pattern¶
Dev → Staging → Prod via overlay promotion:
- CI builds image, tags it with git SHA:
myapp:abc1234 - CI updates the image tag in
envs/dev/kustomization.yamlvia PR - GitOps agent deploys to dev
- Validate in dev, promote: update
envs/staging/kustomization.yamlvia PR - Validate in staging, promote: update
envs/prod/kustomization.yamlvia PR with required approvals
With ArgoCD ApplicationSets:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: myapp
spec:
generators:
- list:
elements:
- cluster: dev
url: https://dev-aks.example.com
- cluster: staging
url: https://staging-aks.example.com
- cluster: prod
url: https://prod-aks.example.com
template:
metadata:
name: 'myapp-{{cluster}}'
spec:
source:
repoURL: https://github.com/myorg/config-repo
path: 'envs/{{cluster}}'
destination:
server: '{{url}}'
namespace: myapp
Production Workload Checklist¶
Every production Deployment should have:
- Minimum 3 replicas
- PodDisruptionBudget with
maxUnavailable: 1(nevermaxUnavailable: 0— blocks cluster upgrades) - HPA with stabilization windows to prevent flapping
- Topology spread across zones (
DoNotSchedule) and nodes (ScheduleAnyway) - All three probe types: startup (
failureThreshold: 60, slow start tolerance), liveness (lightweight self-check only, never check external deps), readiness (checks dependencies, removes from Service endpoints) - Resource requests always set; memory limits always set; CPU limits not set (causes CFS throttling)
- Non-root user, readOnlyRootFilesystem, no privilege escalation, drop ALL capabilities
- Dedicated ServiceAccount with
automountServiceAccountToken: false - ServiceMonitor for Prometheus scraping
- Distroless or Chainguard base images (no shell, no package manager)
- Images pinned by digest, not tag:
myapp@sha256:abc123...
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "256Mi" # Always set memory limit
# No CPU limit — allow bursting to avoid CFS throttling
Secret Management Options¶
Three approaches for different needs:
-
External Secrets Operator (ESO) — recommended for GitOps. Synchronizes secrets from Azure Key Vault into Kubernetes Secrets via ExternalSecret CRDs safe to commit to Git. Authenticate to Key Vault using Workload Identity.
-
Azure Key Vault CSI Driver — mounts secrets directly as CSI volumes (secrets never stored in etcd). Ideal when avoiding Kubernetes Secrets entirely. Enable with
enable_key_vault_secrets_provider = truein Terraform. -
SOPS with Azure Key Vault — encrypts secret values while keeping keys readable. Perfect for GitOps diffs. Native Flux support. Keys are readable in Git; only values are encrypted.
Never store unencrypted secrets in Git. Four defense layers: pre-commit hooks with gitleaks, CI gitleaks scanning, SOPS encryption, ExternalSecret CRDs as the primary pattern.
Plugin: azure-cloud-infra · View SKILL.md on GitHub