Azure Lesson 91 of 137

Designing an Azure Landing Zone with the Cloud Adoption Framework

In a nutshell

Picture a property developer building a planned neighbourhood. Before a single house goes up, they pour the foundations, run the water and power, lay the roads, install the street lighting, and agree the building codes. Only then do individual builders arrive and put houses on their plots — fast, because the hard shared work is already done and nobody has to re-negotiate where the sewage pipe goes.

An Azure landing zone is exactly that, for cloud. Before any application team deploys a workload, the platform team lays the shared foundations — management groups (how the estate is zoned), Azure Policy (the building codes), networking (the roads and utilities), and identity (who is allowed onto which plot). Application teams then “land” their workloads in a pre-governed, pre-connected environment and move fast without re-litigating security, networking, or compliance on every project. Microsoft’s Cloud Adoption Framework (CAF) is the playbook for the whole neighbourhood; the Azure landing zone is the reference blueprint you build from it.

The pay-off is inheritance. Set a rule once at the top — “no public IP addresses in the internal zone”, “every resource must carry a cost-centre tag” — and every current and future subscription beneath it obeys automatically. That is the difference between governance you enforce and governance you merely hope for.

Level: Advanced · Time: ~23 min

Prerequisites — you’ll get the most from this if you already understand Azure subscriptions and resource groups, virtual networks and subnets, and Azure RBAC role assignments. If VNet peering or NSGs are still new, read the virtual network basics lesson first.

After this lesson you will be able to:

A landing zone is the pre-provisioned, governed environment your workloads land in — networking, identity, policy, and management already wired up so application teams move fast without re-litigating security on every project. Microsoft’s Cloud Adoption Framework (CAF) codifies this into the Azure landing zone architecture. This guide builds one the way it’s done in regulated enterprises.

Azure hub-and-spoke landing zone

The eight design areas

Every enterprise-scale landing zone is a set of decisions across eight areas. Get these right and the rest is implementation detail:

  1. Azure billing & Entra tenant — enrollment, tenant topology.
  2. Identity & access management — Entra ID, RBAC, PIM.
  3. Resource organization — management groups & subscriptions.
  4. Network topology & connectivity — hub-spoke or Virtual WAN.
  5. Security — Defender for Cloud, encryption, secrets.
  6. Management — monitoring, backup, update management.
  7. Governance — Azure Policy, cost controls.
  8. Platform automation & DevOps — IaC, pipelines, CI/CD.

Think of the eight areas as three layers stacked on each other. The root (billing/tenant, identity) is what everything else inherits — get the tenant and identity model wrong and every layer above inherits the mistake. The structure (resource organization, network, security) is what most of this lesson builds. The operating model (management, governance, platform automation) is what keeps the whole thing healthy on day 2 and beyond.

Design area The decision you’re really making Implemented with
Billing & tenant One tenant or many? EA/MCA enrollment shape Entra tenant, billing account roles
Identity & access Cloud-only, hybrid, or synced? Who is privileged? Entra ID, RBAC, PIM, Conditional Access
Resource organization How do policy and RBAC inherit? Management groups, subscriptions
Network topology Regional hub-spoke or global Virtual WAN? VNets, firewall, gateways, DNS
Security How do you detect and prevent? Defender for Cloud, encryption, Key Vault
Management How do you monitor, back up, patch? Log Analytics, Azure Monitor, Backup
Governance How do you stay compliant? Azure Policy, budgets, tags
Platform automation How is all of the above delivered? IaC, pipelines, workload identity

A subtlety beginners miss: these areas are not sequential steps you finish and forget. They are continuous concerns. You revisit network topology when a new region opens, identity when an acquisition arrives, and governance every time a new compliance regime lands. A landing zone is a living platform, not a one-off project.

Step 1 — Management group hierarchy

Subscriptions are the unit of scale; management groups are how you apply policy and RBAC once and inherit everywhere. The CAF reference hierarchy:

