INTERCONNECT
← All articles
InfrastructureAzureTerraformBicep

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%.

Interconnect
Engineering
January 8, 2025 · 7 min read

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:

  1. It is cloud-agnostic (useful for multi-cloud or hybrid scenarios)
  2. It has mature state management
  3. It supports modular composition
  4. It integrates with CI/CD pipelines
  5. It produces human-readable plans before applying changes

Module Structure

Our landing zone repository follows this structure:

plaintext
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:

hcl
1# modules/management-groups/main.tf
2
3resource "azurerm_management_group" "root" {
4 display_name = "Organization Root"
5
6 subscription_ids = []
7}
8
9resource "azurerm_management_group" "platform" {
10 display_name = "Platform"
11 parent_management_group_id = azurerm_management_group.root.id
12}
13
14resource "azurerm_management_group" "landing_zones" {
15 display_name = "Landing Zones"
16 parent_management_group_id = azurerm_management_group.root.id
17}
18
19resource "azurerm_management_group" "production" {
20 display_name = "Production"
21 parent_management_group_id = azurerm_management_group.landing_zones.id
22}
23
24resource "azurerm_management_group" "non_production" {
25 display_name = "Non-Production"
26 parent_management_group_id = azurerm_management_group.landing_zones.id
27}

The equivalent in Bicep:

bicep
1// modules/management-groups/main.bicep
2targetScope = 'tenant'
3
4resource rootMg 'Microsoft.Management/managementGroups@2021-04-01' = {
5 name: 'org-root'
6 properties: {
7 displayName: 'Organization Root'
8 }
9}
10
11resource platformMg 'Microsoft.Management/managementGroups@2021-04-01' = {
12 name: 'platform'
13 properties: {
14 displayName: 'Platform'
15 details: {
16 parent: {
17 id: rootMg.id
18 }
19 }
20 }
21}
22
23resource landingZonesMg 'Microsoft.Management/managementGroups@2021-04-01' = {
24 name: 'landing-zones'
25 properties: {
26 displayName: 'Landing Zones'
27 details: {
28 parent: {
29 id: rootMg.id
30 }
31 }
32 }
33}
34
35resource productionMg 'Microsoft.Management/managementGroups@2021-04-01' = {
36 name: 'production'
37 properties: {
38 displayName: 'Production'
39 details: {
40 parent: {
41 id: landingZonesMg.id
42 }
43 }
44 }
45}
46
47resource nonProductionMg 'Microsoft.Management/managementGroups@2021-04-01' = {
48 name: 'non-production'
49 properties: {
50 displayName: 'Non-Production'
51 details: {
52 parent: {
53 id: landingZonesMg.id
54 }
55 }
56 }
57}

Policy as Code

Compliance requirements become policy assignments. NIST 800-53 controls map to Azure Policy definitions:

hcl
1# modules/policy-assignments/nist.tf
2
3resource "azurerm_management_group_policy_assignment" "nist_800_53" {
4 name = "nist-800-53-r5"
5 management_group_id = var.management_group_id
6 policy_definition_id = "/providers/Microsoft.Authorization/policySetDefinitions/179d1daa-458f-4e47-8086-2a68d0d6c38f"
7
8 identity {
9 type = "SystemAssigned"
10 }
11
12 location = var.location
13
14 parameters = jsonencode({
15 listOfAllowedLocations = {
16 value = var.allowed_locations
17 }
18 })
19}

In Bicep:

bicep
1// modules/policy-assignments/nist.bicep
2targetScope = 'managementGroup'
3
4param managementGroupId string
5param location string
6param allowedLocations array
7
8resource nistPolicy 'Microsoft.Authorization/policyAssignments@2022-06-01' = {
9 name: 'nist-800-53-r5'
10 location: location
11 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: allowedLocations
20 }
21 }
22 }
23}

Network Topology

Hub-and-spoke is the standard pattern for enterprise Azure networks:

