Why I Don't Use AVM Modules As-Is (And What I Do Instead)

Why I Don't Use AVM Modules As-Is (And What I Do Instead)

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original deployment lockfiles are not available with this article, so its exact executed versions are not asserted. These excerpts are not a newly tested Azure deployment. Architecture diagrams, address ranges, resource names and security-control examples are illustrative; they do not describe an identifiable organization’s infrastructure.

AVM Is Great — But Not For Everything

Azure Verified Modules provide reusable Azure modules and interface specifications. Consuming them directly remains an option, including with Terragrunt.

Their broad reuse goals differ from the narrower interfaces I wanted for this landing zone. I chose custom modules to encode our naming, security and lifecycle decisions. That is a maintenance trade-off, not evidence that AVM cannot support the architecture.

I reused selected AVM conventions while developing 60+ custom modules for Azure Landing Zone use cases. Following those conventions does not make custom modules Azure Verified Modules, nor establish production validation of the whole catalogue.

The Terragrunt Problem

The original implementation used self-contained stack modules. Copying only a local module directory can leave sibling paths outside the downloaded source, but Terragrunt supports module composition.

The Terragrunt v1.2.0 source documentation explains that the package before // is downloaded, then the subdirectory after it is selected. With the repository as the package, relative sibling paths remain available. The original source configuration has not been recovered, so this is a packaging explanation, not a reproduced historical bug.

The following partial excerpt records the earlier inline design; it is not the current public KeyVaultStack implementation.

# Historical KeyVaultStack excerpt: resources were inlined by design.
# Current public KeyVaultStack composes sibling modules.
# Variables, locals and data sources are omitted here.

resource "azurerm_resource_group" "this" {
  name     = local.rg_name
  location = var.location
  tags     = local.common_tags
}

resource "azurerm_key_vault" "this" {
  name                = local.kv_name
  location            = var.location
  resource_group_name = azurerm_resource_group.this.name
  tenant_id           = coalesce(var.tenant_id, data.azurerm_client_config.current.tenant_id)
  sku_name            = var.sku_name

  purge_protection_enabled      = true
  public_network_access_enabled = false

  lifecycle {
    prevent_destroy = true
  }
}

resource "azurerm_private_endpoint" "this" {
  name                = local.pe_name
  location            = var.location
  resource_group_name = azurerm_resource_group.this.name
  subnet_id           = var.subnet_id

  private_service_connection {
    name                           = "psc-${local.pe_name}"
    private_connection_resource_id = azurerm_key_vault.this.id
    subresource_names              = ["vault"]
    is_manual_connection           = false
  }

  lifecycle {
    ignore_changes = [private_dns_zone_group]
  }
}

A repository source can preserve the siblings without flattening the modules:

terraform {
  source = "git::https://github.com/Kumo-Craft/terraform-azurerm-landing-zone.git//modules/KeyVaultStack?ref=658d78cfc98de1b685ae902fbf3ef02bc309255b"
}

This is a source-selection example, not a complete caller. The linked public stack composes KeyVault and PrivateEndpoint modules and expects the resource group from its caller; it differs from the historical inline stack above.

The 6 Patterns I Took From AVM

1. map(object) Instead of list(object)

Explicit map keys made resource identity easier to control. Terraform’s for_each rules, documented in v1.11.0, require known map keys or known set elements; values inside a map can still be unknown.

Neither list(object) nor map(object) alone guarantees this. A list can be transformed into a map keyed by known, unique names. A map whose keys depend on apply-time IDs still fails. Numeric index keys can be known, but are sensitive to reordering.

These are interface and caller excerpts, not one runnable file: variable blocks belong in the module, while dependency expressions belong in Terragrunt inputs. The resource examples below describe the historical AzureRM 4-style design, not an AzureRM 5 migration.

Before:

# Historical list interface: behavior depends on the for_each expression
variable "role_assignments" {
  type = list(object({
    scope                = string
    role_definition_name = string
    principal_id         = string
  }))
}

# Caller excerpt: an unknown scope is not itself an unknown instance key
role_assignments = [
  { scope = dependency.rg.outputs.id, role_definition_name = "Reader", principal_id = "..." }
]

After:

