---
title: "Azure Terraform Patterns: Resource Groups, AKS, Managed Identity, and Common Gotchas"
description: "Azure-specific Terraform patterns for the azurerm provider. Covers resource group organization, VNET networking, AKS with workload identity, Azure Database for PostgreSQL Flexible Server, managed identities, Key Vault integration, and Azure-specific gotchas that cause failures in plan and apply."
url: https://agent-zone.ai/knowledge/infrastructure/azure-terraform-patterns/
section: knowledge
date: 2026-02-22
categories: ["infrastructure"]
tags: ["terraform","azure","aks","resource-groups","managed-identity","workload-identity","key-vault","vnet","postgresql-flexible","gotchas"]
skills: ["azure-terraform","aks-setup","managed-identity-patterns","resource-group-design"]
tools: ["terraform","az"]
levels: ["intermediate"]
word_count: 1106
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/azure-terraform-patterns/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/azure-terraform-patterns/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Azure+Terraform+Patterns%3A+Resource+Groups%2C+AKS%2C+Managed+Identity%2C+and+Common+Gotchas
---


# 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

```hcl
# 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

```hcl
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

```hcl
# 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):

```hcl
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

```hcl
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:

```hcl
# 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` |

