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