# Map interface: callers must still provide known instance keys
variable "role_assignments" {
  type = map(object({
    scope                      = string
    role_definition_id_or_name = string
    principal_id               = string
    principal_type             = optional(string)
    condition                  = optional(string)
    condition_version          = optional(string)
    description                = optional(string)
  }))
  default  = {}
  nullable = false
}

# Usage with map — key "rg_reader" is always known
role_assignments = {
  rg_reader = {
    scope                      = dependency.rg.outputs.id
    role_definition_id_or_name = "Reader"
    principal_id               = dependency.identity.outputs.principal_id
  }
}

The historical migration covered 12 modules and required caller changes. This is not a catalogue-wide rule: the public SubnetWithNsg module still accepts a list and derives instance keys from subnet names.

2. role_definition_id_or_name + strcontains()

AVM modules use a single role_definition_id_or_name field instead of two mutually exclusive fields (role_definition_id and role_definition_name). The module auto-detects which one you passed by checking if the value contains the Azure role definition resource path:

locals {
  role_definition_resource_substring = "/providers/Microsoft.Authorization/roleDefinitions"
}

resource "azurerm_role_assignment" "this" {
  for_each = var.role_assignments

  scope        = each.value.scope
  principal_id = each.value.principal_id

  # Auto-detect: is it a role ID or a role name?
  role_definition_id = strcontains(
    lower(each.value.role_definition_id_or_name),
    lower(local.role_definition_resource_substring)
  ) ? each.value.role_definition_id_or_name : null

  role_definition_name = strcontains(
    lower(each.value.role_definition_id_or_name),
    lower(local.role_definition_resource_substring)
  ) ? null : each.value.role_definition_id_or_name
}

One field. No “you set both” or “you set neither” errors. The consumer passes "Reader" or a full role definition ID — the module figures it out. In the historical implementation, this pattern was used in 7 modules: KeyVault, KeyVaultStack, ResourceGroup, RbacAssignments, Aks, ContainerRegistry, and StorageAccount.

3. lock Variable

Where implemented, the lock interface follows this shape: kind (required) and name (optional). This excerpt is not a guarantee of support in every module:

variable "lock" {
  type = object({
    kind = string
    name = optional(string)
  })
  default     = null
  description = "Management lock. kind = CanNotDelete or ReadOnly."

  validation {
    condition     = var.lock != null ? contains(["CanNotDelete", "ReadOnly"], var.lock.kind) : true
    error_message = "Lock kind must be CanNotDelete or ReadOnly."
  }
}

resource "azurerm_management_lock" "this" {
  count = var.lock != null ? 1 : 0

  lock_level = var.lock.kind
  name       = coalesce(var.lock.name, "lock-${var.lock.kind}")
  scope      = azurerm_resource_group.this.id
}

Two options: CanNotDelete (prevent accidental deletion) or ReadOnly (restrict control-plane modifications). The name auto-generates if you don’t provide one. The historical work added lock support to 8 modules: ResourceGroup, KeyVault, KeyVaultStack, Aks, ContainerRegistry, StorageAccount, Vnet, and RouteTable.

4. nullable = false on Required Vars

In Terraform, a variable with no default is required — but the caller can still pass null. Without nullable = false, that null propagates silently through your module until it hits a resource argument that can’t handle it, producing a cryptic error far from the source.

variable "location" {
  type        = string
  description = "Azure region"
  nullable    = false     # Rejects a known null input
}

variable "subscription_acronym" {
  type = string
  validation {
    condition     = can(regex("^[a-z]{2,5}$", var.subscription_acronym))
    error_message = "Must be 2-5 lowercase letters."
  }
}

variable "environment" {
  type = string
  validation {
    condition     = can(regex("^[a-z]{2,4}$", var.environment))
    error_message = "Must be 2-4 lowercase letters."
  }
}

For a known null input, nullable = false gives a direct variable diagnostic. I adopted it for inputs that must have a value; this is a design convention, not proof of coverage across every module or of early validation when values remain unknown.

5. Validation Blocks Everywhere

