IaC Azure

Terraform Module: Azure Management Group — codify the top of your governance hierarchy

Quick take — Build a reusable hashicorp/azurerm module for azurerm_management_group: parent nesting, subscription association, validated display names, and outputs ready for policy and RBAC assignment. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "management_group" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-group?ref=v1.0.0"

  display_name = "..."  # Friendly display name shown in the portal. Mutable; 1–9…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Management Group is a container that sits above subscriptions in the Azure resource hierarchy. You group subscriptions under a management group, then apply Azure Policy assignments, RBAC role assignments, and cost controls once at the group level — and every subscription nested below inherits them. Management groups can nest up to six levels deep beneath the non-visible Tenant Root Group, giving you a tree like Tenant Root → Platform / Landing Zones → Corp / Online → individual subscriptions that mirrors a Cloud Adoption Framework enterprise-scale landing zone.

Wrapping azurerm_management_group in a reusable module matters because the management group tree is the single most leverage-heavy surface in a tenant: a policy denied here blocks resource creation across hundreds of subscriptions, and a mis-set parent_management_group_id silently re-parents an entire branch. A module gives you one validated, reviewed definition of “how we create a management group” — consistent naming, deliberate parent wiring, explicit subscription association, and clean outputs (the group ID) that downstream policy and RBAC modules consume. It also tames two notorious sharp edges: the group name (the ID segment) is immutable after creation, and associating a subscription here will remove it from whatever group it previously lived under.

When to use it

Skip a dedicated module (or reach for Microsoft’s caf-enterprise-scale / ALZ module) only when you need the entire landing-zone stack — policies, role definitions, and the full archetype set — generated together; this module owns the management group node itself.

Module structure

terraform-module-azure-management-group/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

# The management group ID segment ("name") is immutable. We default it to a
# sanitized version of the display name but always let the caller pin it
# explicitly so a display-name edit never forces a destroy/recreate.
locals {
  management_group_name = coalesce(
    var.name,
    lower(replace(replace(var.display_name, " ", "-"), "_", "-"))
  )
}

resource "azurerm_management_group" "this" {
  name                       = local.management_group_name
  display_name               = var.display_name
  parent_management_group_id = var.parent_management_group_id

  # WARNING: associating a subscription here REMOVES it from its current
  # management group. Existing subscription IDs only; new subscriptions are
  # placed via subscription vending, not this argument.
  subscription_ids = var.subscription_ids
}

variables.tf

variable "display_name" {
  description = "Friendly display name shown in the portal (e.g. 'Platform - Connectivity'). Mutable; can be changed in place."
  type        = string

  validation {
    condition     = length(var.display_name) >= 1 && length(var.display_name) <= 90
    error_message = "display_name must be between 1 and 90 characters."
  }
}

variable "name" {
  description = "Immutable management group ID segment (the unqualified name). If null, it is derived from display_name. Changing this forces recreation."
  type        = string
  default     = null

  validation {
    # Azure allows letters, numbers, hyphens, underscores, parentheses and
    # periods, 1-90 chars, and the name must not end with a period.
    condition = var.name == null || (
      can(regex("^[a-zA-Z0-9-_().]{1,90}$", var.name)) &&
      !endswith(coalesce(var.name, "x"), ".")
    )
    error_message = "name must be 1-90 chars of letters, numbers, hyphens, underscores, parentheses, or periods, and must not end with a period."
  }
}

variable "parent_management_group_id" {
  description = "Full resource ID of the parent management group. If null, the group is created directly under the Tenant Root Group. Format: /providers/Microsoft.Management/managementGroups/<name>."
  type        = string
  default     = null

  validation {
    condition = var.parent_management_group_id == null || can(regex(
      "^/providers/Microsoft.Management/managementGroups/[a-zA-Z0-9-_().]{1,90}$",
      var.parent_management_group_id
    ))
    error_message = "parent_management_group_id must be a full management group resource ID (/providers/Microsoft.Management/managementGroups/<name>) or null."
  }
}

variable "subscription_ids" {
  description = "Bare subscription GUIDs to associate with this management group. Associating a subscription removes it from its previous group. Leave empty when subscriptions are placed via vending."
  type        = set(string)
  default     = []

  validation {
    condition = alltrue([
      for s in var.subscription_ids :
      can(regex("^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$", s))
    ])
    error_message = "Each subscription_id must be a bare subscription GUID (no /subscriptions/ prefix)."
  }
}

outputs.tf

output "id" {
  description = "Full resource ID of the management group — assign policies and RBAC roles to this scope."
  value       = azurerm_management_group.this.id
}

output "name" {
  description = "Immutable management group name (ID segment), e.g. 'platform-connectivity'."
  value       = azurerm_management_group.this.name
}

output "display_name" {
  description = "Friendly display name of the management group."
  value       = azurerm_management_group.this.display_name
}

output "parent_management_group_id" {
  description = "Resource ID of the parent management group (null/root when created under Tenant Root)."
  value       = azurerm_management_group.this.parent_management_group_id
}

output "subscription_ids" {
  description = "Set of subscription GUIDs associated with this management group."
  value       = azurerm_management_group.this.subscription_ids
}

How to use it

# Top-level "Platform" group directly under the Tenant Root Group.
module "mg_platform" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-group?ref=v1.0.0"

  name         = "platform"
  display_name = "Platform"
}

# Nested "Connectivity" group with the hub subscription associated to it.
module "mg_connectivity" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-group?ref=v1.0.0"

  name                       = "platform-connectivity"
  display_name               = "Platform - Connectivity"
  parent_management_group_id = module.mg_platform.id
  subscription_ids           = ["00000000-1111-2222-3333-444444444444"]
}

# Downstream: assign a "deny public IP" policy at the Connectivity scope,
# consuming the module's `id` output as the assignment scope.
resource "azurerm_management_group_policy_assignment" "deny_public_ip" {
  name                 = "deny-public-ip"
  display_name         = "Deny public IP addresses"
  management_group_id  = module.mg_connectivity.id
  policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/83a86a26-fd1f-447c-b59d-e51f44264114"
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/management_group/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-group?ref=v1.0.0"
}

inputs = {
  display_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/management_group && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
display_name string Yes Friendly display name shown in the portal. Mutable; 1–90 characters.
name string null No Immutable ID segment. Derived from display_name when null; changing it forces recreation.
parent_management_group_id string null No Full resource ID of the parent group. Null places the group under the Tenant Root Group.
subscription_ids set(string) [] No Bare subscription GUIDs to associate. Associating moves a subscription out of its previous group.

Outputs

Name Description
id Full resource ID of the management group — the scope for policy and RBAC assignments.
name Immutable management group name (ID segment).
display_name Friendly display name of the management group.
parent_management_group_id Resource ID of the parent management group (root when created under Tenant Root).
subscription_ids Set of subscription GUIDs associated with this management group.

Enterprise scenario

A retail bank running an enterprise-scale landing zone codifies its entire governance tree with this module: a root Contoso group holds Platform (with nested Identity, Management, and Connectivity) and Landing Zones (with Corp and Online). Each branch’s id output is fed into a policy module that enforces required tags, denied regions, and a “no public ingress” rule at the Corp scope. When the bank acquires a fintech and inherits 40 subscriptions, the platform team adds them to subscription_ids on the appropriate group in a single PR, and policy plus RBAC inheritance applies automatically across the new estate the moment the apply completes.

Best practices

TerraformAzureManagement GroupModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading