Landing zones are products, not one-off deployments. They bundle identity, networking, governance, and observability into repeatable environments that teams can request on demand. The control plane you choose to express and enforce those concerns shapes how fast you can evolve, how safely you can scale, and how cleanly you can operate. The goal is not to pick a winner, but to assign clear roles and boundaries so the combination behaves predictably under change.

A practical definition helps anchor decisions. A landing zone is the minimal, compliant, and observable substrate for workloads. At a minimum it defines subscription topology and placement, baseline RBAC, network connectivity patterns, policy guardrails, diagnostics, and cost governance. It must be idempotent, versioned, and testable, with a safe roll-forward story and an explicit approach to drift.

A resilient pattern is to separate governance from platform resources. Governance covers management group hierarchy, policy and initiative definitions, assignments, and top-level RBAC. Platform resources cover VNETs, private DNS, firewalls, shared platform services like Key Vault and Log Analytics. The control plane that leads governance should optimize for native Azure constructs and evaluation semantics, while the one that leads platform resources should optimize for composability, tests, and change review.

Terraform works well as the primary engine for platform resources and for orchestration that crosses service boundaries. Its state model and data sources make complex references practical, and providers bridge gaps for services that are slower to reach native templates. The trade-offs are state lifecycle and blast radius: plan hygiene, workspace isolation, and import discipline are essential. Use remote state with RBAC, pin provider versions, and treat state migrations as change-managed events.

Bicep shines for Azure-native deployments that must align closely with ARM semantics, especially at management group and subscription scope. It integrates with template specs, what-if, and native deployment APIs, and it avoids state by design. The trade-offs are limited multi-cloud reach and smaller module ecosystems; module versioning and parameter discipline become central. When using deployment stacks or template specs to manage lifecycle, define clear ownership boundaries so that updates do not unintentionally prune resources owned by other stacks.

Azure Policy as Code is the governance engine. It detects and blocks drift, remediates gaps via deployIfNotExists or modify effects, and expresses rules in a way platform and security teams can audit. The core trade-off is that policies are not general-purpose resource builders; overusing deployIfNotExists to manufacture complex infrastructure leads to brittle and slow remediation pipelines. Use policies to enforce contracts, deny noncompliant shapes, and stamp minimal remediation where safe. Keep resource creation in Terraform or Bicep.

Clear role assignment reduces conflict. Use Azure Policy to define and assign guardrails and log their compliance state. Use Bicep or Terraform to deploy the resources that must exist. Resist double-writing: do not both create a resource with IaC and also rely on a deployIfNotExists policy to patch it; pick one owner. Where ownership must change, sequence the handover so the new system becomes authoritative only after the old one stops managing the target.

The management group hierarchy is the spine of the landing zone. A common approach is a root group under the tenant, with children for platform, corp, online, and sandbox. Place initiative assignments high enough to capture everything they should govern, but not so high that variance becomes a sea of exceptions. Assign role definitions and diagnostic baselines at management group scope so subscriptions inherit the rules as they are vended.

The following Terraform snippet demonstrates creating a management group and assigning a built-in allowed locations policy at that scope. This belongs in a small, well-tested module dedicated to governance primitives. Keep module inputs explicit and default-free to make plans legible during reviews.

# Terraform configuration for Azure management group and policy assignment
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.120"
    }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_management_group" "platform" {
  display_name               = "platform"
  name                       = "mg-platform"
  parent_management_group_id = "/providers/Microsoft.Management/managementGroups/contoso"
}

data "azurerm_policy_definition" "allowed_locs" {
  name = "e56962a6-4747-49cd-b67b-bf8b01975c4c" # Allowed locations
}

resource "azurerm_policy_assignment" "restrict_location" {
  name                 = "restrict-location"
  scope                = azurerm_management_group.platform.id
  policy_definition_id = data.azurerm_policy_definition.allowed_locs.id
  parameters = jsonencode({
    listOfAllowedLocations = {
      value = ["westeurope", "northeurope"]
    }
  })
  enforcement_mode = "Default"
}

For teams that prefer Azure-native tooling for governance, a compact Bicep template can assign the same policy at management group scope. The template is stateless and can be packaged as a template spec for promotion through environments. Keep parameter files under version control to make the change intent explicit.

# Bicep template for management group scoped policy assignment with allowed locations
targetScope = 'managementGroup'

param mgName string = 'mg-platform'
param allowedLocations array = [
  'westeurope'
  'northeurope'
]

resource def 'Microsoft.Authorization/policyDefinitions@2021-06-01' existing = {
  name: 'e56962a6-4747-49cd-b67b-bf8b01975c4c'
}

resource assign 'Microsoft.Authorization/policyAssignments@2021-06-01' = {
  name: 'pa-allowed-locations'
  scope: managementGroupResource(mgName)
  properties: {
    policyDefinitionId: def.id
    parameters: {
      listOfAllowedLocations: { value: allowedLocations }
    }
    enforcementMode: 'Default'
  }
}

