PROJECTAdvanced

Azure Landing Zone with Terraform

Deploy enterprise-ready Azure environment with hub-spoke network, Azure Firewall, Log Analytics, Defender for Cloud following Microsoft CAF best practices.

Dylan H.

Cloud Architecture

February 3, 2026
11 min read
8-12 hours
Azure Landing Zone with Terraform

Tools & Technologies

TerraformAzure CLIVS CodeGit

Azure Landing Zone with Terraform

Deploy a production-ready Azure Landing Zone following Microsoft's Cloud Adoption Framework (CAF). This project implements hub-spoke networking, centralized security with Azure Firewall, comprehensive monitoring, and security controls.

Project Overview

What We're Building

┌─────────────────────────────────────────────────────────────────────┐
│                   Azure Landing Zone Architecture                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Management Group Hierarchy                                         │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                    Root Management Group                     │   │
│  │  ├── Platform                                                │   │
│  │  │   ├── Identity (Azure AD, Domain Controllers)            │   │
│  │  │   ├── Management (Monitoring, Automation)                │   │
│  │  │   └── Connectivity (Hub, Firewall, DNS)                  │   │
│  │  ├── Landing Zones                                          │   │
│  │  │   ├── Corp (Internal workloads)                          │   │
│  │  │   └── Online (Internet-facing)                           │   │
│  │  └── Sandbox (Development/Testing)                          │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  Hub-Spoke Network Topology                                         │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                         ┌─────────┐                          │   │
│  │                         │   Hub   │                          │   │
│  │                         │  VNet   │                          │   │
│  │                         │10.0.0.0 │                          │   │
│  │                         └────┬────┘                          │   │
│  │                              │                               │   │
│  │              ┌───────────────┼───────────────┐               │   │
│  │              │               │               │               │   │
│  │         ┌────▼────┐    ┌─────▼─────┐   ┌─────▼─────┐        │   │
│  │         │  Spoke  │    │   Spoke   │   │   Spoke   │        │   │
│  │         │  Prod   │    │    Dev    │   │    DMZ    │        │   │
│  │         │10.1.0.0 │    │ 10.2.0.0  │   │ 10.3.0.0  │        │   │
│  │         └─────────┘    └───────────┘   └───────────┘        │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Prerequisites

  • Azure subscription with Owner access
  • Terraform 1.5+ installed
  • Azure CLI installed and authenticated
  • Git for version control
  • Understanding of Azure networking concepts

Part 1: Foundation Setup

Step 1: Project Structure

azure-landing-zone/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── variables.tf
│       └── terraform.tfvars
├── modules/
│   ├── hub-network/
│   ├── spoke-network/
│   ├── firewall/
│   ├── bastion/
│   ├── log-analytics/
│   └── policy/
├── policies/
│   ├── tagging/
│   ├── network/
│   └── security/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
└── README.md

Step 2: Configure Providers

# providers.tf
terraform {
  required_version = ">= 1.5.0"
 
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.80.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.45.0"
    }
  }
 
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "landing-zone"
    key                  = "terraform.tfstate"
  }
}
 
provider "azurerm" {
  features {
    resource_group {
      prevent_deletion_if_contains_resources = false
    }
    key_vault {
      purge_soft_delete_on_destroy    = false
      recover_soft_deleted_key_vaults = true
    }
  }
}
 
provider "azuread" {}

Step 3: Define Variables

# variables.tf
variable "environment" {
  description = "Environment name"
  type        = string
  default     = "prod"
}
 
variable "location" {
  description = "Primary Azure region"
  type        = string
  default     = "eastus"
}
 
variable "location_secondary" {
  description = "Secondary Azure region for DR"
  type        = string
  default     = "westus2"
}
 
variable "hub_vnet_address_space" {
  description = "Address space for hub VNet"
  type        = list(string)
  default     = ["10.0.0.0/16"]
}
 
variable "spoke_vnets" {
  description = "Configuration for spoke VNets"
  type = map(object({
    address_space  = list(string)
    dns_servers    = list(string)
    subnets        = map(object({
      address_prefix = string
      nsg_rules      = list(any)
    }))
  }))
  default = {}
}
 