The examples use three categories of input validation:

  • Regex on naming vars — subscription_acronym must be 2-5 lowercase letters, environment must be 2-4 lowercase letters, region_code must be 2-5 lowercase letters. Catches typos before they become misnamed resources.
  • Contains for enums — SKU values, protocol types, access levels, lock kinds. No freeform strings where a finite set of values exists.
  • Cross-field validation — The most powerful pattern. Example from RouteTable: if next_hop_type is VirtualAppliance, then next_hop_in_ip_address is required. The excerpt below enforces that direction only; rejecting an IP for other next-hop types would require an additional rule.
variable "routes" {
  type = map(object({
    name                   = string
    address_prefix         = string
    next_hop_type          = string
    next_hop_in_ip_address = optional(string)
  }))
  default  = {}
  nullable = false

  # Enum validation
  validation {
    condition = alltrue([
      for r in var.routes : contains(
        ["VirtualNetworkGateway", "VnetLocal", "Internet", "VirtualAppliance", "None"],
        r.next_hop_type
      )
    ])
    error_message = "next_hop_type must be one of the allowed values."
  }

  # Cross-field validation
  validation {
    condition = alltrue([
      for r in var.routes :
      r.next_hop_type != "VirtualAppliance" || r.next_hop_in_ip_address != null
    ])
    error_message = "next_hop_in_ip_address is required when next_hop_type is VirtualAppliance."
  }
}

The goal is to catch invalid known inputs early. Unknown values can defer checks, and API constraints can still fail during apply. A successful plan is not proof that the deployment will succeed.

6. output “resource” (Complete Object)

Exposing the primary resource object is one output convention I used. It is not universal: public SubnetWithNsg exposes resources (plural) and subnet_ids. In this Key Vault excerpt, consumers can access attributes exported by the provider:

output "resource" {
  description = "The complete Key Vault resource object"
  value       = azurerm_key_vault.this
}

# Consumer can access attributes on the exported resource object:
# module.keyvault.resource.vault_uri
# module.keyvault.resource.tenant_id
# module.keyvault.resource.id

This eliminates “add output X” pull requests. If a consumer needs vault_uri, it’s already there. If they need tenant_id tomorrow, it’s already there. The individual convenience outputs (id, name) still exist for readability, but resource is the escape hatch that prevents module churn.

What I Didn’t Take From AVM

AVM defines several interface specifications that I deliberately chose not to implement:

  • Telemetry (enable_telemetry) — I omitted this interface from the historical internal modules; this is a design choice, not a claim about every AVM implementation.
  • Diagnostic settings interface — I chose a separate DiagnosticSettings module rather than embedding that interface in these resource modules. Decoupled, reusable, independently deployable.
  • Private endpoints interface — Same approach. A standalone PrivateEndpoint module rather than embedding PE configuration in every resource module. The historical KeyVaultStack inlined PEs by choice; its public successor composes sibling modules.
  • Managed identities interface — Separate ManagedIdentity module. Identity creation and role assignment are distinct lifecycle concerns from the resource itself.
  • Customer managed key interface — Inlined where needed (KeyVaultStack, PaloCluster) rather than standardized across all modules. Those were the two cases considered at that implementation stage, not a limit on which Azure services need CMK support.

My preference for these custom modules was to extract selected cross-cutting concerns into dedicated modules. Less duplication, clearer ownership, independent deployment.

The Security Defaults I Added

These are illustrative security choices, not an inventory of internal controls, a comparison proving superiority over AVM or a claim of uniform coverage of the public catalogue:

SettingIllustrative useQualification
prevent_destroy = trueSelected critical resourcesBlocks Terraform-planned destruction while the lifecycle rule remains configured
ignore_changes = [private_dns_zone_group]Endpoints whose DNS zone groups had a separate policy ownerReduces Terraform reconciliation; requires checking policy coverage
public_network_access_enabled = falseSupported services intended for private accessReview service-specific networking and exceptions
purge_protection_enabled = trueKey Vault protectionReview retention and key lifecycle requirements
rbac_authorization_enabled = trueKey Vault authorizationReflects the chosen IAM model
soft_delete_retention_days = 90Key Vault retentionA chosen retention period, not a universal compliance requirement

The Terraform v1.11.0 lifecycle reference explains the limits: prevent_destroy does not protect a resource after its configuration is removed or prevent deletion outside Terraform. ignore_changes delegates update responsibility; it does not ensure the ignored settings remain correct.

