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.mdStep 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!