variable "tags" {
  description = "Default tags for all resources"
  type        = map(string)
  default = {
    Environment = "Production"
    ManagedBy   = "Terraform"
    Project     = "Landing-Zone"
  }
}

Part 2: Hub Network Module

Step 4: Hub Network Module

# modules/hub-network/main.tf
resource "azurerm_resource_group" "hub" {
  name     = "rg-hub-${var.environment}-${var.location}"
  location = var.location
  tags     = var.tags
}
 
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub-${var.environment}-${var.location}"
  location            = azurerm_resource_group.hub.location
  resource_group_name = azurerm_resource_group.hub.name
  address_space       = var.address_space
  dns_servers         = var.dns_servers
 
  tags = var.tags
}
 
# Gateway Subnet (for VPN/ExpressRoute)
resource "azurerm_subnet" "gateway" {
  name                 = "GatewaySubnet"
  resource_group_name  = azurerm_resource_group.hub.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [cidrsubnet(var.address_space[0], 8, 0)] # /24
}
 
# Azure Firewall Subnet
resource "azurerm_subnet" "firewall" {
  name                 = "AzureFirewallSubnet"
  resource_group_name  = azurerm_resource_group.hub.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [cidrsubnet(var.address_space[0], 8, 1)] # /24
}
 
# Azure Bastion Subnet
resource "azurerm_subnet" "bastion" {
  name                 = "AzureBastionSubnet"
  resource_group_name  = azurerm_resource_group.hub.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [cidrsubnet(var.address_space[0], 8, 2)] # /24
}
 
# Management Subnet
resource "azurerm_subnet" "management" {
  name                 = "snet-management"
  resource_group_name  = azurerm_resource_group.hub.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [cidrsubnet(var.address_space[0], 8, 3)] # /24
}
 
# DNS Subnet
resource "azurerm_subnet" "dns" {
  name                 = "snet-dns"
  resource_group_name  = azurerm_resource_group.hub.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = [cidrsubnet(var.address_space[0], 8, 4)] # /24
 
  delegation {
    name = "dns-resolver"
    service_delegation {
      name = "Microsoft.Network/dnsResolvers"
      actions = [
        "Microsoft.Network/virtualNetworks/subnets/join/action"
      ]
    }
  }
}

Step 5: Azure Firewall Module

# modules/firewall/main.tf
resource "azurerm_public_ip" "firewall" {
  name                = "pip-fw-${var.environment}-${var.location}"
  location            = var.location
  resource_group_name = var.resource_group_name
  allocation_method   = "Static"
  sku                 = "Standard"
  zones               = ["1", "2", "3"]
 
  tags = var.tags
}
 
resource "azurerm_firewall_policy" "main" {
  name                = "fwpol-${var.environment}-${var.location}"
  location            = var.location
  resource_group_name = var.resource_group_name
  sku                 = "Premium"
 
  dns {
    proxy_enabled = true
    servers       = var.dns_servers
  }
 
  threat_intelligence_mode = "Deny"
 
  intrusion_detection {
    mode = "Deny"
  }
 
  tags = var.tags
}
 
resource "azurerm_firewall" "main" {
  name                = "fw-${var.environment}-${var.location}"
  location            = var.location
  resource_group_name = var.resource_group_name
  sku_name            = "AZFW_VNet"
  sku_tier            = "Premium"
  firewall_policy_id  = azurerm_firewall_policy.main.id
  zones               = ["1", "2", "3"]
 
  ip_configuration {
    name                 = "configuration"
    subnet_id            = var.firewall_subnet_id
    public_ip_address_id = azurerm_public_ip.firewall.id
  }
 
  tags = var.tags
}
 
