Azure Terraform Patterns#
Azure’s Terraform provider (azurerm) has its own idioms, naming conventions, and gotchas that differ significantly from AWS. The biggest differences: everything lives in a Resource Group, identity management uses Managed Identity (not IAM roles), and many services require explicit Private DNS Zone configuration for private networking.
Resource Groups: Azure’s Organizational Unit#
Every Azure resource belongs to a Resource Group. This is the first thing you create and the last thing you delete.
Organization Strategy#
# Option 1: One RG per environment (simple, common)
resource "azurerm_resource_group" "main" {
name = "${var.project}-${var.environment}-rg"
location = var.location
tags = local.common_tags
}
# Option 2: One RG per concern (larger deployments)
resource "azurerm_resource_group" "networking" {
name = "${var.project}-${var.environment}-networking-rg"
location = var.location
}
resource "azurerm_resource_group" "compute" {
name = "${var.project}-${var.environment}-compute-rg"
location = var.location
}
resource "azurerm_resource_group" "data" {
name = "${var.project}-${var.environment}-data-rg"
location = var.location
}Gotcha: Deleting a Resource Group deletes everything in it — including resources created by other Terraform configurations or manually. Use Terraform to manage RG lifecycle, not the Azure portal.
Gotcha: AKS creates a second Resource Group (MC_*) for its managed infrastructure (VMs, disks, NICs). Do not Terraform-manage this RG — AKS manages it.
Location and Naming#
variable "location" {
type = string
default = "eastus"
# Azure locations: eastus, westus2, westeurope, northeurope, southeastasia, etc.
# Use short names, not display names: "eastus" not "East US"
}
locals {
# Azure naming convention: {project}-{environment}-{resource-type}
name_prefix = "${var.project}-${var.environment}"
}Gotcha: Some Azure resource names must be globally unique (Storage Accounts, Key Vaults, Public IPs with DNS labels). Add a random suffix or use a naming convention that includes the subscription ID prefix.
Managed Identity#
Azure’s equivalent of IAM roles. Managed Identities are Azure AD objects that resources use to authenticate without credentials.
System-Assigned vs User-Assigned#
# System-Assigned: lifecycle tied to the resource
resource "azurerm_kubernetes_cluster" "main" {
name = "${local.name_prefix}-aks"
# ...
identity {
type = "SystemAssigned" # AKS creates and manages the identity
}
}
# User-Assigned: lifecycle managed independently
resource "azurerm_user_assigned_identity" "app" {
name = "${local.name_prefix}-app-identity"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
# Grant the identity access to Key Vault
resource "azurerm_role_assignment" "app_keyvault" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}When to use which:
- System-Assigned: for AKS clusters, VMs, App Services — when the identity should be destroyed with the resource
- User-Assigned: for workloads that need a consistent identity across resource replacements, or when multiple resources share the same identity
AKS Workload Identity#
The modern pattern for pod-level IAM (replaces AAD Pod Identity):
resource "azurerm_kubernetes_cluster" "main" {
name = "${local.name_prefix}-aks"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = var.project
oidc_issuer_enabled = true
workload_identity_enabled = true
default_node_pool {
name = "default"
node_count = 3
vm_size = "Standard_D2s_v5"
}
identity {
type = "SystemAssigned"
}
}
# User-assigned identity for the workload
resource "azurerm_user_assigned_identity" "workload" {
name = "${local.name_prefix}-workload"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
# Federated credential linking K8s service account to Azure identity
resource "azurerm_federated_identity_credential" "workload" {
name = "kubernetes-federated"
resource_group_name = azurerm_resource_group.main.name
parent_id = azurerm_user_assigned_identity.workload.id
audience = ["api://AzureADTokenExchange"]
issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url
subject = "system:serviceaccount:default:my-app"
}
# K8s service account annotated with the Azure identity
resource "kubernetes_service_account" "app" {
metadata {
name = "my-app"
namespace = "default"
annotations = {
"azure.workload.identity/client-id" = azurerm_user_assigned_identity.workload.client_id
}
labels = {
"azure.workload.identity/use" = "true"
}
}
}Gotcha: The subject in the federated credential must exactly match the K8s service account namespace and name: system:serviceaccount:{namespace}:{name}.
Key Vault Integration#
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "main" {
name = "${var.project}${var.environment}kv" # globally unique, no hyphens
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
# RBAC instead of access policies (modern approach)
enable_rbac_authorization = true
purge_protection_enabled = true # prevents permanent deletion
}
# Grant Terraform runner access to manage secrets
resource "azurerm_role_assignment" "terraform_kv" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Administrator"
principal_id = data.azurerm_client_config.current.object_id
}
# Store a secret
resource "azurerm_key_vault_secret" "db_password" {
name = "db-password"
value = var.db_password
key_vault_id = azurerm_key_vault.main.id
depends_on = [azurerm_role_assignment.terraform_kv]
}Gotcha: Key Vault names must be globally unique, 3-24 characters, alphanumeric and hyphens only. Many naming conventions fail here.
Gotcha: When using RBAC (enable_rbac_authorization = true), you must grant yourself access before creating secrets. Without the role assignment, azurerm_key_vault_secret fails with ForbiddenByRbac.
Gotcha: purge_protection_enabled prevents Key Vault deletion for 90 days after soft-delete. Set to false only in dev/test.
Private Networking for Databases#
Azure Database for PostgreSQL Flexible Server with private networking requires three interconnected resources:
# 1. Delegated subnet (only PostgreSQL can use this subnet)
resource "azurerm_subnet" "database" {
name = "database-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.2.0/24"]
delegation {
name = "postgresql"
service_delegation {
name = "Microsoft.DBforPostgreSQL/flexibleServers"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
# 2. Private DNS zone for the database hostname
resource "azurerm_private_dns_zone" "postgres" {
name = "${var.project}.postgres.database.azure.com"
resource_group_name = azurerm_resource_group.main.name
}
# 3. Link DNS zone to VNET (so resources in the VNET can resolve the hostname)
resource "azurerm_private_dns_zone_virtual_network_link" "postgres" {
name = "postgres-vnet-link"
resource_group_name = azurerm_resource_group.main.name
private_dns_zone_name = azurerm_private_dns_zone.postgres.name
virtual_network_id = azurerm_virtual_network.main.id
}
# 4. The database itself
resource "azurerm_postgresql_flexible_server" "main" {
name = "${local.name_prefix}-postgres"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
version = "15"
sku_name = "GP_Standard_D2s_v3"
storage_mb = 65536
delegated_subnet_id = azurerm_subnet.database.id
private_dns_zone_id = azurerm_private_dns_zone.postgres.id
administrator_login = "dbadmin"
administrator_password = var.db_password
depends_on = [azurerm_private_dns_zone_virtual_network_link.postgres]
}Gotcha: The depends_on for the DNS zone link is required. Without it, Terraform may try to create the database before the DNS link is ready, causing a cryptic error about DNS resolution.
Gotcha: A delegated subnet cannot be used by any other resource type. Plan subnet allocation accordingly.
Common Azure Terraform Gotchas#
| Gotcha | Symptom | Fix |
|---|---|---|
| Resource name globally unique | ConflictError on Storage Account or Key Vault |
Add random suffix or include subscription prefix |
| AKS MC_ resource group | Terraform wants to delete AKS-managed resources | Do not import or manage the MC_ resource group |
| Subnet delegation conflicts | Cannot create non-delegated resources in delegated subnet | Use separate subnets for delegated services |
| Private DNS zone link order | Database creation fails with DNS error | Add depends_on for the VNET link before the database |
| Key Vault RBAC timing | ForbiddenByRbac when creating secrets |
Add depends_on for role assignment before secrets |
| Azure CNI subnet sizing | AKS runs out of IPs | Size subnet for max_pods × max_nodes (each pod gets a VNET IP) |
| Provider registration | MissingSubscriptionRegistration on first use |
Run az provider register --namespace Microsoft.ContainerService |
| Terraform state and Azure locks | ScopeLocked when resource has Azure locks |
Remove Azure lock, apply, re-add lock (or use ignore_changes) |
| Soft-delete on Key Vault | Cannot recreate deleted Key Vault with same name | Purge the soft-deleted vault first: az keyvault purge --name X |
| NSG vs subnet association | NSG created but not associated with subnet | Explicitly create azurerm_subnet_network_security_group_association |