Introduction

In this project, I'll be designing and deploying a reusable Microsoft Azure Landing Zone for small and medium-sized enterprises (SMEs) using Terraform.

An Azure Landing Zone provides a standardised foundation for deploying cloud infrastructure, covering governance, security, networking and monitoring.

Enterprise landing zones can introduce unnecessary cost and complexity for smaller organisations. The objective of this project is to build a secure, lightweight and cost-effective Azure foundation that can be reused across different customer environments.

The solution will initially use a single Azure subscription, with Terraform Infrastructure as Code (IaC) providing consistent and repeatable deployments.

What Is an Azure Landing Zone?

An Azure Landing Zone establishes the foundation for securely deploying and managing Azure workloads.

Its key components include:

  • Management Groups: Organise subscriptions and apply governance.

  • Azure Policy: Enforce organisational standards.

  • RBAC: Control access to Azure resources.

  • Networking: Provide secure connectivity.

  • Security: Protect infrastructure and workloads.

  • Monitoring: Centralise logging and operational visibility.

  • Terraform: Automate infrastructure deployments.

For this SME project, I'll focus on essential services without introducing unnecessary complexity, such as dedicated Azure Firewalls or hub-and-spoke networking.

Part 1: Management Group Foundation

The first stage involves creating the management group hierarchy using Terraform.

Management groups allow Azure subscriptions to be organised logically, with policies and access controls inherited from parent groups.

Architecture Design

The following hierarchy has been implemented:

Tenant Root Group
│
└── MSP-LandingZone
    │
    ├── Platform
    ├── LandingZones
    └── Sandbox
        └── Lab Subscription

MSP-LandingZone

The parent management group, providing a central location for governance.

Platform

Reserved for shared infrastructure services if required in future deployments.

LandingZones

Reserved for production and non-production workload subscriptions.

Sandbox

Used for development and testing. My existing Azure subscription has been associated with this management group.

Although the initial SME deployment uses a single subscription, the structure supports future expansion without requiring additional subscriptions from the outset.

Terraform Project Structure

The initial project uses the following files:

azure-landing-zone/
├── providers.tf
├── variables.tf
├── main.tf
├── outputs.tf
└── terraform.tfvars

As the project develops, reusable modules will be introduced for management groups, governance, networking, RBAC and monitoring.

Configuring the Terraform Provider

providers.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

The AzureRM provider allows Terraform to manage Azure resources.

Defining Terraform Variables

variables.tf

variable "subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "management_group_prefix" {
  description = "Management group prefix"
  type        = string
  default     = "alzlab"
}

terraform.tfvars

subscription_id         = "YOUR-SUBSCRIPTION-ID"
management_group_prefix = "alzlab"

Variables allow the configuration to be reused across environments without modifying the underlying code.

Deploying the Management Groups

main.tf

resource "azurerm_management_group" "root" {
  name         = "${var.management_group_prefix}-root"
  display_name = "MSP-LandingZone"
}

resource "azurerm_management_group" "platform" {
  name         = "${var.management_group_prefix}-platform"
  display_name = "Platform"

  parent_management_group_id = azurerm_management_group.root.id
}

resource "azurerm_management_group" "landing_zones" {
  name         = "${var.management_group_prefix}-landingzones"
  display_name = "LandingZones"

  parent_management_group_id = azurerm_management_group.root.id
}

resource "azurerm_management_group" "sandbox" {
  name         = "${var.management_group_prefix}-sandbox"
  display_name = "Sandbox"

  parent_management_group_id = azurerm_management_group.root.id
}

This creates four management groups, with Platform, LandingZones and Sandbox nested beneath MSP-LandingZone.

Associating the Subscription

resource "azurerm_management_group_subscription_association" "lab" {
  management_group_id = azurerm_management_group.sandbox.id
  subscription_id     = "/subscriptions/${var.subscription_id}"
}

This associates the existing Azure subscription with Sandbox, allowing governance policies to be inherited from the management group hierarchy.

Deploying the Infrastructure

Authenticate to Azure

az login
az account set --subscription "YOUR-SUBSCRIPTION-ID"

Initialise and validate Terraform

terraform init
terraform fmt
terraform validate

Review and apply the deployment

terraform plan -out=tfplan
terraform apply tfplan

These commands initialise Terraform, validate the configuration and deploy the defined infrastructure.

Deployment Results

The management group hierarchy has been created, with the existing subscription associated with Sandbox.

(Ignore the 2 existing subscriptions in the Tenant Root Group)

To verify Terraform detects no outstanding configuration changes:

note
If any resources were originally created manually, they must be imported into Terraform state before being managed through Terraform.

Part 2: Governance and Access

Now that you've created your management group hierarchy and associated your subscription with Sandbox, we'll implement Azure Policy, resource tagging and Role-Based Access Control (RBAC).

The aim is to establish a secure, consistent baseline for your SME Azure Landing Zone before deploying networking and workloads.

What we're building

ComponentConfiguration
Allowed LocationsUK South and UK West
Required TagsEnvironment, Owner, CostCentre
RBACAdministrator and Reader access
ScopeSandbox subscription
DeploymentTerraform

We'll start with Azure Policy and test it before introducing RBAC.

important
Because Sandbox is your existing subscription, check whether it contains other resources before applying restrictive policies. We can begin with audit policies and switch to enforcement after validation.

Allowed Azure Regions

We'll use Microsoft's built-in Allowed locations policy rather than writing a custom policy.

Create governance.tf

Inside your existing Terraform project, create a new file:

azure-landing-zone/
├── providers.tf
├── variables.tf
├── main.tf
├── governance.tf     ← New
├── outputs.tf
└── terraform.tfvars

Add the following to governance.tf:

# Retrieve Microsoft's built-in policy
data "azurerm_policy_definition" "allowed_locations" {
  display_name = "Allowed locations"
}

# Assign policy to the Sandbox subscription
resource "azurerm_subscription_policy_assignment" "allowed_locations" {
  name         = "allowed-locations"
  display_name = "SME - Allowed Azure Regions"
  description  = "Restrict resources to approved UK regions."

  subscription_id      = "/subscriptions/${var.subscription_id}"
  policy_definition_id = data.azurerm_policy_definition.allowed_locations.id

  enforce = false

  parameters = jsonencode({
    listOfAllowedLocations = {
      value = var.allowed_locations
    }
  })
}

This assigns the built-in policy at subscription scope.

Setting enforce = false means the policy can evaluate compliance without blocking deployments during initial testing.

Add the following to variables.tf:

variable "allowed_locations" {
  description = "List of allowed Azure regions"
  type        = list(string)
}

Add the following to terraform.tfvars:

allowed_locations = ["uksouth", "ukwest"]

Deploy through Terraform

Run:

terraform fmt
terraform validate
terraform plan
terraform apply

Review the plan before approving the deployment. It should show the new policy assignment being created without unexpectedly changing your management groups.

Verify in Azure

Navigate to:

Azure Portal > Policy > Assignments

Select the Sandbox subscription scope and look for:

SME - Allowed Azure Regions

Then check Policy → Compliance once evaluation results are available.

Test the policy

Create two disposable resource groups or test resources:

TestRegionExpected result
TestNetwork-UKSUK SouthCompliant
TestNetwork-EUSEast USNon-compliant when evaluated
note
Be aware that the Allowed locations policy primarily evaluates resource locations. RG's have a separate built-in allowed-locations policy. For this test, use a supported location-based resource, rather than assuming a resource group will be blocked.

Once you've confirmed the evaluation works, change:

enforce = true

Apply again and test an attempted deployment outside the allowed regions. It should now be denied, subject to the policy's exemptions and exclusions.

Required Resource Tags

Next, we'll configure Azure Policy to enforce resource tagging across your SME Landing Zone using Terraform.

This helps organisations identify resource ownership, track costs and maintain consistent governance.

Define the required tags

We'll use three tags:

TagExamplePurpose
EnvironmentProductionIdentifies the environment
OwnerITIdentifies responsibility
CostCentreIT-001Cost allocation

Update variables.tf

Add:

variable "required_tags" {
  description = "Required Azure resource tags"
  type        = set(string)
  default = [
    "Environment",
    "Owner",
    "CostCentre"
  ]
}

Update governance.tf

Keep your existing Allowed Locations policy and add:

# Retrieve the built-in Azure tagging policy
data "azurerm_policy_definition" "required_tag" {
  display_name = "Require a tag on resources"
}

# Create a policy assignment for each required tag
resource "azurerm_subscription_policy_assignment" "required_tags" {
  for_each = var.required_tags

  name                 = "require-${lower(each.value)}"
  display_name         = "SME - Require ${each.value}"
  subscription_id      = "/subscriptions/${var.subscription_id}"
  policy_definition_id = data.azurerm_policy_definition.required_tag.id

  enforce = false

  parameters = jsonencode({
    tagName = {
      value = each.value
    }
  })
}
tip
How it works: Terraform's for_each creates three policy assignments from one resource definition.

We're starting with enforce = false, so policies evaluate compliance without denying deployments.

Deploy with Terraform

terraform fmt
terraform validate
terraform plan
terraform apply

The expected result is three new policy assignments:

Test the tagging policies

In the Azure Portal, navigate to Policy → Compliance, selecting your Sandbox subscription.

Create a temporary VNet without the required tags and check the compliance results after policy evaluation.

Then create another VNet with:

tags = {
  Environment = "Development"
  Owner       = "IT"
  CostCentre  = "IT-001"
}

Verify the tagged resource is compliant. Allow time for Azure Policy evaluation; results are not necessarily immediate.

Enable enforcement

Once testing is successful, update:

enforce = true

Run:

terraform plan
terraform apply

Try creating a temporary VNet without tags again. The deployment should now be denied by Azure Policy.

note
Be aware that the built-in policy checks whether tags exist, not whether they contain meaningful values. It also does not automatically tag existing resources.

Instead of manually creating Entra ID security groups, we'll use Terraform to automate both group creation and Azure RBAC assignments.

We'll use two Terraform providers:

  • AzureAD provider - Creates the Microsoft Entra ID security groups.

  • AzureRM provider - Assigns Contributor and Reader permissions to those groups.

This means when you deploy the landing zone for another customer, Terraform can create their security groups and configure their Azure permissions automatically.

Azure RBAC

Update providers.tf

Add the azuread provider under your existing azurerm provider.

provider "azuread" {
  tenant_id = var.tenant_id
}

Update variables.tf

Keep your existing variables and add:

variable "tenant_id" {
  description = "Azure AD tenant ID"
  type        = string
}

variable "customer_name" {
  description = "Customer identifier for resource naming"
  type        = string
}

Then add the corresponding values to terraform.tfvars:

tenant_id       = "YOUR-TENANT-ID"
customer_name   = "PaulPopaCloud"

This allows each customer deployment to use its own Entra tenant and naming convention.

Create rbac.tf

We'll create both Entra ID groups and assign their Azure roles automatically.