hcl
1# modules/networking/hub.tf
2
3resource "azurerm_virtual_network" "hub" {
4 name = "vnet-hub-${var.environment}"
5 location = var.location
6 resource_group_name = var.resource_group_name
7 address_space = [var.hub_address_space]
8
9 tags = var.tags
10}
11
12resource "azurerm_subnet" "firewall" {
13 name = "AzureFirewallSubnet"
14 resource_group_name = var.resource_group_name
15 virtual_network_name = azurerm_virtual_network.hub.name
16 address_prefixes = [var.firewall_subnet_prefix]
17}
18
19resource "azurerm_subnet" "gateway" {
20 name = "GatewaySubnet"
21 resource_group_name = var.resource_group_name
22 virtual_network_name = azurerm_virtual_network.hub.name
23 address_prefixes = [var.gateway_subnet_prefix]
24}

In Bicep:

bicep
1// modules/networking/hub.bicep
2param environment string
3param location string
4param hubAddressSpace string
5param firewallSubnetPrefix string
6param gatewaySubnetPrefix string
7param tags object
8
9resource hubVnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
10 name: 'vnet-hub-${environment}'
11 location: location
12 tags: tags
13 properties: {
14 addressSpace: {
15 addressPrefixes: [hubAddressSpace]
16 }
17 subnets: [
18 {
19 name: 'AzureFirewallSubnet'
20 properties: {
21 addressPrefix: firewallSubnetPrefix
22 }
23 }
24 {
25 name: 'GatewaySubnet'
26 properties: {
27 addressPrefix: gatewaySubnetPrefix
28 }
29 }
30 ]
31 }
32}
33
34output hubVnetId string = hubVnet.id
35output firewallSubnetId string = hubVnet.properties.subnets[0].id
36output gatewaySubnetId string = hubVnet.properties.subnets[1].id

Centralized Logging

All diagnostic logs route to a central Log Analytics workspace:

hcl
1# modules/logging/main.tf
2
3resource "azurerm_log_analytics_workspace" "central" {
4 name = "law-central-${var.environment}"
5 location = var.location
6 resource_group_name = var.resource_group_name
7 sku = "PerGB2018"
8 retention_in_days = var.retention_days
9
10 tags = var.tags
11}
12
13resource "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.id
17
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:

bicep
1// modules/logging/main.bicep
2param environment string
3param location string
4param retentionDays int = 365
5param tags object
6
7resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
8 name: 'law-central-${environment}'
9 location: location
10 tags: tags
11 properties: {
12 sku: {
13 name: 'PerGB2018'
14 }
15 retentionInDays: retentionDays
16 }
17}
18
19resource activityLogDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
20 name: 'activity-logs-to-law'
21 properties: {
22 workspaceId: logAnalytics.id
23 logs: [
24 {
25 category: 'Administrative'
26 enabled: true
27 }
28 {
29 category: 'Security'
30 enabled: true
31 }
32 {
33 category: 'Policy'
34 enabled: true
35 }
36 ]
37 }
38}
39
40output logAnalyticsId string = logAnalytics.id
41output logAnalyticsWorkspaceId string = logAnalytics.properties.customerId

CI/CD Pipeline

Landing zone changes go through a structured pipeline:

  1. Pull Request — Terraform plan runs, output posted as PR comment
  2. Review — Security and platform teams review the plan
  3. Merge — Terraform apply runs against staging
  4. Promote — After validation, apply to production
yaml
1# .github/workflows/landing-zone.yml
2name: Landing Zone Deployment
3
4on:
5 pull_request:
6 paths:
7 - 'landing-zone/**'
8 push:
9 branches:
10 - main
11 paths:
12 - 'landing-zone/**'
13
14jobs:
15 plan:
16 runs-on: ubuntu-latest
17 steps:
18 - uses: actions/checkout@v4
19
20 - name: Terraform Init
21 run: terraform init
22 working-directory: landing-zone/environments/production
23
24 - name: Terraform Plan
25 run: terraform plan -out=tfplan
26 working-directory: landing-zone/environments/production

For Bicep deployments, replace the Terraform steps with Azure CLI:

yaml
1# Bicep deployment steps
2 - name: Azure Login
3 uses: azure/login@v1
4 with:
5 creds: ${{ secrets.AZURE_CREDENTIALS }}
6
7 - name: Bicep Build
8 run: az bicep build --file main.bicep
9 working-directory: landing-zone/environments/production
10
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.json
18 working-directory: landing-zone/environments/production
19
20 - name: Deploy
21 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.json
28 working-directory: landing-zone/environments/production

State 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:

hcl
1# backend.tf
2
3terraform {
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