Tenant Root Group
└── Contoso (top-level MG)
    ├── Platform
    │   ├── Identity        (domain controllers, Entra Connect)
    │   ├── Management      (Log Analytics, automation)
    │   └── Connectivity    (hub VNet, firewall, DNS)
    ├── Landing Zones
    │   ├── Corp            (internal, no public ingress)
    │   └── Online          (internet-facing)
    ├── Decommissioned
    └── Sandbox

Create it with Bicep at the tenant scope:

targetScope = 'managementGroup'

param topLevelMgId string = 'contoso'

resource platform 'Microsoft.Management/managementGroups@2023-04-01' = {
  name: 'platform'
  properties: {
    displayName: 'Platform'
    details: { parent: { id: tenantResourceId('Microsoft.Management/managementGroups', topLevelMgId) } }
  }
}

Why this matters: policies assigned at Landing Zones (e.g. “deny public IPs on NICs”, “require tags”) flow to every current and future subscription beneath it. New teams inherit guardrails on day one.

A few rules that save real pain:

The same hierarchy in the Azure CLI, one node at a time:

# Create the top-level MG under the Tenant Root Group, then children
az account management-group create --name contoso --display-name "Contoso"
az account management-group create --name platform --display-name "Platform" --parent contoso
az account management-group create --name landingzones --display-name "Landing Zones" --parent contoso

Moving a subscription into a management group re-parents its inheritance instantly — it picks up every policy and role assignment on the new branch:

az account management-group subscription add --name landingzones --subscription <SUBSCRIPTION_ID>

Step 2 — Subscription democratization

Hand each workload (or environment) its own subscription. Subscriptions are a scale unit and a billing/blast-radius boundary — not something to hoard. A typical split:

Subscription Lives under Purpose
sub-connectivity Platform/Connectivity Hub VNet, Firewall, ExpressRoute
sub-management Platform/Management Log Analytics, Automation, backup vault
sub-identity Platform/Identity Domain controllers, Entra Connect
sub-prod-corp Landing Zones/Corp Production workloads, no public ingress
sub-prod-online Landing Zones/Online Internet-facing apps

Why a subscription and not just another resource group? A subscription is Azure’s real unit of scale, isolation, and blast radius:

The trade-off is sprawl — dozens or hundreds of subscriptions is normal at enterprise scale, which is exactly why you never hand-create them. You vend them from a template (see Going deeper). A common convention is one subscription per workload × environmentsub-payments-prod, sub-payments-nonprod — each landing in the management group whose guardrails it needs.

Step 3 — Hub-and-spoke networking

The hub holds shared network services (firewall, gateways, DNS, Bastion). Spokes hold workloads and peer to the hub. All spoke-to-spoke and spoke-to-internet traffic is forced through the firewall.

Terraform for the hub VNet + Azure Firewall:

resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub-eus"
  resource_group_name = azurerm_resource_group.connectivity.name
  location            = "eastus"
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_subnet" "firewall" {
  name                 = "AzureFirewallSubnet" # name is mandatory & exact
  resource_group_name  = azurerm_resource_group.connectivity.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.10.1.0/26"]
}

resource "azurerm_firewall" "hub" {
  name                = "afw-hub-eus"
  resource_group_name = azurerm_resource_group.connectivity.name
  location            = "eastus"
  sku_name            = "AZFW_VNet"
  sku_tier            = "Standard"
  ip_configuration {
    name                 = "ipc"
    subnet_id            = azurerm_subnet.firewall.id
    public_ip_address_id = azurerm_public_ip.fw.id
  }
}

Peer a spoke to the hub (both directions, with gateway transit so spokes use the hub’s VPN/ER gateway):

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                         = "prod-to-hub"
  resource_group_name          = azurerm_resource_group.prod.name
  virtual_network_name         = azurerm_virtual_network.prod.name
  remote_virtual_network_id    = azurerm_virtual_network.hub.id
  allow_forwarded_traffic      = true
  use_remote_gateways          = true
}

