Skip to main content

Azure Landing Zones: From Concept to a Customized Terraform Deployment

What is an Azure landing zone?

An Azure landing zone is Microsoft’s answer to a question every organization runs into once they have more than a handful of subscriptions: how do you govern, secure, and scale Azure at that point without every team reinventing the same networking, identity, and policy decisions independently?

Per Microsoft’s own definition, a landing zone is built from two components:

  • Platform landing zone — the centralized foundation. Management group hierarchy, shared connectivity, identity, security monitoring, and management resources. Most organizations should have exactly one of these per Entra tenant.
  • Application (workload) landing zones — where individual teams actually deploy their workloads, operating inside the guardrails the platform landing zone established, without needing to re-solve governance themselves.

Both can be implemented two ways: Microsoft-provided accelerators (infrastructure as code following documented recommended practices), or a custom build. Accelerators are the faster path for most organizations, and it’s the one this post covers — specifically the official alz-terraform-accelerator.

Why the management group hierarchy matters

The management group hierarchy is the actual governance mechanism, not just an organizational nicety. Azure Policy assignments attach to management groups and inherit down through every subscription underneath — get the hierarchy right, and policy enforcement is consistent by construction. Get it wrong, and you’re reconciling drift by hand.

The hierarchy separates platform resources from workload resources, and workload subscriptions land in one of three places depending on their connectivity requirements: Corp (needs private connectivity back to on-prem/hub), Online (internet-facing, no private connectivity needed), or Local (isolated, workload-specific networking).

Hub-and-spoke vs. Virtual WAN

Microsoft’s reference architectures cover two networking topologies for the connectivity subscription: traditional hub-and-spoke (a hub VNet peered to spoke VNets) and Azure Virtual WAN (a managed backbone with virtual hubs, better suited to many regions or complex routing). Neither is universally “correct” — the accelerator supports both, toggled by which variables you populate.

Implementing it: the alz-terraform-accelerator

The official accelerator isn’t a single monolithic template — it’s a set of starter modules built on Azure Verified Modules (AVM), wired together through one templating layer so the rest of the config never touches raw variables directly.

Layout

The accelerator’s own files handle the platform landing zone itself, unmodified; everything below is what this deployment adds on top, to customize the hierarchy and add the Landing Zone A2 spoke:

.
├── main.tf / providers.tf / versions.tf          # (existing) wiring for the platform_landing_zone module
├── alz_custom.alz_architecture_definition.yaml   # Change intermediate root group to ALZ-Testing
├── variables.tf                                  # accelerator inputs (subscription_ids now also accepts "landingzone_a2")
├── terraform.tfvars                              # subscription IDs, root_parent_management_group_id
├── sandbox.tfvars                                # -var-file: management groups, policy, Virtual WAN hub
├── providers_landingzone_a2.tf                   # azurerm provider alias for the Landing zone A2 subscription
└── main_spoke_landingzone_a2_virtual_network.tf  # Hub VNet (Connectivity) + Spoke VNet (Landing zone A2) + peering

The full picture

Everything the tree above adds, assembled into one topology — management group hierarchy down through subscriptions down to the actual VNets and the peering between them:

Diagram of the full Azure landing zone structure: Tenant Root Group down through the ALZ-Testing management group hierarchy (Platform with Security/Management/Identity/Connectivity, Landing Zones with Corp/Online/Local, Decommissioned, Sandbox), each relevant branch mapped to its subscription, down to the Connectivity hub VNet peered with the Landing Zone A2 spoke VNet, with the Sandbox subscription shown as a hard-coded placeholder with no network resources deployed.

One connectivity pattern, not two: connectivity_type defaults to hub_and_spoke_vnet in the accelerator’s own variables, and nothing in terraform.tfvars or sandbox.tfvars overrides it to virtual_wan. The accelerator’s template ships code for both patterns, but only the hub-and-spoke path is actually active here — worth checking explicitly rather than assuming, since the unused code path is still sitting right there in the repo.

Provider and version wiring

terraform {
  required_version = "~> 1.12"
  required_providers {
    alz = {
      source  = "Azure/alz"
      version = "~> 0.21"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.0"
    }
  }
}