# Network Rule Collection
resource "azurerm_firewall_policy_rule_collection_group" "network" {
  name               = "rcg-network-rules"
  firewall_policy_id = azurerm_firewall_policy.main.id
  priority           = 200
 
  network_rule_collection {
    name     = "AllowAzureServices"
    priority = 210
    action   = "Allow"
 
    rule {
      name                  = "AllowAzureCloud"
      protocols             = ["TCP", "UDP"]
      source_addresses      = ["10.0.0.0/8"]
      destination_addresses = ["AzureCloud"]
      destination_ports     = ["443", "445"]
    }
 
    rule {
      name                  = "AllowDNS"
      protocols             = ["TCP", "UDP"]
      source_addresses      = ["10.0.0.0/8"]
      destination_addresses = ["168.63.129.16"]
      destination_ports     = ["53"]
    }
  }
 
  network_rule_collection {
    name     = "AllowInterVnet"
    priority = 220
    action   = "Allow"
 
    rule {
      name                  = "SpokeToSpoke"
      protocols             = ["Any"]
      source_addresses      = ["10.0.0.0/8"]
      destination_addresses = ["10.0.0.0/8"]
      destination_ports     = ["*"]
    }
  }
}
 
# Application Rule Collection
resource "azurerm_firewall_policy_rule_collection_group" "application" {
  name               = "rcg-application-rules"
  firewall_policy_id = azurerm_firewall_policy.main.id
  priority           = 300
 
  application_rule_collection {
    name     = "AllowMicrosoft"
    priority = 310
    action   = "Allow"
 
    rule {
      name = "WindowsUpdate"
      protocols {
        type = "Https"
        port = 443
      }
      source_addresses  = ["10.0.0.0/8"]
      destination_fqdns = ["*.microsoft.com", "*.windowsupdate.com", "*.windows.com"]
    }
 
    rule {
      name = "AzureManagement"
      protocols {
        type = "Https"
        port = 443
      }
      source_addresses  = ["10.0.0.0/8"]
      destination_fqdns = ["*.azure.com", "*.azure-automation.net", "*.azure-api.net"]
    }
  }
}

Part 3: Spoke Networks and Peering

Step 6: Spoke Network Module

# modules/spoke-network/main.tf
resource "azurerm_resource_group" "spoke" {
  name     = "rg-spoke-${var.spoke_name}-${var.environment}"
  location = var.location
  tags     = var.tags
}
 
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke-${var.spoke_name}-${var.environment}"
  location            = azurerm_resource_group.spoke.location
  resource_group_name = azurerm_resource_group.spoke.name
  address_space       = var.address_space
  dns_servers         = var.dns_servers
 
  tags = var.tags
}
 
# Dynamic subnet creation
resource "azurerm_subnet" "subnets" {
  for_each = var.subnets
 
  name                 = each.key
  resource_group_name  = azurerm_resource_group.spoke.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = [each.value.address_prefix]
 
  dynamic "delegation" {
    for_each = each.value.delegation != null ? [each.value.delegation] : []
    content {
      name = delegation.value.name
      service_delegation {
        name    = delegation.value.service
        actions = delegation.value.actions
      }
    }
  }
}
 
# NSG for each subnet
resource "azurerm_network_security_group" "subnets" {
  for_each = var.subnets
 
  name                = "nsg-${each.key}-${var.spoke_name}"
  location            = azurerm_resource_group.spoke.location
  resource_group_name = azurerm_resource_group.spoke.name
 
  dynamic "security_rule" {
    for_each = each.value.nsg_rules
    content {
      name                       = security_rule.value.name
      priority                   = security_rule.value.priority
      direction                  = security_rule.value.direction
      access                     = security_rule.value.access
      protocol                   = security_rule.value.protocol
      source_port_range          = security_rule.value.source_port_range
      destination_port_range     = security_rule.value.destination_port_range
      source_address_prefix      = security_rule.value.source_address_prefix
      destination_address_prefix = security_rule.value.destination_address_prefix
    }
  }
 
  tags = var.tags
}
 
# Associate NSGs with subnets
resource "azurerm_subnet_network_security_group_association" "subnets" {
  for_each = var.subnets
 
  subnet_id                 = azurerm_subnet.subnets[each.key].id
  network_security_group_id = azurerm_network_security_group.subnets[each.key].id
}
 
# Peering to Hub
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                         = "peer-to-hub"
  resource_group_name          = azurerm_resource_group.spoke.name
  virtual_network_name         = azurerm_virtual_network.spoke.name
  remote_virtual_network_id    = var.hub_vnet_id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  allow_gateway_transit        = false
  use_remote_gateways          = var.use_remote_gateways
}
 