Then force spoke egress through the firewall with a route table (UDR) whose default route points at the firewall’s private IP:

resource "azurerm_route" "default_to_fw" {
  name                   = "default-via-firewall"
  route_table_name       = azurerm_route_table.spoke.name
  resource_group_name    = azurerm_resource_group.prod.name
  address_prefix         = "0.0.0.0/0"
  next_hop_type          = "VirtualAppliance"
  next_hop_in_ip_address = azurerm_firewall.hub.ip_configuration[0].private_ip_address
}

The single most important networking fact for a landing zone: VNet peering is not transitive. If spoke A peers with the hub and spoke B peers with the hub, spoke A still cannot reach spoke B on its own. That is a feature — it forces east-west traffic through the hub firewall where you can inspect and log it. To actually allow (and inspect) spoke-to-spoke traffic you point both spokes’ 0.0.0.0/0 route at the firewall via UDR, and the firewall makes the allow/deny decision.

The peering above is only the spoke side. For spokes to use the hub’s VPN/ExpressRoute gateway, the hub side must offer gateway transit:

resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "hub-to-prod"
  resource_group_name       = azurerm_resource_group.connectivity.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.prod.id
  allow_forwarded_traffic   = true
  allow_gateway_transit     = true # hub offers its gateway…
}
# …and the spoke sets use_remote_gateways = true (shown above) to consume it.

Peering must be created on both sides — it is never implied. And keep the layers straight: NSGs are stateful, per-subnet/per-NIC allow-deny lists (cheap, distributed), while Azure Firewall is the centralised, FQDN-aware, logged choke point. You use both — NSGs for micro-segmentation inside a spoke, the firewall for everything crossing the hub.

Step 4 — Governance with Azure Policy

Policy is how a landing zone stays compliant. Assign initiatives (policy sets) at the management-group scope. Three you almost always want:

resource denyPublicIp 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
  name: 'deny-public-ip'
  scope: managementGroup()
  properties: {
    policyDefinitionId: tenantResourceId(
      'Microsoft.Authorization/policyDefinitions',
      '83a86a26-fd1f-447c-b59d-e51f44264114') // built-in: not allow public IP on NIC
    enforcementMode: 'Default'
  }
}

Policy has more effects than Deny, and a landing zone uses most of them:

Effect What it does Typical landing-zone use
Deny Blocks the create/update request outright No public IP on NICs in Corp
Audit Allows it, flags non-compliance Report resources missing tags
Append / Modify Adds or changes properties on write Force a tag value, add TLS settings
DeployIfNotExists (DINE) Deploys a remediation resource after the fact Onboard new VMs to Log Analytics / Defender
AuditIfNotExists Flags when a related resource is missing VM without a backup
Disabled Turns the policy off for staged testing Phased rollout

Two operational must-knows. First, DeployIfNotExists and Modify run under a managed identity that Azure creates for the assignment — and that identity needs an explicit role at the remediation scope, or the remediation silently fails with an authorization error. Second, always assign a new initiative with enforcementMode: 'DoNotEnforce' (compliance-only) first, read the results, and only then flip enforcement on. When one resource legitimately must break a rule, use a policy exemption scoped to that resource or resource group rather than weakening the policy for everyone.

Step 5 — Identity & least privilege

Three additions separate a real landing zone from a demo:

Scope discipline is the whole game: a role assignment at a management group is inherited by every subscription beneath it. Owner at the top-level MG is Owner on the entire estate. Grant the narrowest role at the narrowest scope that still gets the job done.

Step 6 — Platform automation

Everything above is IaC. Structure it so the platform team owns the hierarchy, policy, and hub, while application teams own their spokes:

platform/        # MG hierarchy, policy, hub network, Log Analytics  (platform team)
landing-zones/   # per-workload spoke modules                         (app teams, via PR)
modules/         # shared, versioned modules (network, monitoring)