When using Azure Policy to fix drift, prefer modify for tags and small patches, and deployIfNotExists for cases where the existence of a single child resource is the contract. Model the least powerful role that can remediate and include it in the policy definition, not the assignment, to reduce configuration variance. The following policy appends a required tag if missing.

# Azure Policy definition to append required owner tag with modify effect
{
  "properties": {
    "displayName": "Append required tags",
    "mode": "Indexed",
    "policyRule": {
      "if": { "field": "tags['owner']", "exists": "false" },
      "then": {
        "effect": "modify",
        "details": {
          "operations": [
            { "operation": "add", "field": "tags['owner']", "value": "platform" }
          ],
          "roleDefinitionIds": [
            "/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
          ]
        }
      }
    }
  }
}

A subscription vending flow ties the pieces together. The automation places the subscription under the right management group, stamps tags, and ensures policy assignments take effect immediately. Keep the service principal permissions tightly scoped to create assignments and move subscriptions within the hierarchy. Prefer idempotent scripts that log correlation IDs for observability.

# Create Azure management group and assign allowed locations policy with remediation
az account management-group create --name mg-platform --display-name platform

az policy assignment create \
  --name pa-allowed-locations \
  --scope /providers/Microsoft.Management/managementGroups/mg-platform \
  --policy e56962a6-4747-49cd-b67b-bf8b01975c4c \
  --params '{ "listOfAllowedLocations": { "value": ["westeurope","northeurope"] } }'

az policy remediation create \
  --name remediate-locations \
  --policy-assignment pa-allowed-locations \
  --scope /providers/Microsoft.Management/managementGroups/mg-platform

Guardrails need observability. Treat policy compliance as an SLO: a target percentage of compliant resources with a time-to-remediation objective. Feed compliance state into central dashboards and page only on guardrails that protect high-risk assets. The query below surfaces the highest-volume noncompliance in the last 24 hours across the hierarchy.

# KQL query to identify top non-compliant policy assignments for monitoring
PolicyResources
| where TimeGenerated > ago(24h)
| where CompliantState == "NonCompliant"
| summarize count() by PolicyAssignmentName, ManagementGroupName
| order by count_ desc

Change management is where differences between tools become operational. Terraform exposes changes in plans and can gate merges on non-empty plans; this encourages small, frequent changes and predictable roll-outs. Bicep supports what-if to preview changes and integrates with Azure-native RBAC and deployment history; this improves traceability at the platform level and can simplify emergency changes through break-glass processes. Azure Policy evaluates continuously and asynchronously, so changes to assignments take time to converge; always account for evaluation intervals and remediation job throughput when defining operational expectations.

Idempotency is the non-negotiable property for all three. Avoid hidden environment variables and imperative steps that mutate the platform outside of IaC. For Terraform, insist on explicit imports for brownfield resources and avoid tainting resources as a routine practice. For Bicep, choose incremental deployments for additive changes and complete mode only for stacks that fully own the scope. For Policy, distinguish deny from audit and deploy effects, and resist using audit to mask noncompliance that should be blocked.

Security boundaries should be deliberate. Use separate identities and pipelines for governance and platform layers. Keep Terraform state in a storage account with private endpoints and scoped access. Use Key Vault references and managed identity where possible; avoid embedding secrets in parameter files. Ensure policy definitions and assignments are protected by review gates and that custom definitions are versioned and signed if distributed through template specs or artifact repositories.

Migration paths benefit from asymmetric adoption. It is feasible to keep governance in Azure Policy as Code while moving platform resources from Bicep to Terraform or the reverse. The practical approach is to converge on identical resource shapes and names, import or reference the existing state, and then switch ownership one slice at a time. During the overlap, disable remediation for the resources moving under IaC control to avoid flapping.

Operations complete the picture. Define SLOs for drift detection, remediation, and deployment lead time. Wire activity logs, policy events, and deployment statuses into a central workspace. Capture cost allocation tags at the edge of subscription vending and enforce them with policy. Establish a sandbox path that allows rapid iteration under the same guardrails, using isolated management groups and subscriptions that can be recycled on a schedule.

The most scalable landing zones assign clear responsibilities. Use Azure Policy as Code to define and enforce the rules of the road. Use Bicep when you need Azure-native, stateless deployments with ARM semantics and what-if confidence. Use Terraform when you need composable modules, cross-service orchestration, and multi-cloud consistency. The combination scales best when each tool does what it does best, ownership is singular for any given resource, and the platform treats change as a routine, observable, and reversible operation.

References

  1. Azure Landing Zones Documentation https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/landing-zone/

  2. Azure Policy as Code https://learn.microsoft.com/en-us/azure/governance/policy/concepts/policy-as-code

  3. Azure Bicep Documentation https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/

  4. Terraform Azure Provider https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs

  5. Azure Management Groups https://learn.microsoft.com/en-us/azure/governance/management-groups/