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
- You are standing up a Cloud Adoption Framework / enterprise-scale landing zone and need to codify the management group hierarchy (Platform, Landing Zones, Sandbox, Decommissioned) instead of clicking it in the portal.
- You want to apply Azure Policy or RBAC at a scope broader than a single subscription — e.g. “deny public IPs across all production subscriptions” — and need a stable management group ID to assign them to.
- You are onboarding new subscriptions through a subscription vending process and want each one associated to the correct governance group automatically at provision time.
- You need an auditable, peer-reviewed record of who changed the hierarchy and when, with PR-based approvals before a re-parenting takes effect.
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 config — live/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 config — live/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
- Pin
nameexplicitly and treat it as permanent. The ID segment cannot be renamed; a change forces a destroy/recreate that re-parents everything beneath it. Editdisplay_namefreely, but never thename. - Build the tree top-down using output IDs, not hardcoded strings. Reference
module.parent.idforparent_management_group_idso Terraform orders creation correctly and re-parenting is an explicit, reviewed graph change rather than a copy-pasted string. - Use a deny-first policy posture at high-level groups. Assign broad guardrails (allowed locations, denied SKUs, required tags) at
Platform/Landing Zonesso every subscription inherits them — far more reliable than per-subscription enforcement. - Keep
subscription_idsempty when you use subscription vending. Letting two systems own subscription placement causes Terraform to fight the vending process and silently bounce subscriptions between groups on every apply. - Scope a
Sandboxand aDecommissionedgroup early. Route experimental and retiring subscriptions into isolated branches with relaxed or lockdown policies, keeping cost and blast radius out of your production landing zones. - Restrict who can apply this module. Management-group writes require the
Management Group Contributorrole and propagate tenant-wide; gate the pipeline behind required reviewers and a service principal scoped only to the management group hierarchy.