Deploy through pipelines with a service principal (or workload identity federation) that has Owner on the relevant management group — gated behind PR review and a plan approval.

The split above is also a state and blast-radius boundary. Give the platform stack its own remote state (its own storage account + container) and the application spokes theirs, so an app team’s terraform apply can never touch — or corrupt the state of — the hub, the policies, or the MG hierarchy. Application changes arrive by pull request against their own spoke module; the platform team reviews and merges.

A representative deployment pipeline gates every change behind a plan and a manual approval:

# azure-pipelines.yml — platform landing-zone deployment (representative)
trigger:
  branches:
    include: [ main ]
stages:
  - stage: validate
    jobs:
      - job: plan
        steps:
          - script: terraform plan -out=tfplan
            displayName: Terraform plan
  - stage: apply
    dependsOn: validate
    condition: succeeded()
    jobs:
      - deployment: apply
        environment: platform-prod # protected: requires manual approval
        strategy:
          runOnce:
            deploy:
              steps:
                - script: terraform apply tfplan
                  displayName: Terraform apply

The environment: platform-prod is the control point — Azure DevOps environments (and GitHub environments) let you attach a required-reviewer check, so no landing-zone change reaches production without a human approving the exact plan.

Enterprise scenario

A financial-services client deployed the CAF reference hierarchy with the standard Deny-PublicIP and Deny-PublicEndpoint initiatives at the Corp management group, then ran a wave of platform-team Bicep deployments to stand up shared services. Half the deployments failed at the DeployIfNotExists remediation step. The deny policy was blocking the very Private Endpoints that other DINE policies were trying to create for Key Vault and Storage — and DINE remediation tasks run under a managed identity that the deny effect evaluates like any other principal. The result was a deadlock: the platform couldn’t bootstrap its own private connectivity under its own guardrails.

The fix was not to weaken the deny policy but to scope it correctly. Private Endpoints are not public endpoints, so the team narrowed the deny rule with a notIn exclusion on the platform resource group and assigned the remediation identity an explicit role at the right scope:

resource remediation 'Microsoft.PolicyInsights/remediations@2021-10-01' = {
  name: 'remediate-pe-storage'
  scope: subscription()
  properties: {
    policyAssignmentId: deployPrivateEndpoint.id
    resourceDiscoveryMode: 'ReEvaluateCompliance'
    failureThreshold: { percentage: 10 }
  }
}

The broader lesson: order policy effects deliberately. Deny evaluates before DeployIfNotExists, so a deny that’s too broad silently starves your auto-remediation. Always dry-run new initiatives in enforcementMode: 'DoNotEnforce' against the platform subscriptions first, read the compliance results, then flip enforcement on.

Going deeper

Platform vs application landing zones

The phrase “landing zone” gets used for two different things, and mixing them up causes real confusion. Platform landing zones are the shared-service subscriptions the platform team owns — connectivity (the hub), management (Log Analytics, automation), and identity. Application landing zones are the subscriptions handed to workload teams to land in — a spoke, pre-wired to the hub and pre-governed by inherited policy. The platform landing zone is built once, carefully. Application landing zones are mass-produced. Everything under “subscription vending” below is about mass-producing application landing zones safely.

Hub-spoke vs Virtual WAN

Classic hub-and-spoke means you build and own the hub VNet, the firewall, the gateways, and every peering — maximum control, maximum operational surface. Azure Virtual WAN (vWAN) is the managed alternative: Microsoft runs the hub as a service, you attach spokes and branches, and it handles transit routing — including spoke-to-spoke, which classic peering cannot do without your UDRs.

Hub-spoke (self-managed) Virtual WAN
Hub ownership You build and operate it Managed by Azure
Transit routing Manual (UDRs, NVAs) Built in
Global reach You interconnect regions yourself Native any-to-any across regions
Best when One or a few regions, deep control needed Many regions/branches, SD-WAN, scale