# Get the current Terraform identity
data "azuread_client_config" "current" {}

# Create security groups
resource "azuread_group" "admins" {
  display_name            = "SG-${var.customer_name}-Azure-Admins"
  security_enabled        = true
  prevent_duplicate_names = true

  owners = [
    data.azuread_client_config.current.object_id
  ]
}

resource "azuread_group" "readers" {
  display_name            = "SG-${var.customer_name}-Azure-Readers"
  security_enabled        = true
  prevent_duplicate_names = true

  owners = [
    data.azuread_client_config.current.object_id
  ]
}

# Assign Contributor role
resource "azurerm_role_assignment" "admins" {
  scope                = "/subscriptions/${var.subscription_id}"
  role_definition_name = "Contributor"
  principal_id         = azuread_group.admins.object_id
  principal_type       = "Group"
}

# Assign Reader role
resource "azurerm_role_assignment" "readers" {
  scope                = "/subscriptions/${var.subscription_id}"
  role_definition_name = "Reader"
  principal_id         = azuread_group.readers.object_id
  principal_type       = "Group"
}

Terraform uses the group Object IDs automatically, so there's no need to copy them manually.

Deploy everything

Run:

terraform init -upgrade
terraform fmt
terraform validate
terraform plan
terraform apply

Expected result

  • Two Entra ID security groups created.

  • Contributor assigned to the administrators group.

  • Reader assigned to the readers group.

  • All resources tracked within Terraform state.

important
Your authentication identity needs permission to create Entra groups and assign Azure roles. Typically this means suitable Microsoft Entra group-management permissions plus Owner or Role Based Access Control Administrator permissions at subscription scope. An account with Contributor alone won't be sufficient.

Verify the deployment

In Microsoft Entra ID > Groups, confirm both groups exist.

Then go to Subscriptions > Sandbox Subscription > Access control (IAM) and check:

The groups will initially have no members, other than the configured owner relationship. You can add test users to validate their access. Group membership can also be automated through Terraform later.

Part 3: Networking

Now that we've configured management groups, Azure Policy and RBAC, we'll build the networking foundation for your SME Landing Zone.

Keeping it simple: one VNet, three subnets and Network Security Groups (NSGs). No Azure Firewall or hub-and-spoke architecture.

Network Architecture

vnet-sme-dev-uks-01 · 10.10.0.0/22 · UK South

SubnetAddress rangePurpose
Application Subnet10.10.1.0/24Application VMs and services
Data Subnet10.10.2.0/24Database and backend workloads
Private Endpoint Subnet10.10.3.0/24Private access to Azure PaaS services

Virtual Network and Subnets

Create networking.tf in your existing Terraform project and add:

# Networking resource group
resource "azurerm_resource_group" "network" {
  name     = "rg-${var.customer_name}-network-dev-uks-01"
  location = "uksouth"

  tags = {
    Environment = "Development"
    Owner       = "IT"
    CostCentre  = "IT-001"
  }
}

# Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "vnet-${var.customer_name}-dev-uks-01"
  address_space       = ["10.10.0.0/22"]
  location            = azurerm_resource_group.network.location
  resource_group_name = azurerm_resource_group.network.name

  tags = azurerm_resource_group.network.tags
}

# Application subnet
resource "azurerm_subnet" "app" {
  name                 = "snet-app"
  resource_group_name  = azurerm_resource_group.network.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.10.1.0/24"]
}

# Data subnet
resource "azurerm_subnet" "data" {
  name                 = "snet-data"
  resource_group_name  = azurerm_resource_group.network.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.10.2.0/24"]
}

# Private Endpoint subnet
resource "azurerm_subnet" "private_endpoints" {
  name                 = "snet-private-endpoints"
  resource_group_name  = azurerm_resource_group.network.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.10.3.0/24"]
}

This creates the resource group, VNet and subnets.

Network Security Groups

We'll create an NSG for the application subnet and associate it using Terraform.

Add the following to networking.tf:

# Application NSG
resource "azurerm_network_security_group" "app" {
  name                = "nsg-${var.customer_name}-app-dev-uks-01"
  location            = azurerm_resource_group.network.location
  resource_group_name = azurerm_resource_group.network.name

  tags = azurerm_resource_group.network.tags
}

# Associate NSG with application subnet
resource "azurerm_subnet_network_security_group_association" "app" {
  subnet_id                 = azurerm_subnet.app.id
  network_security_group_id = azurerm_network_security_group.app.id
}

An NSG has default rules, including inbound Internet traffic denial. We'll add workload-specific rules later rather than opening unnecessary ports now.

The data subnet should also receive an NSG before hosting workloads. The private endpoint subnet will require its own carefully considered security configuration when we deploy private endpoints.

Deploy

Run:

terraform fmt
terraform validate
terraform plan
terraform apply

Review the plan to ensure Terraform isn't unexpectedly modifying your existing management groups, policies or RBAC assignments.

Verify in Azure

Navigate to Azure Portal > Virtual Networks > Settings > Subnets and confirm:

warning
Watch for your Azure Policy assignments: If your required-tag policies are already enforced, ensure any newly deployed tag-supporting resources comply. The resource group and VNet in this example have all three tags.

Securing Traffic Between Subnets

Now that you've deployed your VNet and three subnets using Terraform, we'll configure Network Security Group (NSG) rules to control traffic between them.

The goal is to prevent unnecessary access to your application and data subnets.

Our security design

