Manage an Azure management group and its subscription memberships as one keyed unit — the governance anchor other modules assign policy and RBAC against, on
hashicorp/azurerm ~> 4.0.
- 🏛️ Creates one
azurerm_management_group— a governance container in the tenant hierarchy. - 🔗 Assigns subscriptions to it via
azurerm_management_group_subscription_associationfrom a keyed map (for_each). - 🌳 Optionally parents the group under another management group, or leaves it directly under the tenant root.
- 🎯 Emits the group
idas the scope that policy, initiative, and RBAC modules target.
💡 Why it matters: management groups are where tenant-wide governance is expressed. Policy and RBAC applied at a management group flow down to every subscription and resource beneath it, so getting the hierarchy and its memberships right is what makes landing-zone governance consistent instead of per-subscription guesswork.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart TD
TENANT["Tenant root management group"]
MG["terraform-azurerm-management-group"]
AMG["azurerm_management_group"]
ASSOC["azurerm_management_group_subscription_association (for_each)"]
SUB["Azure Subscription"]
POLICY["terraform-azurerm-management-group-policy-assignment"]
ROLES["terraform-azurerm-role-assignments"]
MGSET["terraform-azurerm-management-group-policy-set-definition"]
MGEXEMPT["terraform-azurerm-management-group-policy-exemption"]
MGREM["terraform-azurerm-management-group-policy-remediation"]
MGDEPLOY["terraform-azurerm-management-group-template-deployment"]
TENANT -->|"parent_management_group_id"| MG
MG --> AMG
AMG --> ASSOC
SUB -->|"subscription_id"| ASSOC
AMG -->|"id (scope)"| POLICY
AMG -->|"id (scope)"| ROLES
AMG -->|"management_group_id"| MGSET
AMG -->|"management_group_id"| MGEXEMPT
AMG -->|"management_group_id"| MGREM
AMG -->|"management_group_id"| MGDEPLOY
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class MG this;
class AMG key;
flowchart LR
I1["name (immutable MG ID)"]
I2["display_name"]
I3["parent_management_group_id (optional)"]
I4["subscription_associations (map)"]
MG["azurerm_management_group.this"]
ASSOC["azurerm_management_group_subscription_association.this<br/>for_each = subscription_associations"]
O1["id"]
O2["name"]
O3["display_name"]
O4["tenant_scoped_id"]
O5["subscription_association_ids"]
I1 --> MG
I2 --> MG
I3 --> MG
I4 --> ASSOC
MG --> ASSOC
MG --> O1
MG --> O2
MG --> O3
MG --> O4
ASSOC --> O5
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class MG key;
class ASSOC this;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_management_group.this |
1 (keystone) | The management group. |
azurerm_management_group_subscription_association.this |
0..N (for_each) |
Subscription memberships, keyed by a stable identifier. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite
nameis the group's identity — it becomes the.../managementGroups/<name>segment of the Resource ID and is immutable; changing it forces a new group. Prefer an explicit, stable value over letting the platform generate a random UUID.display_nameis updatable in place and is not part of the identity.parent_management_group_idis updatable — changing it moves the group to a new parent rather than replacing it. Leave it null to sit directly under the tenant root management group.- On each membership record, both
management_group_idandsubscription_idare immutable; changing either recreates that membership. - Setting
subscription_idson the group and managing memberships through association records against the same group is unsupported and produces unpredictable results. This module owns memberships only through the association records and never sets the keystone's subscription list. azurerm_management_groupdoes not supporttags— the resource has no tag field, so this module exposes notagsvariable.- Creating a group under the tenant root requires elevated access at the root (see RBAC below) — a common first-run blocker.
- Management Group Contributor at the parent scope (the parent group, or the tenant root management group when
parent_management_group_idis null) — or a custom role withMicrosoft.Management/managementGroups/read,.../write, and.../subscriptions/write. - To create groups directly under the tenant root management group, the caller's identity often needs elevated access at the tenant root — Owner or Management Group Contributor granted at the root group, or the one-time tenant-level access elevation performed by a Global Administrator.
- The identity must also be able to read the subscriptions referenced in
subscription_associations.
- The
Microsoft.Managementresource provider registered on the subscription the provider authenticates against. - Any parent management group referenced by
parent_management_group_idalready exists. - Any subscriptions referenced in
subscription_associationsalready exist and are visible to the caller's identity. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
terraform-azurerm-management-group/
├── providers.tf # required_version + azurerm ~> 4.0; no provider block
├── variables.tf # name, display_name, parent_management_group_id, subscription_associations, timeouts
├── main.tf # azurerm_management_group.this + for_each subscription associations
├── outputs.tf # id, name, display_name, tenant_scoped_id, subscription_association_ids
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical Terraform ignore set
provider "azurerm" {
features {}
}
module "mg_platform" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
}ℹ️ Pin the module by immutable tag (
?ref=v1.0.0), never a branch. The caller configures the provider, authentication, subscription, and the mandatoryfeatures {}block.
Consumes
| Input | Type | From |
|---|---|---|
parent_management_group_id |
string |
a parent terraform-azurerm-management-group (id), or null for the tenant root |
subscription_associations[*].subscription_id |
string |
a subscription's Resource ID (/subscriptions/<guid>) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Management group Resource ID | terraform-azurerm-role-assignments, terraform-azurerm-management-group-policy-assignment, terraform-azurerm-management-group-policy-set-definition, terraform-azurerm-management-group-policy-exemption, terraform-azurerm-management-group-policy-remediation, terraform-azurerm-management-group-template-deployment, child groups (parent_management_group_id) |
name |
Management group name (tenant-unique ID) | references / tooling |
display_name |
Friendly display name | documentation |
tenant_scoped_id |
Tenant-scoped ID (computed) | tenant-level tooling |
subscription_association_ids |
map: membership key → association ID | audit |
1 · Minimal (child of the tenant root)
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
}ℹ️ With
parent_management_group_idomitted, the group is created directly under the tenant root management group.
2 · Nested under a parent management group
module "mg_connectivity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-connectivity"
display_name = "Connectivity"
parent_management_group_id = var.platform_mg_id
}💡
parent_management_group_idtakes the parent's full Resource ID (/providers/Microsoft.Management/managementGroups/<parent>).
3 · One subscription membership
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-corp"
display_name = "Corp"
subscription_associations = {
prod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000" }
}
}🔒
subscription_idis the full subscription Resource ID, not the bare GUID.
4 · Multiple memberships (keyed map)
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-landingzones"
display_name = "Landing Zones"
subscription_associations = {
prod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000001" }
nonprod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000002" }
sandbox = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000003" }
}
}💡 Stable keys (
prod,nonprod, …) mean removing one membership never re-indexes the others.
5 · Membership from a subscription data source
data "azurerm_subscription" "prod" {
subscription_id = "00000000-0000-0000-0000-000000000001"
}
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-corp"
display_name = "Corp"
subscription_associations = {
prod = { subscription_id = data.azurerm_subscription.prod.id }
}
}ℹ️ A subscription data source's
idis exactly the/subscriptions/<guid>form this input expects.
6 · for_each memberships at scale
locals {
subscription_ids = {
prod = "/subscriptions/00000000-0000-0000-0000-000000000001"
nonprod = "/subscriptions/00000000-0000-0000-0000-000000000002"
dev = "/subscriptions/00000000-0000-0000-0000-000000000003"
}
}
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-workloads"
display_name = "Workloads"
subscription_associations = { for k, id in local.subscription_ids : k => { subscription_id = id } }
}7 · A two-level hierarchy (parent + child)
module "mg_platform" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
}
module "mg_identity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-identity"
display_name = "Identity"
parent_management_group_id = module.mg_platform.id
}💡 Wire the child's
parent_management_group_idto the parent module'sidoutput — Terraform then creates the parent first.
8 · Landing-zone style tree
module "mg_root" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-contoso"
display_name = "Contoso"
}
module "mg_platform" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
parent_management_group_id = module.mg_root.id
}
module "mg_landingzones" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-landingzones"
display_name = "Landing Zones"
parent_management_group_id = module.mg_root.id
}
module "mg_corp" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-corp"
display_name = "Corp"
parent_management_group_id = module.mg_landingzones.id
}9 · Group with memberships and a custom name
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
display_name = "Legacy Workloads"
subscription_associations = {
legacy = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000009" }
}
}
⚠️ A GUIDnameis valid but opaque; a slug likemg-legacyreads better in the hierarchy. Either way,nameis immutable.
10 · Custom timeouts
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
timeouts = { create = "30m", delete = "30m" }
}ℹ️
updateapplies to the group only; each membership record has no update phase (create/read/delete).
11 · Governance anchor — RBAC at the group scope
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
}
module "roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.mg.id
role_assignments = {
platform_readers = {
role_definition_name = "Reader"
principal_id = var.platform_team_group_object_id
}
}
}🔒 Roles assigned at the management group inherit down to every subscription and resource beneath it — grant least privilege here deliberately.
12 · Governance anchor — policy at the group scope
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-landingzones"
display_name = "Landing Zones"
}
module "policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-policy-assignment.git?ref=v1.0.0"
name = "require-tag-costcenter"
management_group_id = module.mg.id
policy_definition_id = var.require_tag_policy_id
}💡 Assigning at the management group is what makes a guardrail apply tenant-wide from a single record. Use the management-group-scoped assignment module here — the resource-group-scoped one takes a
resource_group_idand cannot assign at this scope. Note also that assignment names are limited to 24 characters at management-group scope.
13 · Diagnostic settings on the group activity log
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
}
module "diag" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"
name = "mg-activity-to-law"
target_resource_id = module.mg.id
log_analytics_workspace_id = var.law_id
}ℹ️ The group's activity log flows to a Log Analytics workspace owned by a sibling module.
14 · 🏗️ End-to-end composition
A management group, subscriptions attached to it, then RBAC and policy applied at its scope — a minimal governance root wired from real sibling outputs.
provider "azurerm" {
features {}
}
module "mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
name = "mg-platform"
display_name = "Platform"
subscription_associations = {
prod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000001" }
nonprod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000002" }
}
}
module "roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.mg.id
role_assignments = {
platform_owners = {
role_definition_name = "Owner"
principal_id = var.platform_admins_object_id
}
}
}
module "policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-policy-assignment.git?ref=v1.0.0"
name = "deny-public-storage"
management_group_id = module.mg.id
policy_definition_id = var.deny_public_storage_policy_id
}💡 The management group
idis the single scope both the RBAC and the policy module consume — the group is the anchor the rest of the governance stack hangs from.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
✅ | — | Tenant-unique management group ID. Immutable. |
display_name |
string |
✅ | — | Friendly display name. Updatable. |
parent_management_group_id |
string |
— | null |
Parent group Resource ID; null = tenant root. Updatable (moves the group). |
subscription_associations |
map(object) |
— | {} |
Subscription memberships, keyed by a stable identifier. |
timeouts |
object |
— | null |
Optional operation timeouts. |
ℹ️ This resource type does not support
tags, so the module intentionally exposes notagsvariable.
Full variable schemas
variable "name" {
type = string # immutable; the .../managementGroups/<name> Resource ID segment
}
variable "display_name" {
type = string # updatable friendly name
}
variable "parent_management_group_id" {
type = string # full parent Resource ID; null parents under the tenant root
default = null
}
variable "subscription_associations" {
type = map(object({
subscription_id = string # full subscription Resource ID: /subscriptions/<guid>
}))
default = {}
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string) # applies to the group only
delete = optional(string)
})
default = null
}| Output | Description | Kind |
|---|---|---|
id |
The Azure Resource ID of the management group (/providers/Microsoft.Management/managementGroups/) | Passthrough |
name |
The name (tenant-unique ID) of the management group | Passthrough |
display_name |
The friendly display name of the management group | Passthrough |
tenant_scoped_id |
The management group ID scoped to the tenant, in the form /tenants//providers/Microsoft.Management/managementGroups/ (computed) | Passthrough |
subscription_association_ids |
Map of subscription-association key => the association record's Azure Resource ID (empty when no memberships are configured) | Derived |
parent_management_group_id |
The Resource ID of this group's parent, or the tenant root group when none was given | Passthrough |
is_parented_at_tenant_root |
True when no parent was specified, in which case the provider parents this group directly under the TENANT ROOT group - the widest position in the hierarchy | Derived |
subscription_association_count |
How many subscriptions this module associates with the group | Passthrough |
associated_subscription_ids |
The full subscription Resource IDs associated with this group by this module, sorted | Derived |
removing_a_subscription_sends_it_to_tenant_root |
Always true | Constant |
losing_read_access_makes_terraform_plan_a_recreate |
Always true | Constant |
destroy_reports_success_without_deleting_when_read_is_forbidden |
Always true | Constant |
recreating_this_group_detaches_everything_beneath_it |
Always true | Constant |
- No tags.
azurerm_management_grouphas no tag field, so the module exposes notagsvariable. Governance metadata for a group lives in naming conventions and in policy/RBAC applied at its scope, not in tags. - Immutable identity.
nameis the group's Resource ID segment and forces replacement when changed.display_nameis updatable, andparent_management_group_idis updatable in place (it moves the group rather than recreating it). - The module owns memberships — one way. Memberships are separate
azurerm_management_group_subscription_associationrecords viafor_each, keyed by a stable identifier. The keystone'ssubscription_idsis deliberately left unset because managing memberships both there and through association records against the same group is unsupported and yields perpetual, unpredictable diffs. - Membership fields are immutable. Changing a membership's target group or subscription recreates that record; the stable map key keeps unrelated memberships untouched.
- Ordering is implicit. Each membership references the keystone's
id, so Terraform creates the group before associating subscriptions — nodepends_onneeded. - Governance anchor. The primary output is
id; sibling modules assign policy, initiatives, and RBAC at that scope, and child groups reference it as their parent. features {}dependence. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| Concern | Secure default (empty call) | Opt-out / opt-in |
|---|---|---|
| Membership management | Owned only via association records; keystone subscription_ids unset |
add entries to subscription_associations |
| Placement | Directly under the tenant root | set parent_management_group_id |
| Least-privilege scope | Group emits id for narrow, deliberate RBAC/policy scoping |
assign roles/policy at the smallest group that works |
| Identity stability | Explicit, caller-chosen name (immutable) |
supply a GUID if a random ID is preferred |
cd terraform-azurerm-management-group
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema and canonically formatted, with no cloud calls. What it does not exercise: whether the caller's identity has tenant-root access to create the group, whether referenced parent groups and subscriptions exist, and the dual-management conflict that only appears when a group's subscription list and association records fight over the same subscription — those surface only under terraform plan/apply against real credentials from CI.
$ terraform output
id = "/providers/Microsoft.Management/managementGroups/mg-platform"
name = "mg-platform"
display_name = "Platform"
tenant_scoped_id = "/tenants/11111111-1111-1111-1111-111111111111/providers/Microsoft.Management/managementGroups/mg-platform"
subscription_association_ids = {
"prod" = "/providers/Microsoft.Management/managementGroups/mg-platform/subscriptions/00000000-0000-0000-0000-000000000001"
"nonprod" = "/providers/Microsoft.Management/managementGroups/mg-platform/subscriptions/00000000-0000-0000-0000-000000000002"
}| Symptom | Cause | Fix |
|---|---|---|
AuthorizationFailed creating a group under the tenant root |
Identity lacks elevated access at the tenant root management group | Grant Management Group Contributor/Owner at the root, or have a Global Administrator elevate tenant access once. |
| Group wants to be replaced on every plan | name was changed |
name is immutable; keep it stable, or accept the recreation deliberately. |
| Perpetual diff on subscription membership | subscription_ids set on the group while also using association records |
Manage memberships only through subscription_associations; leave the keystone list unset (this module already does). |
| Membership rejected / recreated | Wrong subscription_id format, or the field changed |
Use the full /subscriptions/<guid> Resource ID; both membership fields are immutable. |
ParentManagementGroupNotFound |
parent_management_group_id points at a group that does not exist yet |
Wire it to a parent module's id, or create the parent first. |
| Subscription not found during association | Subscription not visible to the caller's identity | Grant the identity read access to the target subscription. |
- Provider resources:
azurerm_management_group,azurerm_management_group_subscription_association - Sibling modules:
terraform-azurerm-role-assignments,terraform-azurerm-management-group-policy-assignment,terraform-azurerm-management-group-policy-set-definition,terraform-azurerm-management-group-policy-exemption,terraform-azurerm-management-group-policy-remediation,terraform-azurerm-management-group-template-deployment,terraform-azurerm-monitor-diagnostic-setting - This module's cross-module contract:
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."