~/2026/04/15/azure-landing-zone-design-for-a-mid-size-company-three-years-in.md
Azure: Landing Zone Design for a Mid-Size Company, Three Years In
--- author: Tom Lasswell date: read: 7 min in: [engineering, strategy] tags: [azure] ---
$ grep -n '^#' post.md
I designed my first Azure landing zone with the Cloud Adoption Framework diagrams open in one window and a blank management group tree in the other, trying to guess how much structure a company our size actually needed. Three years and a few painful lessons later, here is what I would tell that version of myself, along with the code I would now start from instead of the portal.
Start With Fewer Management Groups Than the Diagram Shows
The current Azure landing zone reference hierarchy is bigger than most people remember. Under the tenant root it puts an intermediate root with your company prefix, then Platform (with Security, Management, Connectivity and Identity children), Landing zones (with Corp, Online and Local children), Sandboxes and Decommissioned. That is a dozen groups before a single workload lands. I built nearly all of it on day one for a company with maybe fifteen subscriptions, and most of that structure sat empty for two years. A management group with one subscription under it is not governance, it is a folder with extra steps.
The CAF guidance itself backs restraint more than the diagram suggests. It recommends keeping the hierarchy "reasonably flat, ideally with no more than three to four levels", tells you not to mirror your org chart, and says not to create management groups for production, test and development: separate those by subscription inside the same group. It also recommends pointing new subscriptions at a default management group (a sandbox group is the suggested candidate) so nothing lands under the tenant root by accident, and turning on the hierarchy setting that requires authorization to create management groups, because by default any user in the tenant can create one.
What actually earned its place for us: a Platform group holding the identity, connectivity and management subscriptions directly (no child groups until a platform subscription needed different policy), a Landing zones group split into Corp and Online for internal versus internet-facing workloads, a Sandbox group with policies loose enough that engineers stop provisioning shadow resource groups just to escape the guardrails, and a Decommissioned group to park cancelled subscriptions. Everything else I added later, when a real workload justified it.
Here is that tree as Bicep. Management groups are tenant-level resources, so from a management group deployment each one is declared with scope: tenant() and a details.parent.id, which is the pattern in Microsoft's Bicep management group deployment docs.
// mg-hierarchy.bicep: a deliberately small ALZ-style management group tree.
// Deploy at the tenant root group, whose ID is the Microsoft Entra tenant ID.
targetScope = 'managementGroup'
@description('Short company prefix, used as the intermediate root ID.')
param prefix string = 'contoso'
@description('Display name for the intermediate root.')
param rootDisplayName string = 'Contoso'
resource intRoot 'Microsoft.Management/managementGroups@2023-04-01' = {
scope: tenant()
name: prefix
properties: {
displayName: rootDisplayName
details: {
parent: {
id: managementGroup().id
}
}
}
}
var topLevel = [
{
name: '${prefix}-platform'
displayName: 'Platform'
}
{
name: '${prefix}-sandbox'
displayName: 'Sandbox'
}
{
name: '${prefix}-decommissioned'
displayName: 'Decommissioned'
}
]
resource topGroups 'Microsoft.Management/managementGroups@2023-04-01' = [for mg in topLevel: {
scope: tenant()
name: mg.name
properties: {
displayName: mg.displayName
details: {
parent: {
id: intRoot.id
}
}
}
}]
resource landingZones 'Microsoft.Management/managementGroups@2023-04-01' = {
scope: tenant()
name: '${prefix}-landingzones'
properties: {
displayName: 'Landing zones'
details: {
parent: {
id: intRoot.id
}
}
}
}
var workloadTypes = [
{
name: '${prefix}-corp'
displayName: 'Corp'
}
{
name: '${prefix}-online'
displayName: 'Online'
}
]
resource workloadGroups 'Microsoft.Management/managementGroups@2023-04-01' = [for mg in workloadTypes: {
scope: tenant()
name: mg.name
properties: {
displayName: mg.displayName
details: {
parent: {
id: landingZones.id
}
}
}
}]
output landingZonesId string = landingZones.id
Deploy it, then set the two hierarchy settings. The root management group's ID is the tenant ID, and only a Global Administrator who has elevated access can grant rights at the root in the first place. Elevation itself only gives you User Access Administrator at root scope, which can't deploy anything, so use it to assign yourself (or the pipeline identity) Owner on the tenant root group first. This is a one-time bootstrap run by someone with that access.
#!/usr/bin/env bash
# bootstrap-hierarchy.sh: deploy the management group tree and protect the root.
set -euo pipefail
TENANT_ID="<tenant-id>" # the tenant root group has the same ID
PREFIX="contoso"
LOCATION="eastus" # where deployment metadata is stored
az deployment mg create \
--name alz-hierarchy \
--location "$LOCATION" \
--management-group-id "$TENANT_ID" \
--template-file mg-hierarchy.bicep \
--parameters prefix="$PREFIX"
# New subscriptions land in Sandbox instead of the tenant root,
# and creating management groups requires write access on the root.
az account management-group hierarchy-settings create \
--name "$TENANT_ID" \
--default-management-group "/providers/Microsoft.Management/managementGroups/${PREFIX}-sandbox" \
--require-authorization-for-group-creation true
# Place the platform subscriptions directly under Platform.
for SUB in "<identity-subscription-id>" "<connectivity-subscription-id>" "<management-subscription-id>"; do
az account management-group subscription add \
--name "${PREFIX}-platform" \
--subscription "$SUB"
done
If you want the full reference architecture rather than this trimmed tree, Microsoft's current Bicep path is the ALZ Bicep accelerator built on Azure Verified Modules (AVM), which also uses Azure Deployment Stacks to clean up resources and policies that drop out of the templates. The older ALZ-Bicep modules are now labelled "classic". I would still start small and let the accelerator's structure be something you grow into, not something you inherit.
Policy Assignments Are a Liability the Moment You Stop Reading Them
Azure Policy is where landing zone design goes to either succeed quietly or fail loudly, and the failure mode is almost always the same: someone assigns a built-in initiative wholesale, it works fine for a year, and then a change to that initiative's underlying policies alters behaviour nobody voted on. I have had a deny effect I never explicitly configured start blocking a deployment because it was bundled two levels deep in an initiative we assigned once and never opened again.
This is documented behaviour, not bad luck. Built-in definitions are versioned Major.Minor.Patch, and Microsoft lists "adding or moving definitions within an initiative" and "minor rule logic changes" as minor-version changes. An assignment defaults to the latest major version and automatically takes minor and patch updates. You can change that with the assignment's definitionVersion property: 1.*.* follows every minor update, while 1.1.* pins the minor version and only takes patches (patches are always applied, and are limited to text changes and break-glass fixes).
The fix that actually worked was boring: prefer individual built-in policies (or custom initiatives you assemble from them) over someone else's bundle, pin the minor version on anything with a deny or modify effect, and review every assignment on a quarterly cadence rather than when something breaks. Policy changes then come from a pull request, not a platform update. These are the three assignments I put at the intermediate root and landing zone groups on day one, all built-in definitions referenced by their fixed IDs:
| Built-in policy | Definition ID | Why |
|---|---|---|
| Allowed locations | e56962a6-4747-49cd-b67b-bf8b01975c4c | Keeps resources in the regions you have networking and data residency for |
| Require a tag on resource groups | 96670d01-0a4d-4649-9c89-2d3abc0a5025 | No resource group without a costCenter |
| Inherit a tag from the resource group if missing | ea3f2387-9b95-492a-a190-fcdc54f7b070 | Copies costCenter down to resources (modify effect) |
// core-policy.bicep: deploy at the intermediate root management group.
targetScope = 'managementGroup'
param allowedLocations array = [
'eastus'
'eastus2'
]
param costTagName string = 'costCenter'
@description('Region for the managed identity that the modify assignment needs.')
param identityLocation string = 'eastus'
var builtIn = {
allowedLocations: tenantResourceId('Microsoft.Authorization/policyDefinitions', 'e56962a6-4747-49cd-b67b-bf8b01975c4c')
requireRgTag: tenantResourceId('Microsoft.Authorization/policyDefinitions', '96670d01-0a4d-4649-9c89-2d3abc0a5025')
inheritRgTag: tenantResourceId('Microsoft.Authorization/policyDefinitions', 'ea3f2387-9b95-492a-a190-fcdc54f7b070')
}
// Contributor, the role the inherit-tag definition declares in roleDefinitionIds.
var contributorRoleId = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
// Management group scope limits assignment names to 24 characters.
resource locations 'Microsoft.Authorization/policyAssignments@2025-03-01' = {
name: 'allowed-locations'
properties: {
displayName: 'Allowed locations'
policyDefinitionId: builtIn.allowedLocations
definitionVersion: '1.1.*'
parameters: {
listOfAllowedLocations: {
value: allowedLocations
}
}
nonComplianceMessages: [
{
message: 'Deploy only to approved regions. Request an exemption from the platform team if you need another.'
}
]
}
}
resource rgTag 'Microsoft.Authorization/policyAssignments@2025-03-01' = {
name: 'require-rg-costcenter'
properties: {
displayName: 'Require ${costTagName} on resource groups'
policyDefinitionId: builtIn.requireRgTag
parameters: {
tagName: {
value: costTagName
}
}
nonComplianceMessages: [
{
message: 'Every resource group needs a ${costTagName} tag.'
}
]
}
}
resource inheritTag 'Microsoft.Authorization/policyAssignments@2025-03-01' = {
name: 'inherit-rg-costcenter'
location: identityLocation
identity: {
type: 'SystemAssigned'
}
properties: {
displayName: 'Inherit ${costTagName} from the resource group'
policyDefinitionId: builtIn.inheritRgTag
parameters: {
tagName: {
value: costTagName
}
}
}
}
// The modify effect needs its identity to hold the declared role at the assignment scope.
resource inheritTagRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(managementGroup().id, 'inherit-rg-costcenter', contributorRoleId)
properties: {
roleDefinitionId: tenantResourceId('Microsoft.Authorization/roleDefinitions', contributorRoleId)
principalId: inheritTag.identity.principalId
principalType: 'ServicePrincipal'
}
}
Two details bit me here. Policy assignment names at management group scope are limited to 24 characters, which is why the names above are terse. And a modify policy only changes resources as they are created or updated; existing resources are marked non-compliant and need a remediation task, which runs as the assignment's managed identity.
The quarterly review is a script, not a meeting. This one exports every assignment at a management group plus the current content of each built-in initiative it references, so you can commit the output and let git diff tell you what Microsoft changed since last quarter.
#!/usr/bin/env bash
# policy-snapshot.sh <management-group-id> [output-dir]
# Exports assignments at one management group and the member list of every
# built-in initiative they reference. Commit the output; diff it next quarter.
set -euo pipefail
MG="${1:?usage: policy-snapshot.sh <management-group-id> [output-dir]}"
OUT="${2:-policy-snapshot}"
mkdir -p "$OUT/assignments" "$OUT/initiatives"
az policy assignment list \
--management-group "$MG" \
--filter "atScope()" \
--query "[].{name:name, definition:policyDefinitionId, version:definitionVersion, enforcement:enforcementMode}" \
--output json > "$OUT/assignments/$MG.json"
az policy assignment list \
--management-group "$MG" \
--filter "atScope()" \
--query "[].policyDefinitionId" \
--output tsv |
while read -r defId; do
case "$defId" in
/providers/Microsoft.Authorization/policySetDefinitions/*)
name="${defId##*/}"
az policy set-definition show \
--name "$name" \
--query "{displayName:displayName, version:version, policies:policyDefinitions[].{ref:policyDefinitionReferenceId, id:policyDefinitionId, version:definitionVersion}}" \
--output json > "$OUT/initiatives/$name.json"
;;
esac
done
echo "Snapshot written to $OUT. Review with: git diff --stat -- $OUT"
Subscription Vending Beats Subscription Requests
For the first eighteen months, every new subscription meant a ticket, a manual policy pass, and someone remembering to wire up the right network peering and log forwarding. It worked until it did not scale past the two people who understood the whole checklist. Automating subscription creation as a vending process, a pipeline that runs the same governance steps every time, was the single highest-leverage change I made to the landing zone.
CAF now describes exactly this. The vending automation should capture the request (budget, owners, networking, criticality) at intake, place the subscription in the right management group, create the peered virtual network, assign access through Entra groups rather than individuals, tag it for cost reporting, and create a starting budget the application team then adjusts. Microsoft publishes subscription vending modules for Bicep and Terraform (aka.ms/lz-vending/bicep and aka.ms/lz-vending/tf), and notes that creating subscriptions programmatically needs an EA, MCA or MPA agreement, while everything after creation can be automated regardless. New subscriptions come out identical now, and the checklist lives in code review history instead of one person's memory. For the access side, I also run a periodic check of who holds Owner across every subscription; the script is in Audit Who Has Owner Role Across All Subscriptions.
Cost Management Should Have Been Day One, Not Year Two
I treated cost management as an operational nicety and bolted it on well after the platform was live, which meant a year of budget alerts configured inconsistently across subscriptions and no shared tagging standard to slice spend by team or environment. Retrofitting tags onto resources that already existed is much harder than enforcing them at the vending stage, where the require-rg-costcenter assignment above simply blocks the deployment. For the backlog of untagged resources that predate the policy, Tag Every Untagged Resource in a Subscription is the cleanup pass I used.
What I Would Keep Doing
The hub-and-spoke network topology, the centralized Log Analytics workspace in the management subscription, and the decision to build identity first rather than as an afterthought all held up under three years of growth. So did keeping application teams' role assignments at subscription or resource group scope, which is also what CAF recommends: management groups are for policy, and standing RBAC at that level is reserved for platform staff through Privileged Identity Management. Landing zone design rewards restraint on scaffolding and discipline on the few controls that actually govern risk. Everything else can be added later, and usually should be.
References
- Management groups (Cloud Adoption Framework design area)
- Subscription vending (Cloud Adoption Framework)
- Protect your resource hierarchy
- Use Bicep to deploy resources to a management group
- Azure Policy definition structure basics (versioning)
- Azure Policy assignment structure (definitionVersion)
- Azure Policy modify effect
- Azure Policy built-in policy definitions
- Naming rules for Azure resources (policy assignment name limits)
- Azure landing zones Bicep implementation