Infrastructure as Code for Azure Landing Zones
How we deploy FedRAMP-aligned Azure Landing Zones using Terraform, eliminating configuration drift and reducing provisioning time by 60%.
Manual cloud provisioning does not scale. It introduces configuration drift, creates undocumented dependencies, and makes compliance auditing nearly impossible. Infrastructure as Code (IaC) solves these problems by treating infrastructure definitions as version-controlled software.
This post covers our approach to deploying Azure Landing Zones using Terraform, with specific attention to compliance requirements for government and regulated enterprise environments.
What Is a Landing Zone?
Note: This guide assumes familiarity with Azure fundamentals. For government deployments, substitute Azure Government endpoints throughout.
An Azure Landing Zone is a pre-configured environment that provides:
- Subscription organization and management group hierarchy
- Identity and access management baseline
- Network topology and connectivity
- Security and governance policies
- Monitoring and logging infrastructure
Think of it as the foundation your applications deploy into. Get the landing zone wrong, and every application inherits those problems.
Terraform vs Bicep
We support both Terraform and Bicep for landing zone deployments. The choice depends on your organization:
Terraform is ideal when:
- You have multi-cloud or hybrid scenarios
- Your team already has Terraform expertise
- You need cloud-agnostic modules
Bicep is ideal when:
- You are Azure-only
- You want native Azure integration and tooling
- You prefer tighter ARM template compatibility
Tip: For government environments, Bicep often has faster support for new Azure Government features since it compiles directly to ARM templates.
The Terraform Approach
We use Terraform for landing zone deployment because:
- It is cloud-agnostic (useful for multi-cloud or hybrid scenarios)
- It has mature state management
- It supports modular composition
- It integrates with CI/CD pipelines
- It produces human-readable plans before applying changes
Module Structure
Our landing zone repository follows this structure:
1landing-zone/2├── modules/3│ ├── management-groups/4│ ├── policy-assignments/5│ ├── networking/6│ ├── logging/7│ └── security-center/8├── environments/9│ ├── production/10│ ├── staging/11│ └── development/12└── policies/13 ├── nist-800-53/14 └── fedramp-high/Each module is independently testable and versioned. Environments compose modules with environment-specific variables.
Management Group Hierarchy
The management group hierarchy determines policy inheritance. A typical structure:
1# modules/management-groups/main.tf23resource "azurerm_management_group" "root" {4 display_name = "Organization Root"5 6 subscription_ids = []7}89resource "azurerm_management_group" "platform" {10 display_name = "Platform"11 parent_management_group_id = azurerm_management_group.root.id12}1314resource "azurerm_management_group" "landing_zones" {15 display_name = "Landing Zones"16 parent_management_group_id = azurerm_management_group.root.id17}1819resource "azurerm_management_group" "production" {20 display_name = "Production"21 parent_management_group_id = azurerm_management_group.landing_zones.id22}2324resource "azurerm_management_group" "non_production" {25 display_name = "Non-Production"26 parent_management_group_id = azurerm_management_group.landing_zones.id27}The equivalent in Bicep:
1// modules/management-groups/main.bicep2targetScope = 'tenant'34resource rootMg 'Microsoft.Management/managementGroups@2021-04-01' = {5 name: 'org-root'6 properties: {7 displayName: 'Organization Root'8 }9}1011resource platformMg 'Microsoft.Management/managementGroups@2021-04-01' = {12 name: 'platform'13 properties: {14 displayName: 'Platform'15 details: {16 parent: {17 id: rootMg.id18 }19 }20 }21}2223resource landingZonesMg 'Microsoft.Management/managementGroups@2021-04-01' = {24 name: 'landing-zones'25 properties: {26 displayName: 'Landing Zones'27 details: {28 parent: {29 id: rootMg.id30 }31 }32 }33}3435resource productionMg 'Microsoft.Management/managementGroups@2021-04-01' = {36 name: 'production'37 properties: {38 displayName: 'Production'39 details: {40 parent: {41 id: landingZonesMg.id42 }43 }44 }45}4647resource nonProductionMg 'Microsoft.Management/managementGroups@2021-04-01' = {48 name: 'non-production'49 properties: {50 displayName: 'Non-Production'51 details: {52 parent: {53 id: landingZonesMg.id54 }55 }56 }57}Policy as Code
Compliance requirements become policy assignments. NIST 800-53 controls map to Azure Policy definitions:
1# modules/policy-assignments/nist.tf23resource "azurerm_management_group_policy_assignment" "nist_800_53" {4 name = "nist-800-53-r5"5 management_group_id = var.management_group_id6 policy_definition_id = "/providers/Microsoft.Authorization/policySetDefinitions/179d1daa-458f-4e47-8086-2a68d0d6c38f"7 8 identity {9 type = "SystemAssigned"10 }11 12 location = var.location13 14 parameters = jsonencode({15 listOfAllowedLocations = {16 value = var.allowed_locations17 }18 })19}In Bicep:
1// modules/policy-assignments/nist.bicep2targetScope = 'managementGroup'34param managementGroupId string5param location string6param allowedLocations array78resource nistPolicy 'Microsoft.Authorization/policyAssignments@2022-06-01' = {9 name: 'nist-800-53-r5'10 location: location11 identity: {12 type: 'SystemAssigned'13 }14 properties: {15 displayName: 'NIST SP 800-53 Rev. 5'16 policyDefinitionId: '/providers/Microsoft.Authorization/policySetDefinitions/179d1daa-458f-4e47-8086-2a68d0d6c38f'17 parameters: {18 listOfAllowedLocations: {19 value: allowedLocations20 }21 }22 }23}Network Topology
Hub-and-spoke is the standard pattern for enterprise Azure networks:
1# modules/networking/hub.tf23resource "azurerm_virtual_network" "hub" {4 name = "vnet-hub-${var.environment}"5 location = var.location6 resource_group_name = var.resource_group_name7 address_space = [var.hub_address_space]8 9 tags = var.tags10}1112resource "azurerm_subnet" "firewall" {13 name = "AzureFirewallSubnet"14 resource_group_name = var.resource_group_name15 virtual_network_name = azurerm_virtual_network.hub.name16 address_prefixes = [var.firewall_subnet_prefix]17}1819resource "azurerm_subnet" "gateway" {20 name = "GatewaySubnet"21 resource_group_name = var.resource_group_name22 virtual_network_name = azurerm_virtual_network.hub.name23 address_prefixes = [var.gateway_subnet_prefix]24}In Bicep:
1// modules/networking/hub.bicep2param environment string3param location string4param hubAddressSpace string5param firewallSubnetPrefix string6param gatewaySubnetPrefix string7param tags object89resource hubVnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {10 name: 'vnet-hub-${environment}'11 location: location12 tags: tags13 properties: {14 addressSpace: {15 addressPrefixes: [hubAddressSpace]16 }17 subnets: [18 {19 name: 'AzureFirewallSubnet'20 properties: {21 addressPrefix: firewallSubnetPrefix22 }23 }24 {25 name: 'GatewaySubnet'26 properties: {27 addressPrefix: gatewaySubnetPrefix28 }29 }30 ]31 }32}3334output hubVnetId string = hubVnet.id35output firewallSubnetId string = hubVnet.properties.subnets[0].id36output gatewaySubnetId string = hubVnet.properties.subnets[1].idCentralized Logging
All diagnostic logs route to a central Log Analytics workspace:
1# modules/logging/main.tf23resource "azurerm_log_analytics_workspace" "central" {4 name = "law-central-${var.environment}"5 location = var.location6 resource_group_name = var.resource_group_name7 sku = "PerGB2018"8 retention_in_days = var.retention_days9 10 tags = var.tags11}1213resource "azurerm_monitor_diagnostic_setting" "activity_logs" {14 name = "activity-logs-to-law"15 target_resource_id = "/subscriptions/${var.subscription_id}"16 log_analytics_workspace_id = azurerm_log_analytics_workspace.central.id17 18 enabled_log {19 category = "Administrative"20 }21 22 enabled_log {23 category = "Security"24 }25 26 enabled_log {27 category = "Policy"28 }29}In Bicep:
1// modules/logging/main.bicep2param environment string3param location string4param retentionDays int = 3655param tags object67resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {8 name: 'law-central-${environment}'9 location: location10 tags: tags11 properties: {12 sku: {13 name: 'PerGB2018'14 }15 retentionInDays: retentionDays16 }17}1819resource activityLogDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {20 name: 'activity-logs-to-law'21 properties: {22 workspaceId: logAnalytics.id23 logs: [24 {25 category: 'Administrative'26 enabled: true27 }28 {29 category: 'Security'30 enabled: true31 }32 {33 category: 'Policy'34 enabled: true35 }36 ]37 }38}3940output logAnalyticsId string = logAnalytics.id41output logAnalyticsWorkspaceId string = logAnalytics.properties.customerIdCI/CD Pipeline
Landing zone changes go through a structured pipeline:
- Pull Request — Terraform plan runs, output posted as PR comment
- Review — Security and platform teams review the plan
- Merge — Terraform apply runs against staging
- Promote — After validation, apply to production
1# .github/workflows/landing-zone.yml2name: Landing Zone Deployment34on:5 pull_request:6 paths:7 - 'landing-zone/**'8 push:9 branches:10 - main11 paths:12 - 'landing-zone/**'1314jobs:15 plan:16 runs-on: ubuntu-latest17 steps:18 - uses: actions/checkout@v419 20 - name: Terraform Init21 run: terraform init22 working-directory: landing-zone/environments/production23 24 - name: Terraform Plan25 run: terraform plan -out=tfplan26 working-directory: landing-zone/environments/productionFor Bicep deployments, replace the Terraform steps with Azure CLI:
1# Bicep deployment steps2 - name: Azure Login3 uses: azure/login@v14 with:5 creds: ${{ secrets.AZURE_CREDENTIALS }}6 7 - name: Bicep Build8 run: az bicep build --file main.bicep9 working-directory: landing-zone/environments/production10 11 - name: What-If (Preview Changes)12 run: |13 az deployment mg what-if \14 --management-group-id "org-root" \15 --location "eastus" \16 --template-file main.bicep \17 --parameters parameters.json18 working-directory: landing-zone/environments/production19 20 - name: Deploy21 if: github.ref == 'refs/heads/main'22 run: |23 az deployment mg create \24 --management-group-id "org-root" \25 --location "eastus" \26 --template-file main.bicep \27 --parameters parameters.json28 working-directory: landing-zone/environments/productionState Management
Tip: Never store Terraform state locally or in version control. Remote state with locking prevents concurrent modifications and state corruption.
Terraform state must be stored remotely with locking:
1# backend.tf23terraform {4 backend "azurerm" {5 resource_group_name = "rg-terraform-state"6 storage_account_name = "stterraformstate"7 container_name = "landing-zone"8 key = "production.tfstate"9 }10}For government environments, ensure the storage account is in Azure Government and uses customer-managed keys.
Note: Bicep does not require explicit state management since it uses ARM's built-in deployment history. However, you should still use deployment stacks or track deployments in your CI/CD system for rollback capabilities.
Results
Using this approach, we have:
- Reduced landing zone provisioning from 2 weeks to 2 days
- Eliminated configuration drift through continuous enforcement
- Achieved 100% policy compliance visibility
- Enabled repeatable deployments across multiple environments
Conclusion
Infrastructure as Code is not optional for compliant cloud environments. Manual provisioning creates undocumented configurations that fail audits and introduce security gaps.
Whether you choose Terraform or Bicep, the investment in modular IaC pays dividends in consistency, auditability, and speed of deployment. For Azure-only environments, Bicep offers a more native experience with excellent tooling. For multi-cloud scenarios, Terraform remains the standard.
Need Help With Your Modernization?
We help government agencies, regulated enterprises, and mission-driven organizations transform legacy systems into secure, identity-integrated platforms.
Get in Touch