Was It Worth It?

Developing 60+ modules gave me a reusable structure and a clearer review vocabulary. A skeleton could be quick to create; tests, deployment verification and operational hardening remained separate work.

The historical list-to-map migration affected 12 modules and their callers. Stable keys addressed the specific identity problems described above, not every possible plan-time error. I am not treating an unversioned review summary as proof that the whole library is issue-free or production-validated.

The lesson

I used AVM specifications as design references for custom modules. AVM also provides reusable modules that can be consumed directly. Choose composition, interfaces and lifecycle ownership for the actual requirements, and verify each module revision before reuse.

For related public code, see the Kumo-Craft Terraform Azure module catalogue, to which I contribute. It is a public module catalogue, not an internal infrastructure configuration. The linked KeyVaultStack revision uses sibling modules and has evolved from the historical inline implementation shown in this article. Review its inputs and version requirements before reuse.

Related Posts

The AVM ALZ accelerator is solid. Here's what we add on top (and what we'd do differently)

The AVM ALZ accelerator is solid. Here's what we add on top (and what we'd do differently)

TL;DR. The official Azure/avm-ptn-alz/azurerm module covers the essentials of an Azure Landing Zone correctly. Three things aren't in the box and cost us time: explicit pinning of the alz/`a

read more
Building 60+ Terraform Modules for an Azure Landing Zone

Building 60+ Terraform Modules for an Azure Landing Zone

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original d

read more
The First Apply Is Local (and That's Fine)

The First Apply Is Local (and That's Fine)

TL;DR. The deployment chain that applies your IaC is itself infrastructure — and something has to create it first. Instead of hand-building the pipeline chain before the code could run, we appli

read more
The Second Landing Zone Is the Real Test of the First

The Second Landing Zone Is the Real Test of the First

TL;DR. We stamped out a second application landing zone from the first one. The diff between the two repos is six values: subscription, name acronym, address space, project, service connecti

read more
Running a 79-Module Terraform Library Like a Product

Running a 79-Module Terraform Library Like a Product

TL;DR. A shared Terraform module library has two possible futures: product or landfill. Ours holds 79 modules consumed live from main by four landing zones — no pinning. The only reason th

read more
When terraform plan says 'no changes' and the feature is broken anyway

When terraform plan says 'no changes' and the feature is broken anyway

Everything was green. Workspace deployed, host pool up, users assigned, RBAC clean. Then a user opens Windows App and hits: "No devices or apps found." The feed never loads. Nothing in the Azure

read more
Azure Virtual Desktop: from POC to prod, the hardening checklist

Azure Virtual Desktop: from POC to prod, the hardening checklist

TL;DR. A fully private AVD POC that works is not a prod. Between the two: harden the FSLogix storage (NTFS ACLs, backup), automate secret rotation, wire up observability (AVD Insights + dedicate

read more
Deploying Azure Virtual Desktop End-to-End in a Landing Zone

Deploying Azure Virtual Desktop End-to-End in a Landing Zone

TL;DR. This is the deployment walkthrough for a fully private, Entra-only Azure Virtual Desktop environment inside a regulated Azure Landing Zone. Pooled Windows 11 multi-session on a **cust

read more
Azure Virtual Desktop in a regulated Landing Zone: the traps no doc tells you about

Azure Virtual Desktop in a regulated Landing Zone: the traps no doc tells you about

TL;DR. Deploying Azure Virtual Desktop fully private in a Landing Zone (Private Endpoints everywhere, Palo Alto NVA for egress, centralised DNS) works very well — once you know about a dozen

read more
Azure Landing Zone with Palo Alto NVA: Lessons Learned

Azure Landing Zone with Palo Alto NVA: Lessons Learned

Deploying Palo Alto VM-Series as a Network Virtual Appliance in an Azure Landing Zone sounds straightforward — until you actually do it. This article covers the hard-won lessons from building the fire

read more
SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi

SubnetWithNsg: How Azure Policy Deny Forced Me to Use azapi

Technical revision — 23 October 2026. The original publication date is unchanged. Historical implementation examples are distinguished below from current, versioned documentation. The original d

read more