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 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.

Why Build From Scratch?

When developing reusable Azure Landing Zone modules, the obvious question was: why not just use the Azure Verified Modules (AVM) directly?

Three considerations shaped the implementation at that stage:

  • Source packaging — I originally inlined resources in stack modules. Terragrunt can compose sibling modules when the repository is downloaded as a package and // selects its subdirectory, as documented in Terragrunt v1.2.0. This was a packaging/design choice, not a general Terragrunt limitation; the exact original source configuration is not available here.
  • Azure Policy Deny on applicable subnets without an NSG — In this example, the two-resource association pattern conflicts with that policy. The historical AzAPI approach includes an existing NSG reference in the subnet creation request. AzureRM 5 now offers another approach, described below.
  • Focused interfaces — I wanted defaults and lifecycle boundaries tailored to this environment. That preference does not establish that AVM modules were unsuitable or less maintainable in general.

I developed 60+ custom modules using selected AVM conventions. AVM modules can also be consumed directly; adopting conventions does not confer AVM verification on custom code.

The Architecture

The following is a fictional teaching scenario, not a description of a customer network. Two independent demonstration VNets use distinct private ranges:

  • Demo A: 172.20.0.0/16.
  • Demo B: 172.21.0.0/16.
  • Logical separation: platform services and sample workloads can have separate deployment scopes; no real subscription count, names or workload inventory is represented here.

In this illustrative hub-and-spoke pattern, a spoke default route (0.0.0.0/0) points to a firewall appliance through an internal load balancer. Private endpoints and policy-managed DNS illustrate service-access options, not an organization’s deployed controls. Distinct CIDRs alone do not establish isolation.

Illustrative Architecture Pattern

Spoke VNet → UDR (0.0.0.0/0) → internal load balancer → firewall appliance → configured egress path

War Stories

1. Azure Policy Forced Me to Use azapi

In the illustrative scenario, assume an assigned policy denies applicable subnet requests without an NSG. In the historical two-resource pattern, the first request omitted that reference. A Resource Manager-mode Deny effect rejects the request before it reaches the resource provider: the subnet is not created and the subsequent association cannot run.

The historical fix used AzAPI to include the existing NSG reference in one subnet create/update request. This partial excerpt omits input declarations and dependency creation; it follows the ARM API 2025-03-01 schema:

resource "azapi_resource" "subnet" {
  for_each = { for s in var.subnets : s.name => s }

  type      = "Microsoft.Network/virtualNetworks/subnets@2025-03-01"
  name      = each.value.name
  parent_id = var.virtual_network_id

  body = {
    properties = {
      addressPrefixes = each.value.address_prefixes
      networkSecurityGroup = {
        id = each.value.nsg_id
      }
      routeTable = each.value.route_table_id != null ? {
        id = each.value.route_table_id
      } : null
    }
  }
}

The SubnetWithNsg module wraps this pattern for the subnets covered by the implementation. This is not an atomic transaction creating both the NSG and subnet; reads, polling and dependencies can require other requests.

October 2026 update: AzureRM 5.0.0 (27 July 2026) added network_security_group_id_wo / network_security_group_id_wo_version and equivalent route-table fields. The v5.10.0 documentation targets this policy-constrained scenario. Write-only arguments require Terraform 1.11+, while consumed modules may have stricter constraints. The linked public SubnetWithNsg revision still declares Terraform >= 1.12.0, AzureRM ~> 4.0 and AzAPI ~> 2.4; it remains an AzAPI implementation. See the detailed subnet article for the example and lifecycle caveats. No provider upgrade or state migration is implied here.

2. Historical Workaround: Private Key Vault KMS and API Server VNet Integration

The historical workaround illustrated here uses a post-creation az aks update step for KMS and API Server VNet Integration; this does not identify an organization using that configuration. AzureRM issue #27640 reports missing API Server VNet Integration support with v4.4.0 and is now closed. It does not prove that all KMS configurations fail throughout AzureRM v4, or that closure resolves this exact historical combination. The original Terraform, provider, AKS and CLI lock/version evidence is not available with this article.

This historical lifecycle excerpt avoided Terraform reconciling fields managed by the CLI step, at the cost of reduced drift management:

lifecycle {
  ignore_changes = [
    api_server_access_profile,
    key_management_service,
  ]
}

As explained in Terraform v1.11.0’s lifecycle documentation, ignored fields are excluded from update reconciliation. A clean plan does not verify remote KMS behavior. A separate owner and operational checks are needed for externally managed configuration; this excerpt is not a recommendation to retain the workaround on every current version.

Microsoft now distinguishes the legacy KMS experience (versioned documentation) from the new KMS data encryption experience (versioned documentation). The new experience requires AKS 1.33+, Azure CLI 2.73.0+ and aks-preview 19.0.0b13+. The current Microsoft guide, checked on 23 October 2026, still labels it Preview. That is not an automatic production migration recommendation. Reassess the actual version tuple and supported configuration in an isolated environment first.

