
Why I Don't Use AVM Modules As-Is (And What I Do Instead)
- Jonathan Aerts
- Landing Zone
- 08 Apr, 2026
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_acronymmust be 2-5 lowercase letters,environmentmust be 2-4 lowercase letters,region_codemust 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: ifnext_hop_typeisVirtualAppliance, thennext_hop_in_ip_addressis 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
DiagnosticSettingsmodule rather than embedding that interface in these resource modules. Decoupled, reusable, independently deployable. - Private endpoints interface — Same approach. A standalone
PrivateEndpointmodule rather than embedding PE configuration in every resource module. The historicalKeyVaultStackinlined PEs by choice; its public successor composes sibling modules. - Managed identities interface — Separate
ManagedIdentitymodule. 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:
| Setting | Illustrative use | Qualification |
|---|---|---|
prevent_destroy = true | Selected critical resources | Blocks Terraform-planned destruction while the lifecycle rule remains configured |
ignore_changes = [private_dns_zone_group] | Endpoints whose DNS zone groups had a separate policy owner | Reduces Terraform reconciliation; requires checking policy coverage |
public_network_access_enabled = false | Supported services intended for private access | Review service-specific networking and exceptions |
purge_protection_enabled = true | Key Vault protection | Review retention and key lifecycle requirements |
rbac_authorization_enabled = true | Key Vault authorization | Reflects the chosen IAM model |
soft_delete_retention_days = 90 | Key Vault retention | A 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.