The alz provider is the piece that’s easy to miss coming from a plain-azurerm background — it’s what actually reads the architecture definition and policy library, separately from the azurerm provider that provisions the resources those definitions describe. Multiple azurerm provider aliases follow — one per platform subscription (management, connectivity), since a platform landing zone is inherently multi-subscription and each resource block needs to land in the right one.

One config module, everything else reads from it

module "config" {
  source = "./modules/config-templating"

  subscription_id_connectivity    = var.subscription_ids["connectivity"]
  subscription_id_management      = var.subscription_ids["management"]
  root_parent_management_group_id = var.root_parent_management_group_id

  inputs = {
    hub_and_spoke_networks_settings = var.hub_and_spoke_networks_settings
    management_group_settings       = var.management_group_settings
    # ...
  }
}

Every other module in the config — management groups, resource groups, hub-and-spoke networking — reads from module.config.outputs, not from var.* directly. That indirection is deliberate: it’s a single templating layer that substitutes subscription IDs and other environment-specific values into whatever’s defined in .tfvars, so the resource modules themselves stay identical between environments.

Customizing the architecture definition

The default accelerator ships a canonical management group hierarchy. This deployment overrides it with lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml:

name: alz_custom
management_groups:
  - id: alz
    display_name: ALZ-Testing
    archetypes: [root_custom]
    exists: false
    parent_id: null

  - id: platform
    display_name: Platform
    archetypes: [platform_custom]
    parent_id: alz

  - id: landingzones
    display_name: Landing Zones
    archetypes: [landing_zones_custom]
    parent_id: alz

  - id: corp
    display_name: Corp
    archetypes: [corp_custom]
    parent_id: landingzones
  # ...online, local, sandbox, decommissioned follow the same pattern

Two things this file actually changes: the root management group is renamed from the canonical hierarchy to ALZ-Testing (parent_id: null — sits directly under the Tenant Root Group), and every management group below it carries a _custom archetype suffix, meaning policy assignments come from this custom library rather than Microsoft’s defaults. Subscriptions get placed into that hierarchy separately, via subscription_placement in sandbox.tfvars:

subscription_placement = {
  connectivity = {
    subscription_id       = "$${subscription_id_connectivity}"
    management_group_name = "connectivity"
  }
  management = {
    subscription_id       = "$${subscription_id_management}"
    management_group_name = "management"
  }
  landingzone_a2 = {
    subscription_id       = "<change with correct subscription>"
    management_group_name = "corp"
  }
}

That third entry — landingzone_a2 → corp — is the customization this deployment adds beyond the accelerator defaults: a workload subscription placed under Corp, since it needs private connectivity back to the hub rather than sitting isolated.

Adding the Landing Zone A2 spoke

Checking the connectivity module’s own variables settles why this is hand-rolled rather than run through the accelerator: hub_and_spoke_networks_settings only models hubs — address space, firewall, bastion, gateways, DNS. There’s no “spoke” variable anywhere in it. That’s by design, not a gap — a platform team’s connectivity module builds the hub; each landing zone is expected to bring its own spoke VNet and peer it back, since the workload almost always lives in a different subscription than the one the connectivity module is scoped to.

That’s exactly what this file does: a plain azurerm_virtual_network for the Landing Zone A2 spoke, plus the peering back to the hub — not a workaround around the module, just the pattern it was built to expect:

# providers_landingzone_a2.tf
provider "azurerm" {
  resource_provider_registrations = "none"
  alias                           = "landingzone_a2"
  subscription_id                 = var.subscription_ids["landingzone_a2"]
  features {
    resource_group {
      prevent_deletion_if_contains_resources = false
    }
  }
}
# main_spoke_landingzone_a2_virtual_network.tf (peering, abbreviated)
resource "azurerm_virtual_network_peering" "hub_to_lza2" {
  provider                     = azurerm.connectivity
  name                         = "CNTY-AZR-PEER-Hub-to-LZA2"
  resource_group_name          = azurerm_resource_group.rg_hub_vnet.name
  virtual_network_name         = azurerm_virtual_network.vnet_hub.name
  remote_virtual_network_id    = azurerm_virtual_network.vnet_lza2.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  allow_gateway_transit        = false
  use_remote_gateways          = false
}