TrafficActionReason
App > Data (TCP 1433)AllowExample SQL Server connection
App > Data (other ports)DenyRestrict unnecessary access
Data > App (new connections)DenyPrevent data-tier initiated connections
Internet > AppDenyNo public inbound access
Internet > DataDenyProtect backend resources

Azure NSGs are stateful, so return traffic for an allowed connection is automatically permitted.

We'll use SQL Server port 1433 as a lab example. Only permit this when your actual workload requires it.

Update networking.tf

You already have an NSG associated with your application subnet.

Add a separate NSG for your data subnet:

# Data NSG
resource "azurerm_network_security_group" "data" {
  name                = "nsg-${var.customer_name}-data-dev-uks-01"
  location            = azurerm_resource_group.network.location
  resource_group_name = azurerm_resource_group.network.name

  tags = azurerm_resource_group.network.tags
}

# Associate NSG with data subnet
resource "azurerm_subnet_network_security_group_association" "data" {
  subnet_id                 = azurerm_subnet.data.id
  network_security_group_id = azurerm_network_security_group.data.id
}

Allow application-to-database traffic

Add this rule to the data NSG:

resource "azurerm_network_security_rule" "allow_app_sql" {
  name                        = "Allow-App-To-SQL"
  priority                    = 100
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "1433"
  source_address_prefix       = "10.10.1.0/24"
  destination_address_prefix  = "10.10.2.0/24"
  resource_group_name         = azurerm_resource_group.network.name
  network_security_group_name = azurerm_network_security_group.data.name
}

This permits TCP 1433 connections from the application subnet to the data subnet.

Deny other application-to-data traffic

resource "azurerm_network_security_rule" "deny_app_to_data" {
  name                        = "Deny-App-To-Data"
  priority                    = 200
  direction                   = "Inbound"
  access                      = "Deny"
  protocol                    = "*"
  source_port_range           = "*"
  destination_port_range      = "*"
  source_address_prefix       = "10.10.1.0/24"
  destination_address_prefix  = "10.10.2.0/24"
  resource_group_name         = azurerm_resource_group.network.name
  network_security_group_name = azurerm_network_security_group.data.name
}

Because the allow rule has a higher priority (lower number), SQL traffic is permitted and other application-to-data traffic is denied.

Restrict data-to-application traffic

Add an outbound rule to the data NSG:

resource "azurerm_network_security_rule" "deny_data_to_app" {
  name                        = "Deny-Data-To-App"
  priority                    = 200
  direction                   = "Outbound"
  access                      = "Deny"
  protocol                    = "*"
  source_port_range           = "*"
  destination_port_range      = "*"
  source_address_prefix       = "10.10.2.0/24"
  destination_address_prefix  = "10.10.1.0/24"
  resource_group_name         = azurerm_resource_group.network.name
  network_security_group_name = azurerm_network_security_group.data.name
}

This blocks new data-to-application connections while allowing return traffic for established, permitted connections.

Important: Block other unwanted VNet traffic

Azure NSGs include a default AllowVNetInBound rule. The rules above only restrict traffic between the application and data subnets, they do not isolate the data subnet from every other subnet or connected network.

To establish a stricter baseline, add:

resource "azurerm_network_security_rule" "deny_other_data_inbound" {
  name                        = "Deny-Other-VNet-Inbound"
  priority                    = 300
  direction                   = "Inbound"
  access                      = "Deny"
  protocol                    = "*"
  source_port_range           = "*"
  destination_port_range      = "*"
  source_address_prefix       = "VirtualNetwork"
  destination_address_prefix  = "10.10.2.0/24"
  resource_group_name         = azurerm_resource_group.network.name
  network_security_group_name = azurerm_network_security_group.data.name
}

This provides broader inbound isolation for the data subnet, but it must be reviewed if you later add DNS servers, monitoring agents, hybrid connectivity or other services requiring network access.

note
Because the VirtualNetwork tag also covers the data subnet itself, this rule blocks traffic between VMs inside the data subnet too. If you later run something that needs them to talk to each other, such as a SQL cluster, add an allow rule for that traffic above priority 300.

Deploy

terraform fmt
terraform validate
terraform plan
terraform apply

Review the plan to confirm Terraform creates the new data NSG, subnet association and security rules.

Test the rules

Navigate to Azure Portal > Network Security Groups > Effective security rules on a VM's network interface, once test VMs have been deployed.

You can also use Azure Network Watcher connectivity tests to validate allowed and denied traffic.

TestExpected
App VM > Data VM TCP 1433Allowed by NSG
App VM > Data VM TCP 445Denied
Data VM > App VM TCP 3389Denied
Internet > Data VMDenied by default inbound rules
note
An NSG allowing TCP 1433 does not guarantee a successful SQL connection. The destination must have a service listening on that port, with its host firewall permitting the traffic.

Part 4: Monitoring and Logging

Next, we'll configure centralised monitoring for your SME Azure Landing Zone.

Since we've skipped deploying test VMs, we'll focus on monitoring the Azure subscription and networking infrastructure you've already created.

What we're building

ComponentPurpose
Log Analytics WorkspaceCentral location for diagnostic logs
Azure MonitorMonitor activity and resource metrics
Diagnostic SettingsForward subscription activity logs
Action GroupSend email notifications
Activity Log AlertDetect resource deletion events

We'll also configure 30-day log retention to help control costs.

Log Analytics Workspace

Create monitoring.tf and start with a resource group and Log Analytics workspace.

