Azure Terraform Patterns Skill
Reusable infrastructure patterns for Azure Terraform templates. These patterns complement
the terraform-code-best-practices.instructions.md (style rules) and azure-defaults
skill (naming, tags, regions) with composable architecture building blocks.
Quick Reference
| Pattern | When to Use |
|---|---|
| Hub-Spoke Networking | Multi-workload environments with shared services |
| Private Endpoint Wiring | Any PaaS service requiring private connectivity |
| Diagnostic Settings | Every deployed resource (mandatory) |
| Conditional Deployment | Optional resources controlled by variables |
| Module Composition | Calling multiple AVM modules in the root module |
| Managed Identity Binding | Any service-to-service authentication |
| Plan Interpretation | Pre-deployment validation and change analysis |
Pattern 1 — Hub-Spoke Networking
Standard pattern using AVM-TF VNet module with peering:
# Hub VNet
module "hub_vnet" {
source = "Azure/avm-res-network-virtualnetwork/azurerm"
version = "~> 0.7"
name = "vnet-hub-${local.suffix}"
resource_group_name = azurerm_resource_group.hub.name
location = var.location
address_space = ["10.0.0.0/16"]
subnets = {
AzureFirewallSubnet = { address_prefixes = ["10.0.1.0/24"] }
GatewaySubnet = { address_prefixes = ["10.0.2.0/24"] }
}
tags = local.tags
}
# Spoke VNet
module "spoke_vnet" {
source = "Azure/avm-res-network-virtualnetwork/azurerm"
version = "~> 0.7"
name = "vnet-spoke-${var.workload}-${local.suffix}"
resource_group_name = azurerm_resource_group.spoke.name
location = var.location
address_space = [var.spoke_address_prefix]
peerings = {
to-hub = {
remote_virtual_network_resource_id = module.hub_vnet.resource_id
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = false
}
}
tags = local.tags
}
Key rules:
- Hub contains shared infrastructure (firewall, gateway, DNS)
- Spokes peer to hub — never to each other directly
- Use
module.hub_vnet.resource_idoutput to wire peering in spoke modules - Apply NSGs per subnet via the
subnetsmap, not per VNet
Pattern 2 — Private Endpoints
Standard three-resource pattern using AVM-TF private endpoint module:
# Private endpoint for a PaaS service
module "storage_private_endpoint" {
source = "Azure/avm-res-network-privateendpoint/azurerm"
version = "~> 0.1"
name = "pe-${local.st_name}-${local.suffix}"
resource_group_name = azurerm_resource_group.this.name
location = var.location
private_connection_resource_id = module.storage.resource_id
subnet_resource_id = module.spoke_vnet.subnets["PrivateEndpoints"].resource_id
private_dns_zone_group_name = "default"
private_dns_zone_resource_ids = [
azurerm_private_dns_zone.blob.id
]
subresource_names = ["blob"]
tags = local.tags
}
# Private DNS Zone (one per service type)
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.networking.name
tags = local.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
name = "pdnslink-blob-${local.suffix}"
resource_group_name = azurerm_resource_group.networking.name
private_dns_zone_name = azurerm_private_dns_zone.blob.name
virtual_network_id = module.spoke_vnet.resource_id
registration_enabled = false
tags = local.tags
}
Common subresource_names per service:
| Service | Subresource | Private DNS Zone |
|---|---|---|
| Storage (Blob) | blob |
privatelink.blob.core.windows.net |
| Storage (File) | file |
privatelink.file.core.windows.net |
| Key Vault | vault |
privatelink.vaultcore.azure.net |
| SQL Server | sqlServer |
privatelink.database.windows.net |
| Container Reg. | registry |
privatelink.azurecr.io |
| App Service | sites |
privatelink.azurewebsites.net |
| Service Bus | namespace |
privatelink.servicebus.windows.net |
| Cosmos DB | Sql |
privatelink.documents.azure.com |
Pattern 3 — Diagnostic Settings
Use the AVM-TF diagnostics module for every deployed resource. Pass the Log Analytics workspace ID via module outputs:
module "log_analytics" {
source = "Azure/avm-res-operationalinsights-workspace/azurerm"
version = "~> 0.4"
name = "log-${var.project}-${var.environment}-${local.suffix}"
resource_group_name = azurerm_resource_group.this.name
location = var.location
tags = local.tags
}
# Attach diagnostics to each resource — pass workspace ID as output
module "storage_diagnostics" {
source = "Azure/avm-res-insights-diagnosticsetting/azurerm"
version = "~> 0.1"
name = "diag-${local.st_name}"
target_resource_id = module.storage.resource_id
log_analytics_workspace_id = module.log_analytics.resource_id
log_analytics_destination_type = "Dedicated"
logs_destinations_ids = [module.log_analytics.resource_id]
}
Rule: Every resource in the deployment MUST have a diagnostic setting pointing to the central Log Analytics workspace.
Pattern 4 — Conditional Deployment
Use count for simple boolean toggles. Use for_each for named, keyed resources:
# Boolean toggle pattern
variable "deploy_bastion" {
description = "Deploy Azure Bastion host."
type = bool
default = false
}
resource "azurerm_bastion_host" "this" {
count = var.deploy_bastion ? 1 : 0
name = "bas-${var.project}-${var.environment}"
resource_group_name = azurerm_resource_group.this.name
location = var.location
ip_configuration {
name = "configuration"
subnet_id = module.spoke_vnet.subnets["AzureBastionSubnet"].resource_id
public_ip_address_id = azurerm_public_ip.bastion[0].id
}
tags = local.tags
}
# Referencing a conditional resource output safely
output "bastion_id" {
value = var.deploy_bastion ? azurerm_bastion_host.this[0].id : null
}
Use for_each over count whenever resources have distinct names to avoid
index-based drift when items are added or removed:
variable "storage_accounts" {
type = map(object({ sku = string }))
default = {
data = { sku = "Standard_LRS" }
backups = { sku = "Standard_GRS" }
}
}
resource "azurerm_storage_account" "this" {
for_each = var.storage_accounts
name = "st${each.key}${local.suffix}"
resource_group_name = azurerm_resource_group.this.name
location = var.location
account_tier = "Standard"
account_replication_type = each.value.sku
tags = local.tags
}
Pattern 5 — Module Composition
Root module wires multiple AVM child modules, passing outputs as inputs:
# main.tf — root module orchestration
module "resource_group" {
source = "Azure/avm-res-resources-resourcegroup/azurerm"
version = "~> 0.1"
name = "rg-${var.project}-${var.environment}"
location = var.location
tags = local.tags
}
module "key_vault" {
source = "Azure/avm-res-keyvault-vault/azurerm"
version = "~> 0.9"
name = local.kv_name
resource_group_name = module.resource_group.name # ← output from previous module
location = var.location
tenant_id = data.azurerm_client_config.current.tenant_id
tags = local.tags
}
module "app_service" {
source = "Azure/avm-res-web-site/azurerm"
version = "~> 0.13"
name = "app-${var.project}-${var.environment}-${local.suffix}"
resource_group_name = module.resource_group.name # ← shared output
location = var.location
service_plan_id = module.app_service_plan.resource_id # ← chained output
tags = local.tags
app_settings = {
KEY_VAULT_URI = module.key_vault.uri # ← chained output
}
}
Rules:
- Always pass resource IDs and names from module outputs, never hardcode
- Use
data.azurerm_client_config.currentfor tenant and client IDs - Chain outputs through locals when the same value is used 3+ times
Pattern 6 — Managed Identity
Use SystemAssigned managed identity + RBAC role assignments:
# Assign system identity to the app
resource "azurerm_linux_web_app" "this" {
name = "app-${var.project}-${var.environment}-${local.suffix}"
resource_group_name = azurerm_resource_group.this.name
location = var.location
service_plan_id = module.app_service_plan.resource_id
identity {
type = "SystemAssigned"
}
tags = local.tags
}
# Grant app access to Key Vault secrets
resource "azurerm_role_assignment" "app_kv_secrets" {
scope = module.key_vault.resource_id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_linux_web_app.this.identity[0].principal_id
}
# Grant app access to Storage Blob
resource "azurerm_role_assignment" "app_storage_blob" {
scope = module.storage.resource_id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_linux_web_app.this.identity[0].principal_id
}
Common role assignments:
| Service | Role |
|---|---|
| Key Vault | Key Vault Secrets User |
| Storage Blob | Storage Blob Data Contributor |
| Service Bus | Azure Service Bus Data Sender |
| Event Hub | Azure Event Hubs Data Sender |
| ACR | AcrPull |
Pattern 7 — Plan Interpretation
Reading terraform plan output to assess impact before applying:
# Generate a plan
terraform plan -out=plan.tfplan
# Human-readable summary
terraform show plan.tfplan
# Machine-readable JSON for analysis
terraform show -json plan.tfplan > plan.json
Change Type Symbols
| Symbol | Meaning | Action |
|---|---|---|
+ |
Create | New resource — safe |
- |
Destroy | Resource deleted — REVIEW before applying |
~ |
Update in-place | Attribute change — usually safe |
-/+ |
Destroy/Create | Replace — causes downtime for stateful resources |
<= |
Read | Data source refresh — non-destructive |
Red Flags in Plan Output
-/+on databases, Key Vaults, storage accounts — stateful, causes data risk- Large number of
~changes on Application Gateway / NSG — likely Set-type phantom diff (see pitfalls) destroyon resources withprevent_destroy = true— Terraform will error
Plan Summary Assessment
# Quick count of changes
terraform show -json plan.tfplan | \
python3 -c "
import json, sys
plan = json.load(sys.stdin)
changes = plan.get('resource_changes', [])
by_action = {}
for c in changes:
a = '+'.join(c['change']['actions'])
by_action[a] = by_action.get(a, 0) + 1
for k, v in sorted(by_action.items()): print(f'{k}: {v}')
"
Terraform AVM Known Pitfalls
Set-Type Attribute Phantom Diffs
AzureRM resources using Terraform's Set type (Application Gateway, Load Balancer,
NSG, Azure Firewall, Front Door) compare elements by hash rather than logical identity.
Adding or removing ONE element causes ALL elements to appear as changed.
Affected resources: azurerm_application_gateway, azurerm_lb,
azurerm_network_security_group, azurerm_firewall, azurerm_frontdoor
Detection: Plan shows many ~ changes after adding a single rule.
Mitigation:
# Use ignore_changes for set-type blocks when managed externally
lifecycle {
ignore_changes = [
backend_address_pool,
backend_http_settings,
http_listener,
request_routing_rule,
probe,
]
}
For full analysis, use the set-diff analyzer skill in docs/tf-support/SKILL.md.
Provider Version Constraint Pitfalls
# ❌ Too permissive — crosses breaking major versions
version = ">= 3.0"
# ❌ Too strict — blocks patch updates
version = "= 4.1.0"
# ✅ Correct — pins to azurerm 4.x, gets patch updates
version = "~> 4.0"
~> 4.0 allows 4.0.1, 4.1.0, 4.9.x but NOT 5.0.0.
~> 4.1 allows 4.1.0, 4.1.1 but NOT 4.2.0.
Ignore Changes for Externally-Managed Tags
Some Azure services (e.g., Azure Policy Modify) auto-inject tags at deployment.
Without ignore_changes, every terraform plan shows phantom tag diff:
resource "azurerm_resource_group" "this" {
# ...
lifecycle {
ignore_changes = [tags["DateCreated"], tags["auto-managed-tag"]]
}
}
for_each Over count for Named Resources
Using count for resources with distinct identities causes drift when items
are inserted or removed from the middle of a list (Terraform reindexes):
# ❌ count — deletes resource[1] and recreates resource[2] as resource[1]
resource "azurerm_subnet" "this" {
count = length(var.subnet_names)
name = var.subnet_names[count.index]
}
# ✅ for_each — stable key-based identity
resource "azurerm_subnet" "this" {
for_each = toset(var.subnet_names)
name = each.value
}
moved Block for Resource Renaming
Renaming a resource identifier without a moved block causes destroy + recreate:
# Old: resource "azurerm_key_vault" "main"
# New: resource "azurerm_key_vault" "this"
# Add moved block to prevent destroy/recreate
moved {
from = azurerm_key_vault.main
to = azurerm_key_vault.this
}
moved blocks can also handle module renames:
moved {
from = module.old_name
to = module.new_name
}
Remove moved blocks after the state migration is confirmed in all environments.
AVM Module enable_telemetry Default
AVM-TF modules deploy a null_resource for telemetry by default.
To disable in environments where outbound network is restricted:
module "key_vault" {
source = "Azure/avm-res-keyvault-vault/azurerm"
version = "~> 0.9"
enable_telemetry = false
# ...
}
azurerm 4.x Breaking Changes from 3.x
azurerm_storage_account:allow_blob_public_accessrenamed toallow_nested_items_to_be_publicazurerm_storage_account:enable_https_traffic_onlyrenamed tohttps_traffic_only_enabledazurerm_app_serviceandazurerm_function_appremoved — useazurerm_linux_web_app/azurerm_windows_web_appazurerm_sql_*resources largely replaced byazurerm_mssql_*
Always run terraform validate after upgrading the azurerm provider version.