3. The Bootstrap That Silently Failed

Palo Alto VM-Series firewalls bootstrap their configuration from an Azure Storage File Share. This illustrative custom_data payload is a string, not HCL; the account name, key and share name below are placeholders:

storage-account=mystorageacct;access-key=xxx;file-share=bootstrap

My initial implementation passed the ARM resource ID (/subscriptions/.../storageAccounts/mystorageacct) instead of the account name. In that test, the firewall booted without the intended configuration and appeared healthy despite the missing policies. This observation does not establish the behavior of every PAN-OS version.

Caught it during testing. Fixed the variable from bootstrap_storage_account_id to bootstrap_storage_account_name. Also switched the separator from \n to ; to match the official Palo Alto reference architecture.

4. Throughput Testing: Accelerated Networking

After deploying the Palo Alto cluster, throughput tests were disappointing. The fix: one line of code.

accelerated_networking_enabled = true  # on untrust + trust NICs

In the illustrative configuration, accelerated networking is enabled on trust and untrust interfaces and disabled on management. The original benchmark and full VM/PAN-OS version tuple are not published here, so this article does not assert a reproducible percentage gain or prescribe the setting for every deployment.

AVM Patterns That Actually Matter

These are selected patterns adopted during the module work, not a claim that every public module implements each convention:

map(object) instead of list(object)

Explicit stable keys helped control resource identity. As Terraform v1.11.0 documents, for_each needs known map keys or set elements. A map is not automatically safe if its keys depend on unknown values; a list can be transformed into a map using known, unique names. These caller excerpts show the interface change, not the omitted resource iteration:

# Before: list interface (unknown scope alone does not imply failure)
role_assignments = [
  { scope = dependency.rg.outputs.id, role_definition_name = "Reader" }
]

The alternative caller interface (not a second assignment in the same file):

# After (key "rg_reader" is always known)
role_assignments = {
  rg_reader = {
    role_definition_id_or_name = "Reader"
    scope                      = dependency.rg.outputs.id
  }
}

role_definition_id_or_name + strcontains()

The historical interface accepted a role ID or name and selected the corresponding provider argument. These AzureRM 4-style arguments belong inside an azurerm_role_assignment resource with for_each, scope and principal_id; those surrounding declarations are omitted. This is not a standalone configuration or an AzureRM 5 compatibility claim:

role_definition_id = strcontains(
  lower(each.value.role_definition_id_or_name),
  lower("/providers/Microsoft.Authorization/roleDefinitions")
) ? each.value.role_definition_id_or_name : null

role_definition_name = strcontains(
  lower(each.value.role_definition_id_or_name),
  lower("/providers/Microsoft.Authorization/roleDefinitions")
) ? null : each.value.role_definition_id_or_name

One field instead of two mutually exclusive ones. Simpler interface, fewer validation errors.

prevent_destroy on everything that matters

Examples of resources for which a design may use lifecycle { prevent_destroy = true } include the following; this is not a deployment inventory:

  • Key Vaults (secrets, CMK keys)
  • AKS clusters (workloads, persistent volumes — hours to recreate)
  • Container Registry (all container images)
  • DDoS Protection Plan
  • Storage Accounts (state files, backups, FinOps data)
  • Palo Alto VMs + their KV + encryption keys

This rule only blocks destruction planned by Terraform while it remains configured. Removing the resource block removes that protection; it does not prevent external deletion.

Private Endpoint lifecycle ignore

For an illustrative endpoint whose DNS zone group is owned by policy, the lifecycle pattern is:

lifecycle {
  ignore_changes = [private_dns_zone_group]
}

A matching, assigned DeployIfNotExists policy can own the DNS zone group for supported endpoint types. Verify actual coverage and remediation rather than assuming it applies to every endpoint. Ignoring the field avoids competing updates but also reduces Terraform drift management; without another effective owner, configuration can remain missing or incorrect.

The Result

This article documents an implementation stage and the engineering decisions behind it. Developing 60+ reusable modules, validating configuration, testing selected components, deploying them and completing platform hardening are different milestones. The bootstrap and networking observations above are specific implementation experiences, not evidence that every module passed tests or that the entire landing zone is complete.

The public catalogue provides code and CI evidence to inspect at a specific revision. It does not establish production validation of every module, completion of industrialization, or closure of all security findings. Those claims would require scoped test results, deployment evidence and a versioned review.

For related public code, see the Kumo-Craft Terraform Azure module catalogue, to which I contribute, including the inspected revisions of SubnetWithNsg and Aks. These are public module implementations, not a customer landing zone configuration or a snapshot of an internal deployment. Review their inputs and version requirements before reuse.

The work addressed real enterprise requirements. This account distinguishes that engineering context from independently verifiable evidence of production readiness.

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
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
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 d

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