# Hub to Spoke peering
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                         = "peer-to-${var.spoke_name}"
  resource_group_name          = var.hub_resource_group_name
  virtual_network_name         = var.hub_vnet_name
  remote_virtual_network_id    = azurerm_virtual_network.spoke.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = true
  allow_gateway_transit        = true
  use_remote_gateways          = false
}
 
# Route table with firewall as next hop
resource "azurerm_route_table" "spoke" {
  name                = "rt-spoke-${var.spoke_name}"
  location            = azurerm_resource_group.spoke.location
  resource_group_name = azurerm_resource_group.spoke.name
 
  route {
    name                   = "to-firewall"
    address_prefix         = "0.0.0.0/0"
    next_hop_type          = "VirtualAppliance"
    next_hop_in_ip_address = var.firewall_private_ip
  }
 
  tags = var.tags
}
 
resource "azurerm_subnet_route_table_association" "spoke" {
  for_each = {
    for k, v in var.subnets : k => v
    if !contains(["GatewaySubnet", "AzureFirewallSubnet", "AzureBastionSubnet"], k)
  }
 
  subnet_id      = azurerm_subnet.subnets[each.key].id
  route_table_id = azurerm_route_table.spoke.id
}

Part 4: Monitoring and Security

Step 7: Log Analytics and Monitoring

# modules/log-analytics/main.tf
resource "azurerm_resource_group" "monitoring" {
  name     = "rg-monitoring-${var.environment}"
  location = var.location
  tags     = var.tags
}
 
resource "azurerm_log_analytics_workspace" "main" {
  name                = "log-${var.environment}-${var.location}"
  location            = azurerm_resource_group.monitoring.location
  resource_group_name = azurerm_resource_group.monitoring.name
  sku                 = "PerGB2018"
  retention_in_days   = 90
 
  tags = var.tags
}
 
# Enable solutions
resource "azurerm_log_analytics_solution" "security" {
  solution_name         = "Security"
  location              = azurerm_resource_group.monitoring.location
  resource_group_name   = azurerm_resource_group.monitoring.name
  workspace_resource_id = azurerm_log_analytics_workspace.main.id
  workspace_name        = azurerm_log_analytics_workspace.main.name
 
  plan {
    publisher = "Microsoft"
    product   = "OMSGallery/Security"
  }
}
 
resource "azurerm_log_analytics_solution" "network" {
  solution_name         = "NetworkMonitoring"
  location              = azurerm_resource_group.monitoring.location
  resource_group_name   = azurerm_resource_group.monitoring.name
  workspace_resource_id = azurerm_log_analytics_workspace.main.id
  workspace_name        = azurerm_log_analytics_workspace.main.name
 
  plan {
    publisher = "Microsoft"
    product   = "OMSGallery/NetworkMonitoring"
  }
}
 
# Diagnostic settings for firewall
resource "azurerm_monitor_diagnostic_setting" "firewall" {
  name                       = "diag-firewall"
  target_resource_id         = var.firewall_id
  log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
 
  enabled_log {
    category = "AzureFirewallApplicationRule"
  }
 
  enabled_log {
    category = "AzureFirewallNetworkRule"
  }
 
  enabled_log {
    category = "AzureFirewallDnsProxy"
  }
 
  metric {
    category = "AllMetrics"
    enabled  = true
  }
}

Step 8: Azure Policy Module

# modules/policy/main.tf
 
# Require tags on resource groups
resource "azurerm_policy_definition" "require_tags" {
  name         = "require-tags-on-rg"
  policy_type  = "Custom"
  mode         = "All"
  display_name = "Require specified tags on resource groups"
 
  policy_rule = jsonencode({
    if = {
      allOf = [
        {
          field  = "type"
          equals = "Microsoft.Resources/subscriptions/resourceGroups"
        },
        {
          field  = "[concat('tags[', parameters('tagName'), ']')]"
          exists = "false"
        }
      ]
    }
    then = {
      effect = "deny"
    }
  })
 
  parameters = jsonencode({
    tagName = {
      type = "String"
      metadata = {
        displayName = "Tag Name"
        description = "Name of the tag to require"
      }
    }
  })
}
 