resource "azurerm_virtual_network_peering" "lza2_to_hub" {
  provider                     = azurerm.landingzone_a2
  name                         = "LZA2-AZR-PEER-Spoke-to-Hub"
  resource_group_name          = azurerm_resource_group.rg_lza2_vnet.name
  virtual_network_name         = azurerm_virtual_network.vnet_lza2.name
  remote_virtual_network_id    = azurerm_virtual_network.vnet_hub.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  allow_gateway_transit        = false
  use_remote_gateways          = false
}

Diagram of the hub-and-spoke peering: Connectivity subscription hub VNet (10.10.0.0/24) bidirectionally peered to the Landing Zone A2 spoke VNet (10.20.0.0/22) in the Corp-placed subscription.

Peering is inherently bidirectional at the API level — Azure doesn’t infer the reverse direction from one resource, so both blocks are required, one issued from each subscription’s provider alias. allow_gateway_transit and use_remote_gateways are both false here on purpose: this spoke isn’t routing through a gateway in either direction yet, just establishing reachability between the two VNets directly.

Verifying the deployment

Everything above is what the Terraform says it’ll do. Worth actually checking the Azure portal after apply rather than trusting the plan output alone — a few screenshots from the real deployment, cross-checked against every claim made above.

Virtual networks — exactly two, nothing else. No stray Virtual WAN hub sitting around from that unused code path:

Screenshot: Azure portal Virtual networks list showing exactly two VNets — CNTY-AZR-VNET-Hub in the Connectivity subscription and LZA2-AZR-VNET-Spoke in the Landing zone A2 subscription.

The peering — connected and fully synchronized, matching the resource name from main_spoke_landingzone_a2_virtual_network.tf exactly:

Screenshot: CNTY-AZR-VNET-Hub Peerings blade showing one peering, CNTY-AZR-PEER-Hub-to-LZA2, status Fully Synchronized and Connected, remote virtual network LZA2-AZR-VNET-Spoke.

The management group hierarchy — matches the diagram earlier in this post almost exactly: ALZ-Testing directly under the Tenant Root Group, Platform/Landing Zones/Sandbox/ Decommissioned branching from it, Corp (1 subscription) under Landing Zones:

Screenshot: Azure Resource Manager Management groups view showing Tenant Root Group containing ALZ-Testing, which branches into Decommissioned, Landing Zones (containing Corp with 1 subscription — Landing zone A2), Local, Online, Platform, and Sandbox.

And drilling into Platform confirms Connectivity and Management each hold exactly the one subscription they’re supposed to, while Identity, Security, and Sandbox sit at zero — reserved, matching the “hard-coded, unused” note from earlier:

Screenshot: Platform management group expanded, showing Connectivity (1 subscription) and Management (1 subscription) as child management groups, with Identity and Security at 0 subscriptions.

Resource groups — the two this deployment explicitly created, plus something worth knowing about rather than being confused by later: NetworkWatcherRG shows up automatically in each subscription with network resources in it. Azure creates that one on its own the first time a VNet appears in a subscription — it’s not something this Terraform declared, and it’s normal to see it appear unprompted:

Screenshot: Resource groups list showing CNTY-AZR-RG-Hub-VNet and LZA2-AZR-RG-Spoke-VNet, plus two auto-created NetworkWatcherRG resource groups, one per subscription.

Every claim in this post checks out against what’s actually running — which is really the whole point of writing posts like this alongside the Terraform itself: it’s easy to describe an architecture confidently and be wrong about some detail that only shows up once it’s real.

Accelerator modules vs. hand-rolled resources

The pattern worth taking away from this isn’t really about Azure specifically — it’s a general shape for extending any opinionated accelerator: use the accelerator’s own variables for anything it already models (management groups, policy, the hub itself), and reach for plain resources for whatever sits outside the module’s actual scope — not because the module conflicts with it, but because it was never meant to cover it. The comment left in main_spoke_landingzone_a2_virtual_network.tf explaining why it’s hand-rolled instead of going through the module is doing as much work as the resource blocks themselves — six months from now, that’s the difference between “obviously intentional” and “why isn’t this using the module like everything else.”