Rule of thumb: a handful of regions and a strong network team → hub-spoke gives you control. Many regions, lots of on-prem branches, or a global mesh → vWAN removes a mountain of routing toil. The network landing zone lesson works this decision in depth.

The ALZ accelerator, caf-enterprise-scale, and Azure Verified Modules

You have three honest options for building all of this as code, in rising order of “batteries included”:

  1. Hand-rolled IaC (what the snippets in this lesson show). Total control, total responsibility. Great for learning and for genuinely bespoke estates; a lot to maintain.
  2. The Azure/caf-enterprise-scale/azurerm module (a.k.a. Enterprise-Scale). One large, opinionated Terraform module that deploys the whole CAF reference — MG hierarchy, a big library of built-in policy assignments, and the platform resources — driven by configuration. Fast and battle-tested, but you inherit its opinions.
  3. The ALZ accelerator + Azure Verified Modules (AVM). The current Microsoft direction: Azure Verified Modules are the official, supported, single-source-of-truth building blocks (resource modules and higher-level pattern modules), and the ALZ accelerator is a bootstrapping tool that scaffolds a starter repository, pipelines, and the ALZ pattern modules for you. Both Bicep and Terraform flavours exist. New builds should start here.

Whichever accelerator you pick, treat its default policy set as a proposal: assign it in DoNotEnforce, review what each initiative would deny or deploy, and enforce in waves. The Azure Verified Modules platform lesson goes deep on composing AVM modules.

Subscription vending

Subscription vending is the automated production line for application landing zones: a workload team files a request (name, environment, cost centre, network size); a pipeline creates the subscription, moves it under the correct management group, wires its spoke VNet and hub peering, applies budgets and RBAC, and hands back a ready-to-use subscription — in minutes, with zero click-ops. The Azure/lz-vending/azurerm module is purpose-built for it:

module "landing_zone" {
  source  = "Azure/lz-vending/azurerm"
  version = "~> 5.0"

  location = "eastus"

  # Create and place the subscription
  subscription_alias_enabled = true
  subscription_display_name  = "sub-payments-prod"
  subscription_billing_scope = var.billing_scope # placeholder — your EA/MCA scope
  subscription_workload      = "Production"
  subscription_management_group_association_enabled = true
  subscription_management_group_id                  = "landingzones"

  # Vend and peer the spoke network
  virtual_network_enabled = true
  virtual_networks = {
    spoke = {
      address_space           = ["10.20.0.0/24"]
      hub_peering_enabled     = true
      hub_network_resource_id = var.hub_vnet_id # placeholder
    }
  }
}

The equivalent first step in the CLI is az account alias create, which is what actually mints a subscription under a billing scope:

az account alias create \
  --name sub-payments-prod \
  --billing-scope "<BILLING_SCOPE_ID>" \
  --display-name "sub-payments-prod" \
  --workload Production

SRE guardrails: drift, blast radius, and day 2

A landing zone is not “done” at deployment — it is operated. Three concerns dominate day 2:

Common beginner mistakes

Practice challenges

Work these top to bottom — they escalate from a single command to diagnosing a real policy deadlock. Try each before opening the solution.

  1. (Beginner) Create a child management group. Add a Sandbox management group directly under the top-level contoso MG using the Azure CLI.

<details> <summary>Solution</summary>

az account management-group create --name sandbox --display-name "Sandbox" --parent contoso

Why: --parent sets inheritance — sandbox now inherits every policy and role assignment on contoso. </details>

  1. (Beginner) Require an owner tag on new resources. Assign the built-in Require a tag on resources policy at the landingzones MG so resources lacking the tag are refused.

<details> <summary>Solution</summary>