# Allowed locations
resource "azurerm_policy_assignment" "allowed_locations" {
  name                 = "allowed-locations"
  scope                = var.scope
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c"
  display_name         = "Allowed locations"
 
  parameters = jsonencode({
    listOfAllowedLocations = {
      value = var.allowed_locations
    }
  })
}
 
# Enable Defender for Cloud
resource "azurerm_security_center_subscription_pricing" "defender" {
  for_each = toset([
    "VirtualMachines",
    "SqlServers",
    "AppServices",
    "StorageAccounts",
    "KeyVaults",
    "Dns",
    "Arm"
  ])
 
  tier          = "Standard"
  resource_type = each.value
}

Part 5: Main Deployment

Step 9: Main Configuration

# main.tf
locals {
  spoke_configs = {
    prod = {
      address_space = ["10.1.0.0/16"]
      subnets = {
        "snet-web" = {
          address_prefix = "10.1.1.0/24"
          delegation     = null
          nsg_rules = [
            {
              name                       = "AllowHTTPS"
              priority                   = 100
              direction                  = "Inbound"
              access                     = "Allow"
              protocol                   = "Tcp"
              source_port_range          = "*"
              destination_port_range     = "443"
              source_address_prefix      = "*"
              destination_address_prefix = "*"
            }
          ]
        }
        "snet-app" = {
          address_prefix = "10.1.2.0/24"
          delegation     = null
          nsg_rules      = []
        }
        "snet-data" = {
          address_prefix = "10.1.3.0/24"
          delegation     = null
          nsg_rules      = []
        }
      }
    }
    dev = {
      address_space = ["10.2.0.0/16"]
      subnets = {
        "snet-dev" = {
          address_prefix = "10.2.1.0/24"
          delegation     = null
          nsg_rules      = []
        }
      }
    }
  }
}
 
# Hub Network
module "hub_network" {
  source = "./modules/hub-network"
 
  environment   = var.environment
  location      = var.location
  address_space = var.hub_vnet_address_space
  dns_servers   = ["168.63.129.16"]
  tags          = var.tags
}
 
# Azure Firewall
module "firewall" {
  source = "./modules/firewall"
 
  environment         = var.environment
  location            = var.location
  resource_group_name = module.hub_network.resource_group_name
  firewall_subnet_id  = module.hub_network.firewall_subnet_id
  dns_servers         = ["168.63.129.16"]
  tags                = var.tags
}
 
# Spoke Networks
module "spoke_networks" {
  source   = "./modules/spoke-network"
  for_each = local.spoke_configs
 
  spoke_name              = each.key
  environment             = var.environment
  location                = var.location
  address_space           = each.value.address_space
  subnets                 = each.value.subnets
  dns_servers             = [module.firewall.private_ip_address]
  hub_vnet_id             = module.hub_network.vnet_id
  hub_vnet_name           = module.hub_network.vnet_name
  hub_resource_group_name = module.hub_network.resource_group_name
  firewall_private_ip     = module.firewall.private_ip_address
  use_remote_gateways     = false
  tags                    = var.tags
}
 
# Monitoring
module "monitoring" {
  source = "./modules/log-analytics"
 
  environment = var.environment
  location    = var.location
  firewall_id = module.firewall.id
  tags        = var.tags
}
 
# Bastion
module "bastion" {
  source = "./modules/bastion"
 
  environment         = var.environment
  location            = var.location
  resource_group_name = module.hub_network.resource_group_name
  subnet_id           = module.hub_network.bastion_subnet_id
  tags                = var.tags
}

Verification Checklist

Infrastructure:

  • Hub VNet deployed with all subnets
  • Azure Firewall operational
  • Spoke VNets created and peered
  • Route tables configured
  • NSGs applied

Security:

  • Firewall rules configured
  • Defender for Cloud enabled
  • Azure Policy assigned
  • Diagnostic settings configured

Networking:

  • DNS resolution working
  • Spoke-to-spoke via firewall
  • Internet egress via firewall
  • Bastion connectivity working

Resources


Questions? Reach out in our community Discord!