# Monitoring Resource Group
resource "azurerm_resource_group" "monitoring" {
  name     = "rg-${var.customer_name}-monitoring-dev-uks-01"
  location = "uksouth"

  tags = {
    Environment = "Development"
    Owner       = "IT"
    CostCentre  = "IT-001"
  }
}

# Log Analytics Workspace
# Centralised storage for Azure logs and diagnostics
resource "azurerm_log_analytics_workspace" "main" {
  name                = "law-${var.customer_name}-dev-uks-01"
  location            = azurerm_resource_group.monitoring.location
  resource_group_name = azurerm_resource_group.monitoring.name

  sku               = "PerGB2018"
  retention_in_days = 30

  tags = azurerm_resource_group.monitoring.tags
}

This creates a workspace where we can collect and query logs using Kusto Query Language (KQL).

note
Cost: Log Analytics charges can depend on data ingestion and retention. Thirty days of retention does not mean logging is free, so we'll only collect useful categories.

Subscription Diagnostic Settings

We'll send Azure subscription activity logs into Log Analytics.

Add this to monitoring.tf:

# Subscription Diagnostic Settings
# Forward Azure Activity Logs to the Log Analytics Workspace
resource "azurerm_monitor_diagnostic_setting" "subscription" {
  name               = "diag-sme-subscription"
  target_resource_id = "/subscriptions/${var.subscription_id}"

  log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id

  # Administrative operations such as resource creation and deletion
  enabled_log {
    category = "Administrative"
  }

  # Azure Policy compliance and enforcement events
  enabled_log {
    category = "Policy"
  }

  # Security-related Azure Activity Log events
  enabled_log {
    category = "Security"
  }
}

This collects administrative operations, policy events and security-related Azure Activity Log events.

It doesn't automatically collect guest operating system logs, VNet flow logs or every resource's diagnostic logs.

Email Alerts

We'll create an Azure Monitor Action Group.

Add to variables.tf

# Email address used for Azure Monitor notifications
variable "alert_email" {
  description = "Email address for Azure monitoring alerts"
  type        = string
}

Add to terraform.tfvars

alert_email = "your-email@example.com"

Add to monitoring.tf

# Azure Monitor Action Group
# Sends email notifications when monitoring alerts are triggered
resource "azurerm_monitor_action_group" "main" {
  name                = "ag-${var.customer_name}-dev-01"
  resource_group_name = azurerm_resource_group.monitoring.name
  short_name          = "smealerts"

  email_receiver {
    name          = "IT-Notifications"
    email_address = var.alert_email
  }

  tags = azurerm_resource_group.monitoring.tags
}

Resource Group Deletion Alert

We'll detect when someone initiates a resource group deletion.

# Resource Group Deletion Alert
# Notify administrators when a resource group deletion is initiated
resource "azurerm_monitor_activity_log_alert" "rg_delete" {
  name                = "alert-resource-group-delete"
  resource_group_name = azurerm_resource_group.monitoring.name
  location            = "global"
  scopes              = ["/subscriptions/${var.subscription_id}"]

  description = "Notify IT when a resource group deletion is initiated."

  # Monitor resource group deletion events
  criteria {
    category       = "Administrative"
    operation_name = "Microsoft.Resources/subscriptions/resourceGroups/delete"
  }

  # Send alerts to the configured Action Group
  action {
    action_group_id = azurerm_monitor_action_group.main.id
  }

  tags = azurerm_resource_group.monitoring.tags
}

This monitors subscription Activity Log events for the resource-group delete operation. It does not replace resource locks or preventive access controls.

Deploy

Run:

terraform fmt
terraform validate
terraform plan
terraform apply

Your plan should include approximately five new resources:

  • Monitoring resource group

  • Log Analytics workspace

  • Subscription diagnostic setting

  • Action group

  • Activity Log alert

Check that the plan doesn't remove or unexpectedly modify existing landing zone resources.

Validate the Deployment

Once deployed, verify the following:

Azure Portal LocationWhat to check
Log Analytics WorkspacesWorkspace exists in UK South
Monitor > Diagnostic settingsSubscription logs are exported
Monitor > Alerts > Action groupsEmail receiver is configured
Monitor > Alerts > Alert rulesResource group deletion alert exists

Test Log Analytics

Open your Log Analytics workspace > Logs and run:

AzureActivity
| where TimeGenerated > ago(24h)
| project TimeGenerated, OperationNameValue,
          ActivityStatusValue, Caller
| order by TimeGenerated desc

This displays recent ingested Azure Activity Log events. Allow time for log ingestion; an empty result doesn't necessarily mean the configuration failed.

Test your alert

In Azure Monitor, open the Action Group and use its Test feature to check email delivery.

warning
You can also trigger and inspect a harmless, authorised activity event to confirm that subscription logging works. Don't delete a production resource group just to test an alert.

Part 5: Remote Terraform State

Now we'll make your SME Azure Landing Zone reusable across different customers.

So far, we've built Terraform configurations for management groups, governance, RBAC, networking and monitoring.

The next step is to separate those components into reusable modules and store Terraform state securely in Azure.

We'll start with remote state because it's important to protect your infrastructure state before introducing automated customer deployments.

Why Remote State

Currently, Terraform likely stores your infrastructure state locally in terraform.tfstate.

For an MSP solution, that's not ideal because state needs to be protected, backed up and accessible to authorised deployment processes.

We'll use:

ResourcePurpose
Azure Storage AccountSecurely stores Terraform state
Blob ContainerHolds state files
Azure RBACControls who can access state
Blob VersioningHelps recover previous state versions
State LockingPrevents simultaneous state modifications

We'll create this storage separately from your existing landing zone configuration to avoid Terraform depending on the backend it is trying to initialise.

Bootstrap Azure Storage with Terraform

We'll use a separate Terraform folder to create the storage account for remote state.

azure-landing-zone/
├── bootstrap/
│   ├── providers.tf
│   └── main.tf
├── providers.tf
├── main.tf
├── governance.tf
├── networking.tf
├── rbac.tf
├── monitoring.tf
├── variables.tf
└── outputs.tf

bootstrap/providers.tf

# Configure Terraform and AzureRM provider
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
  subscription_id = "YOUR-SUBSCRIPTION-ID"
}

bootstrap/main.tf

# Resource Group for Terraform State
resource "azurerm_resource_group" "tfstate" {
  name     = "rg-terraform-state-uks-01"
  location = "uksouth"

  tags = {
    Environment = "Development"
    Owner       = "IT"
    CostCentre  = "IT-001"
  }
}

# Storage Account for Terraform State
resource "azurerm_storage_account" "tfstate" {
  name                            = "sttfstatespaulpopacloud"
  resource_group_name             = azurerm_resource_group.tfstate.name
  location                        = azurerm_resource_group.tfstate.location
  account_tier                    = "Standard"
  account_replication_type        = "LRS"
  min_tls_version                 = "TLS1_2"
  https_traffic_only_enabled      = true
  public_network_access_enabled   = true
  allow_nested_items_to_be_public = false
  shared_access_key_enabled       = false

  blob_properties {
    versioning_enabled = true

    delete_retention_policy {
      days = 7
    }

    container_delete_retention_policy {
      days = 7
    }
  }

  tags = azurerm_resource_group.tfstate.tags
}

# Private Blob Container for Terraform State
resource "azurerm_storage_container" "tfstate" {
  name                  = "tfstate"
  storage_account_id    = azurerm_storage_account.tfstate.id
  container_access_type = "private"
}

Change the storage account name to something globally unique.

note
For this lab, the storage account has a public network endpoint so your machine can reach it, but anonymous blob access is disabled and shared-key authentication is disabled. It should be further restricted for production use.

Deploy the bootstrap

In PowerShell:

cd bootstrap

terraform init
terraform fmt
terraform validate
terraform plan
terraform apply

Because you're using Microsoft Entra authentication for blob access, your deployment identity needs appropriate storage data-plane permissions to create the container. If container creation returns a 403 error, check its Storage Blob Data Contributor permissions at the storage account scope.

Also, your existing Azure Policy tagging rules may affect the storage account deployment. Review any denials before changing enforcement.

Configure the Remote Backend

Return to the main project directory and add this backend block inside your existing terraform block in providers.tf.

# Remote Terraform State Backend
backend "azurerm" {
  storage_account_name = "sttfstatespaulpopacloud"
  container_name       = "tfstate"
  key                  = "sme-lab/terraform.tfstate"

  use_azuread_auth = true
  use_cli          = true
}
important
The backend block belongs inside terraform { ... }, not inside provider "azurerm" { ... }.

This uses your Azure CLI identity and Microsoft Entra authentication, rather than storing a storage account key.

Assign Storage Permissions

Your Azure CLI identity needs Storage Blob Data Contributor on the state storage account.

Use Azure Portal > Storage Account > Access Control (IAM) to grant the role, using an authorised administrator account.

For an MSP deployment, make this access limited to the deployment identities that actually require it.

Migrate Existing Terraform State

From your main landing zone directory, run:

terraform init -migrate-state

Terraform should ask whether you want to copy the existing local state to the new Azure backend.

Review the prompt and confirm the migration.

warning
Do not run terraform apply against an empty remote state before migrating. Terraform could attempt to recreate existing resources.

After migration:

terraform state list
terraform plan

Verify all previously managed resources are still listed and the plan shows no unexpected changes.

Azure Blob Storage provides state locking and encryption at rest.

important
Keep a secure backup of the original local state until migration is verified, then remove unnecessary local copies. Never commit state files to GitHub.

Verify the State in Azure

Navigate to: Storage Account > Containers > tfstate

You should see a blob similar to:

sme-lab/terraform.tfstate

That confirms your existing Terraform state has been migrated to Azure Storage.

Next: Terraform Modules

Once remote state is working, we'll separate the configuration into modules:

modules/
├── management-groups/
├── governance/
├── networking/
├── rbac/
└── monitoring/

We'll also separate tenant-level management groups from customer workload deployments so the same code can be used safely across different organisations.

tip
Before moving on: Make sure terraform state list still contains your existing resources and terraform plan shows no unexpected changes. That's the critical test for this phase.

Part 6: Reusable Terraform Modules

Now that the core Azure infrastructure has been deployed, the next step is to restructure the Terraform configuration into reusable modules.

Rather than maintaining networking, governance, RBAC and monitoring resources directly in the root configuration, each component will be managed through a dedicated module.

This allows the same infrastructure to be deployed across different customers and environments using configurable variables, without duplicating Terraform code.

Restructure the Project

The project will be organised as follows:

Each module contains:

  • main.tf: Defines the infrastructure resources.

  • variables.tf: Declares configurable inputs.

  • outputs.tf: Exposes resource information to other modules.

The bootstrap directory remains separate because it manages the Azure Storage infrastructure used for Terraform remote state.

Convert Management Groups into a Module

Create modules/management-groups/.

Move the existing management group resources and subscription association from the root main.tf into modules/management-groups/main.tf.

Keep the existing Terraform resource names unchanged during migration.

Module Variables

modules/management-groups/variables.tf

variable "subscription_id" {
  description = "The ID of the subscription"
  type        = string
}

variable "management_group_prefix" {
  description = "The prefix for the management groups"
  type        = string
}

Module Outputs

modules/management-groups/outputs.tf

output "landing_zone_root_id" {
  value = azurerm_management_group.root.id
}

output "platform_id" {
  value = azurerm_management_group.platform.id
}

output "sandbox_id" {
  value = azurerm_management_group.sandbox.id
}

output "landing_zones_id" {
  value = azurerm_management_group.landing_zones.id
}

Management groups will eventually be managed through a separate tenant-level Terraform configuration, as they should not be recreated for every development or production environment.

Convert Governance into a Module

Create modules/governance/.

Move the existing Azure Policy definitions and assignments from governance.tf into modules/governance/main.tf.

Module Variables

modules/governance/variables.tf

# Target Azure Subscription
variable "subscription_id" {
  type = string
}

# Approved Azure Regions
variable "allowed_locations" {
  type = list(string)
}

# Required Resource Tags
variable "required_tags" {
  type = set(string)
}

# Enable or Disable Policy Enforcement
variable "enforce_policies" {
  type    = bool
  default = false
}

The policy assignments will reference these variables rather than hardcoded customer settings.

The remaining required-tag assignments are transferred into the same module, preserving their original resource names and for_each keys.

Convert Networking into a Module

Create modules/networking/.

Move all networking resources from networking.tf into modules/networking/main.tf.

Module Variables

modules/networking/variables.tf

variable "customer_name" {
  type = string
}

variable "prefix_location" {
  type = string
}

variable "location" {
  type = string
}

variable "environment" {
  type = string
}

variable "tags" {
  type = map(string)
}

variable "vnet_address_space" {
  type = string
}

variable "app_subnet" {
  type = string
}

variable "data_subnet" {
  type = string
}

variable "private_endpoint_subnet" {
  type = string
}

The network resources will use these variables to support different customer environments.

During migration, the configured names and address ranges must remain consistent with the existing Azure resources to avoid unintended changes.

Module Outputs

modules/networking/outputs.tf

# Virtual Network ID
output "vnet_id" {
  value = azurerm_virtual_network.main.id
}

# Application Subnet ID
output "app_subnet_id" {
  value = azurerm_subnet.app.id
}

# Data Subnet ID
output "data_subnet_id" {
  value = azurerm_subnet.data.id
}

# Private Endpoint Subnet ID
output "private_endpoint_subnet_id" {
  value = azurerm_subnet.private_endpoints.id
}

Convert RBAC into a Module

Create modules/rbac/.

Move the existing Entra ID security group and Azure RBAC configurations from rbac.tf into modules/rbac/main.tf.

Module Variables

modules/rbac/variables.tf

variable "subscription_id" {
  type = string
}

variable "customer_name" {
  type = string
}

The Entra ID groups and role assignments will retain their existing definitions, with customer-specific information supplied through variables.

The AzureAD provider remains configured in the root module and is inherited by the RBAC module.

Convert Monitoring into a Module

Create modules/monitoring/.

Move the existing resources from monitoring.tf into modules/monitoring/main.tf.

This includes:

  • Monitoring resource group

  • Log Analytics workspace

  • Subscription diagnostic settings

  • Azure Monitor Action Group

  • Resource group deletion alert

Module Variables

modules/monitoring/variables.tf

variable "subscription_id" {
  type = string
}

variable "customer_name" {
  type = string
}

variable "environment" {
  type = string
}

variable "location" {
  type = string
}

variable "tags" {
  type = map(string)
}

variable "alert_email" {
  type = string
}

The module will use these variables to configure monitoring consistently across different customer environments.

Update the Root Terraform Configuration

Once the resources have been transferred into their respective modules, the root main.tf will call each module.

# Deploy Management Groups Module
module "management_groups" {
  source = "./modules/management-groups"

  subscription_id         = var.subscription_id
  management_group_prefix = var.management_group_prefix
}

# Deploy SME Networking Module
module "networking" {
  source = "./modules/networking"

  customer_name   = var.customer_name
  environment     = var.environment
  prefix_location = var.prefix_location
  location        = var.location

  vnet_address_space      = var.vnet_address_space
  app_subnet              = var.app_subnet
  data_subnet             = var.data_subnet
  private_endpoint_subnet = var.private_endpoint_subnet

  tags = var.tags
}

# Deploy Azure Governance Module
module "governance" {
  source = "./modules/governance"

  subscription_id = var.subscription_id

  allowed_locations = var.allowed_locations
  required_tags     = var.required_tags

  enforce_policies = var.enforce_policies
}

# Deploy Azure Monitoring Module
module "monitoring" {
  source = "./modules/monitoring"

  customer_name   = var.customer_name
  subscription_id = var.subscription_id
  alert_email     = var.alert_email
  environment     = var.environment
  location        = var.location
  tags            = var.tags
}

# Deploy Azure RBAC Module
module "rbac" {
  source = "./modules/rbac"

  subscription_id = var.subscription_id
  customer_name   = var.customer_name
}

The root variables.tf will declare the inputs passed to these modules, while terraform.tfvars supplies their values.

For example:

# Customer Configuration
subscription_id         = "YOUR-SUB-ID"
tenant_id               = "YOUR-TENANT-ID"
customer_name           = "PaulPopaCloud"
environment             = "dev"
prefix_location         = "uks"
location                = "uksouth"
alert_email             = "your-email@example.com"
management_group_prefix = "alzlab"

# Networking
vnet_address_space      = "10.10.0.0/22"
app_subnet              = "10.10.1.0/24"
data_subnet             = "10.10.2.0/24"
private_endpoint_subnet = "10.10.3.0/24"

# Governance
allowed_locations = ["uksouth", "ukwest"]

required_tags = [
  "Environment",
  "Owner",
  "CostCentre"
]

enforce_policies = true

# Resource Tags
tags = {
  Environment = "Development"
  Owner       = "IT"
  CostCentre  = "IT-001"
}

Additional existing values, including subscription ID, tenant ID, management group prefix and monitoring email, remain in the root configuration.

This allows customer-specific infrastructure settings to be changed without modifying the module code.

The root providers.tf will continue to configure AzureRM and AzureAD, while root outputs can reference module outputs such as module.networking.vnet_id.

Preserve Existing Terraform Resources

Because these resources have already been deployed, moving them into modules changes their Terraform resource addresses.

warning
Without recording those changes, Terraform may attempt to destroy the existing resources.

To prevent this, we'll use Terraform moved blocks.

moved.tf

For example, the existing management group, governance and networking resources can be mapped to their new module addresses:

# Management Group
moved {
  from = azurerm_management_group.root
  to   = module.management_groups.azurerm_management_group.root
}

# Allowed Locations Policy
moved {
  from = azurerm_subscription_policy_assignment.allowed_locations
  to   = module.governance.azurerm_subscription_policy_assignment.allowed_locations
}

# Virtual Network
moved {
  from = azurerm_virtual_network.main
  to   = module.networking.azurerm_virtual_network.main
}

# Administrator Security Group
moved {
  from = azuread_group.admins
  to   = module.rbac.azuread_group.admins
}

# Log Analytics Workspace
moved {
  from = azurerm_log_analytics_workspace.main
  to   = module.monitoring.azurerm_log_analytics_workspace.main
}

Equivalent moved blocks must be created for every existing managed resource being transferred into the modules, including all NSGs, security rules, policy assignments, RBAC roles and monitoring resources.

The source and destination addresses must match the existing state and new resource declarations exactly.

Once the resources have been defined inside their respective modules and the moved blocks have been added, remove the original resource declarations from the root configuration.

If the original networking.tf, monitoring.tf and rbac.tf files no longer contain any required configuration, they can be deleted.

Validate the Module Migration

Before applying the changes, initialise and validate the configuration.

terraform init
terraform fmt -recursive
terraform validate
terraform plan

Review the deployment plan carefully.

For a migration that only changes Terraform resource addresses, the expected result is:

Plan: 0 to add, 0 to change, 0 to destroy.

Terraform may also report that resources have moved into their respective modules.

warning
If Terraform proposes destroying or replacing existing infrastructure, the deployment must not proceed until the configuration and resource mappings have been corrected.

Once the plan has been verified:

terraform apply
terraform state list

The state should now reference the module addresses, for example:

A final terraform plan should be performed to confirm that no unexpected changes remain.

Supporting Multiple Environments

With the modules established, the next improvement is separating customer and environment configurations into independent Terraform root modules and state files.

For example:

customers/
└── customer-a/
    ├── tenant/
    ├── subscription/
    ├── dev/
    └── prod/

Each configuration will call the appropriate shared modules while maintaining independent Terraform state.

Tenant-level management groups will be deployed once per customer tenant. Subscription-wide governance, RBAC and monitoring will be deployed once per target subscription, while networking can be deployed independently for development and production.

This avoids duplicating shared resources while allowing environments to use different network address spaces, tags and infrastructure settings.

note
Separating existing infrastructure into different state files requires its own carefully planned migration. The moved blocks above address refactoring within the current state; they do not automatically transfer resources between independent backends.

Conclusion

The SME Azure Landing Zone configuration has been designed for conversion into reusable Terraform modules covering management groups, governance, networking, RBAC and monitoring.

This approach reduces duplicated code, improves maintainability and allows infrastructure configurations to be adapted for different customer requirements.

Once the existing resources have been successfully migrated and validated, the next stage will focus on independent customer environments and automated deployment pipelines.

Troubleshooting

401 Unauthorized: MFA Required

During the deployment of the Azure Policy assignment, Terraform returned a 401 Unauthorized error.

Error

Azure required multi-factor authentication (MFA) before allowing the policy assignment to be created. The existing Azure CLI authentication session did not satisfy this requirement.

Resolution

I cleared the existing Azure CLI authentication session and signed in again using device code authentication.

az logout
az account clear
az login --use-device-code

This initiated a fresh authentication process, allowing the required MFA authentication to be completed.

After authenticating, I reran the Terraform deployment and it was successful.

403 KeyBasedAuthenticationNotPermitted

During the deployment of the Terraform remote state storage account, Terraform returned a 403 Forbidden error.

Error

The storage account had Shared Key authentication disabled using:

shared_access_key_enabled = false

However, the AzureRM provider was still attempting to access the Blob service using key-based authentication.

Resolution

To resolve this, I updated the AzureRM provider configuration in bootstrap/providers.tf to use Microsoft Entra ID authentication for Azure Storage.

provider "azurerm" {
  features {}

  subscription_id     = "YOUR-SUBSCRIPTION-ID"
  storage_use_azuread = true
}

The storage_use_azuread = true setting instructs the provider to use Microsoft Entra ID authentication for supported Azure Storage operations instead of Shared Key authentication.

After applying this change and rerunning Terraform, the deployment completed successfully.

All Azure projects