az policy assignment create \
  --name require-owner-tag \
  --scope "/providers/Microsoft.Management/managementGroups/landingzones" \
  --policy "871b6d14-10aa-478d-b590-94f262ecfa99" \
  --params '{ "tagName": { "value": "owner" } }'

Why: the built-in Require a tag on resources definition has a Deny effect; scoping it at the MG makes every subscription below inherit the requirement. </details>

  1. (Intermediate) Complete the peering both ways with gateway transit. Given the spoke-to-hub peering in the lesson, write the hub-to-spoke side so spokes can use the hub’s gateway.

<details> <summary>Solution</summary>

resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "hub-to-prod"
  resource_group_name       = azurerm_resource_group.connectivity.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.prod.id
  allow_forwarded_traffic   = true
  allow_gateway_transit     = true
}

Why: peering is not implied on the far side — the hub must set allow_gateway_transit = true to match the spoke’s use_remote_gateways = true. </details>

  1. (Intermediate) Roll out a new deny initiative safely. You want to add “deny public IP on NICs” to the Corp MG without risking an outage. What is the safe sequence?

<details> <summary>Solution</summary>

Assign it in compliance-only mode, inspect, then enforce:

az policy assignment create --name deny-public-ip --enforcement-mode DoNotEnforce \
  --scope "/providers/Microsoft.Management/managementGroups/corp" \
  --policy "83a86a26-fd1f-447c-b59d-e51f44264114"
# review results, then update the assignment with --enforcement-mode Default
az policy state summarize --management-group corp

Why: DoNotEnforce reports what would be denied without blocking anything, so you catch legitimate breakers before they become an outage. </details>

  1. (Advanced) Vend an application landing zone. Sketch the module call that creates sub-web-prod, places it under landingzones, and peers a 10.30.0.0/24 spoke to the hub.

<details> <summary>Solution</summary>

module "web_prod" {
  source   = "Azure/lz-vending/azurerm"
  version  = "~> 5.0"
  location = "eastus"

  subscription_alias_enabled = true
  subscription_display_name  = "sub-web-prod"
  subscription_billing_scope = var.billing_scope
  subscription_workload      = "Production"
  subscription_management_group_association_enabled = true
  subscription_management_group_id                  = "landingzones"

  virtual_network_enabled = true
  virtual_networks = {
    spoke = {
      address_space           = ["10.30.0.0/24"]
      hub_peering_enabled     = true
      hub_network_resource_id = var.hub_vnet_id
    }
  }
}

Why: vending does creation + placement + networking in one templated, reviewable unit — no click-ops, and the new subscription inherits landingzones guardrails immediately. </details>

  1. (Advanced) Break the DINE-vs-Deny deadlock. A Deny-PublicEndpoint policy at Corp is blocking the Private Endpoints that a DeployIfNotExists policy is trying to create for Key Vault. Without weakening the deny for everyone, how do you unblock the platform?

<details> <summary>Solution</summary>

Create a policy exemption for the platform resource group (or narrow the deny with a notIn on that scope), then re-run remediation:

az policy exemption create \
  --name exempt-platform-pe \
  --policy-assignment "<DENY_ASSIGNMENT_ID>" \
  --exemption-category Waiver \
  --scope "/subscriptions/<SUB_ID>/resourceGroups/rg-platform-connectivity"

az policy remediation create --name remediate-pe --policy-assignment "<DINE_ASSIGNMENT_ID>"

Why: the fix is scoping, not weakening — a targeted exemption lets the platform bootstrap its own private connectivity while the deny still protects every workload subscription. </details>

Landing-zone readiness checklist

Glossary

Where to go next

Wire this landing zone to a CI/CD pipeline, layer Microsoft Defender for Cloud regulatory compliance dashboards on top, and adopt the Well-Architected Framework to pressure-test each workload across reliability, security, cost, operational excellence, and performance. The landing zone is the foundation — WAF keeps what lands on it healthy.

AzureLanding ZoneCAFGovernanceBicepTerraformHub-Spoke
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments