Deploys an ARM template at management-group scope (
azurerm_management_group_template_deployment) for the tenant- and subscription-level constructs Terraform cannot express directly. Targetshashicorp/azurerm ~> 4.0.
- 🌉 Deploys an ARM template at management-group scope as a keystone resource named
this— a deliberate bridge for ARM-only capabilities, not a general escape hatch. - 🔀 Restates the template-source exclusive-or with a module-level message. The provider already enforces this through the SDK's
ExactlyOneOfon both fields, so setting neither or both is rejected at plan -- not at apply. The module keeps its own check because it names a module input and produces an actionable message, but it is a better message for an existing guarantee, not a missing one. Note the two fields are not symmetrical:template_contentis optional-and-computed,template_spec_version_idis optional only. - 📦 Steers reuse toward a template spec version, which is versioned and access-controlled in Azure rather than living in whichever repository ran the apply.
- 🔐 Documents
parameters_contentanddebug_levelas secret-disclosure paths, because both write values into the management group's deployment history. - 📤 Passes the template's own outputs back as
output_contentforjsondecode(). - ⏱️ Gives
createandupdatereal timeout headroom — management-group deployments run long.
💡 Why it matters: A management-group deployment can create subscription- and tenant-level resources, so its reach is wider than any resource-group deployment — but Terraform's plan shows a JSON string, not the resources. The two things that actually go wrong are an ambiguous template source and secrets leaking through deployment history, so this module makes the first fail at plan and the second loudly documented.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
mg["terraform-azurerm-management-group"]
spec["Azure template spec version"]
kv["terraform-azurerm-key-vault"]
this["terraform-azurerm-management-group-template-deployment"]
dep["azurerm_management_group_template_deployment"]
arm["subscription and tenant level resources ARM creates"]
typed["terraform-azurerm-policy-definition and other typed modules"]
mg -->|"management_group_id"| this
spec -->|"template_spec_version_id"| this
kv -->|"ARM parameter reference at deploy time"| dep
this -->|"creates"| dep
dep -->|"deploys"| arm
typed -->|"preferred where a typed resource exists"| arm
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class dep keystone;
class mg,spec,kv,arm,typed sib;
flowchart TB
ident["name and location: deployment metadata only"]
tc["template_content: file or jsonencode"]
tsv["template_spec_version_id: versioned in Azure"]
xor["validation: exactly one template source"]
pc["parameters_content: recorded in deployment history"]
dbg["debug_level: secret disclosure control, leave null"]
this["terraform-azurerm-management-group-template-deployment"]
dep["azurerm_management_group_template_deployment.this"]
hist["management group deployment history"]
arm["resources ARM creates, outside Terraform state"]
out["output_content: whatever the template emitted"]
tc -->|"one source"| xor
tsv -->|"or the other"| xor
xor -->|"enforced at plan"| this
ident -->|"identity"| this
pc -->|"template inputs"| this
dbg -->|"leave null in production"| this
this -->|"creates"| dep
dep -->|"records inputs in"| hist
dep -->|"deploys"| arm
dep -->|"emits"| out
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class dep keystone;
class ident,tc,tsv,xor,pc,dbg,hist,arm,out sib;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_management_group_template_deployment.this |
1 | The keystone deployment record, with its timeouts block. Everything the template creates is outside Terraform's state. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite (verified against the live provider schema):
- The template source is an exclusive-or the PROVIDER enforces. The provider already enforces this through the SDK's
ExactlyOneOfon both fields, so setting neither or both is rejected at plan -- not at apply. The module keeps its own check because it names a module input and produces an actionable message, but it is a better message for an existing guarantee, not a missing one. Note the two fields are not symmetrical:template_contentis optional-and-computed,template_spec_version_idis optional only. The module's check lives ontemplate_contentand references the other variable one-directionally, because Terraform rejects validations that reference each other. name,management_group_id, andlocationare force-new.locationstores the deployment metadata, not the deployed resources — a management group has no region of its own, and the template decides where its resources go.parameters_contentvalues are recorded in the management group's deployment history, readable by anyone with read access to the group. This is the main secret-leakage path for this resource type.debug_levelis a secret-disclosure control, not a verbosity dial: enabling request or response content writes template inputs and outputs into that same history.debug_levelIS a closed provider enum --StringInSliceover exactlynone,requestContent,responseContentandrequestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spellingnoneexplicitly produces a perpetual diff, because the provider's flatten returns the empty string for it while the attribute is not Computed -- leave it unset to mean none.- Terraform does not manage what the template creates. Destroying this resource removes the deployment record; the resources the template created are unaffected.
- The template must declare the management-group deployment schema (
$schemaending inmanagementGroupDeploymentTemplate.json#). A resource-group-scoped template is rejected. output_contentis a pass-through: anything the template emits lands in Terraform state.
Contributorat the target management group covers the deployment itself (Microsoft.Resources/deployments/*), but the operative constraint is that the identity needs whatever rights the template's own resources require. A template that creates policy definitions needsMicrosoft.Authorization/policyDefinitions/write; one that creates management groups needsMicrosoft.Management/managementGroups/write.- To deploy from a template spec,
Microsoft.Resources/templateSpecs/versions/readon that version. - Because a management-group deployment can create subscription- and tenant-level resources, the permission set here is effectively the union of everything the template touches. Grant it at the smallest management group that works, and review the template as you would review a role definition.
- An existing management group, and the caller's identity onboarded to the management-group hierarchy.
- Every resource provider the template uses registered on the subscriptions it targets — a template deployment does not register them for you.
- A template that declares the correct schema for management-group scope.
- For a template spec: the spec and the specific version already published.
- The caller configures the
provider "azurerm" { features {} }block, auth, and subscription.
terraform-azurerm-management-group-template-deployment/
├── providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf # template-source exclusive-or validation + tags/timeouts tail
├── main.tf # keystone azurerm_management_group_template_deployment.this; dynamic timeouts
├── outputs.tf # id, name, management_group_id, location, output_content
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
provider "azurerm" {
features {}
}
module "mg_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
name = "enable-defender-plans-2026-08"
management_group_id = "/providers/Microsoft.Management/managementGroups/mg-platform"
location = "eastus2"
template_content = file("${path.module}/templates/defender-plans.json")
parameters_content = jsonencode({
pricingTier = { value = "Standard" }
})
tags = { environment = "prod", owner = "platform-governance" }
}ℹ️ The caller owns the provider, its authentication, and the mandatory
features {}block. This module never declares them.
Consumes
| Input | Type | Source module |
|---|---|---|
management_group_id |
string |
terraform-azurerm-management-group (id) |
location |
string |
caller (metadata region only) |
template_content |
string (JSON) |
caller (file() or jsonencode()) |
template_spec_version_id |
string |
an Azure template spec version, referenced by ID |
parameters_content |
string (JSON) |
caller |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Deployment Resource ID (first) | audit inventories, deployment tracking |
name |
Deployment name in the management group's history | operational review |
management_group_id |
The management group deployed at | composition wiring |
location |
Region storing the deployment metadata | composition wiring |
output_content |
The template's outputs as a JSON string | downstream modules needing an ID the template produced |
1 · Minimal deployment from a template file
module "mg_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
name = "baseline-arm-2026-08"
management_group_id = var.management_group_id
location = "eastus2"
template_content = file("${path.module}/templates/baseline.json")
}💡 Prefer
file(...)orjsonencode({...})over an inline heredoc, so the template stays reviewable and a malformed document fails early.
2 · Deploying from a template spec version (preferred for reuse)
template_spec_version_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-governance/providers/Microsoft.Resources/templateSpecs/mg-baseline/versions/1.2.0"🔒 A template spec is versioned and access-controlled in Azure, rather than living in whichever repository happened to run the apply. For anything used more than once, this is the more governable source.
3 · The template-source exclusive-or, enforced at plan
# ❌ Neither source set — fails at plan, not at apply.
module "bad" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
name = "no-template"
management_group_id = var.management_group_id
location = "eastus2"
}Error: Invalid value for variable
Exactly one of template_content or template_spec_version_id must be set. This check
is placed on template_content because a validation may only reference its own
variable, so it names template_spec_version_id even when you were editing this one.
The provider declares the same pairing with ExactlyOneOf and would reject it too,
one step later.
💡 The provider rejects this too, through its own
ExactlyOneOf, so the plan fails either way. What the module adds is the message: it names a module input rather than a provider attribute. Setting both is rejected on the same terms.
4 · Building the template inline with `jsonencode`
template_content = jsonencode({
"$schema" = "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#"
contentVersion = "1.0.0.0"
parameters = {}
resources = []
})
⚠️ Note the$schema— a management-group deployment needsmanagementGroupDeploymentTemplate.json#. A resource-group-scoped schema is rejected at this scope.
5 · Passing parameter values safely
parameters_content = jsonencode({
pricingTier = { value = "Standard" }
allowedRegions = { value = ["eastus", "eastus2"] }
})
⚠️ Do not pass secrets here. Non-secure parameter values are recorded in the deployment history and are readable by anyone with read access to that scope. A parameter the template declares assecurestringis the exception -- Microsoft documents that a secure parameter's value "isn't saved to the deployment history and isn't logged" -- but that depends on the TEMPLATE's declaration, which this module cannot see or enforce.
6 · Secrets via an ARM Key Vault reference, not through Terraform
parameters_content = jsonencode({
adminPassword = {
reference = {
keyVault = { id = var.key_vault_id }
secretName = "arm-deployment-admin"
}
}
})🔒 ARM resolves the reference at deployment time, so the secret VALUE never passes through Terraform, never lands in state and never appears in deployment history. Be precise about what does: the provider strips only each parameter's
typekey before storingparameters_content, so the vault Resource ID and the secret's name are read back into state. That is the correct pattern for any secret an ARM template needs, and it is not the same as nothing being recorded.
7 · Reading the template's outputs
locals {
arm_outputs = jsondecode(module.mg_deployment.output_content)
}
output "created_definition_id" {
value = local.arm_outputs.policyDefinitionId.value
}
⚠️ output_contentis a pass-through: whatever the template emitted lands in Terraform state. A template that outputs a key puts that key in state — do not have templates emit secrets.
8 · `debug_level` — a secret-disclosure control
# Diagnosing a specific failure, on a deployment carrying no sensitive values.
debug_level = "requestContent, responseContent" # the space is part of the value
⚠️ This is not a verbosity dial. Enabling request or response content writes the template's inputs and outputs — parameter values included — into the deployment history, readable by anyone with read access to the management group. Set it back tonullafterwards.
⚠️ debug_levelIS a closed provider enum --StringInSliceover exactlynone,requestContent,responseContentandrequestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spellingnoneexplicitly produces a perpetual diff, because the provider's flatten returns the empty string for it while the attribute is not Computed -- leave it unset to mean none.
9 · Deploying a tenant-level construct a typed resource cannot express
module "tenant_construct" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
name = "tenant-only-feature-2026-08"
management_group_id = var.tenant_root_management_group_id
location = "eastus2"
template_content = file("${path.module}/templates/tenant-feature.json")
}💡 This is the module's intended use: an ARM-only capability with no first-class
azurerm_*resource. Anything that does have a typed resource belongs in its own module, where it can be reviewed as typed configuration rather than as an opaque JSON payload.
10 · Timeout headroom for a long deployment
timeouts = {
create = "1h"
read = "10m"
update = "1h"
delete = "1h"
}ℹ️ ARM deployments at management-group scope can be long-running — a template that fans out across subscriptions especially so. Give
createandupdatereal headroom.
11 · Name-stamping each deployment
name = "policy-baseline-2026-08" # date- or ticket-stamped, so history is legible💡 The deployment name becomes the entry in the management group's deployment history.
nameis force-new, which is right: each deployment is its own auditable record. Reusing one name across changes hides the sequence.
12 · Tagging the deployment record
tags = {
environment = "prod"
owner = "platform-governance"
ticket = "GOV-1503"
}ℹ️ These tags land on the deployment record, not on the resources the template creates. The template is responsible for tagging its own output.
13 · Several deployments from a keyed map
locals {
arm_deployments = {
defender = { template = "defender-plans.json", params = { pricingTier = { value = "Standard" } } }
diagnostics = { template = "diagnostics-policy.json", params = {} }
}
}
module "arm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
for_each = local.arm_deployments
name = "${each.key}-2026-08"
management_group_id = var.management_group_id
location = "eastus2"
template_content = file("${path.module}/templates/${each.value.template}")
parameters_content = jsonencode(each.value.params)
}💡 One deployment per concern keeps each history entry meaningful and each failure isolated.
14 · Why destroying this resource leaves the resources behind
# terraform destroy removes the deployment RECORD.
# The policy definitions, management groups, or role definitions the template
# created are unaffected — Terraform never tracked them.
⚠️ This is the most important thing to understand about the module. Terraform's state holds a deployment record, not an inventory. Cleaning up what a template created is a separate, deliberate action — which is precisely why anything with a typedazurerm_*resource should use that instead.
15 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
# 1 · The management group everything is scoped to.
module "platform_mg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
display_name = "Platform"
name = "mg-platform"
}
# 2 · A Key Vault holding any secret the template needs, resolved by ARM at deploy time.
module "governance_vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-governance-eastus2"
resource_group_name = var.governance_resource_group_name
location = "eastus2"
tenant_id = var.tenant_id
}
# 3 · The ARM-only construct — this module.
module "arm_bridge" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-template-deployment.git?ref=v1.0.0"
name = "arm-only-feature-2026-08"
management_group_id = module.platform_mg.id
location = "eastus2"
template_content = file("${path.module}/templates/arm-only-feature.json")
# Secrets stay out of Terraform: ARM resolves the reference at deployment time.
parameters_content = jsonencode({
allowedRegions = { value = ["eastus", "eastus2"] }
sharedSecret = {
reference = {
keyVault = { id = module.governance_vault.id }
secretName = "arm-shared-secret"
}
}
})
# Left null deliberately — enabling it would write parameter values into deployment history.
debug_level = null
timeouts = { create = "1h", update = "1h" }
tags = { environment = "prod", owner = "platform-governance", ticket = "GOV-1503" }
}
# 4 · Anything with a typed resource uses the typed module instead of the template.
module "cost_center_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-policy-definition.git?ref=v1.0.0"
name = "require-cost-center-tag"
display_name = "Require a costCenter tag"
policy_type = "Custom"
mode = "Indexed"
management_group_id = module.platform_mg.id
}
# 5 · Consume an ID the template produced.
locals {
arm_outputs = jsondecode(module.arm_bridge.output_content)
}💡 This wiring shows the intended dependency order — management group → Key Vault → deployment — and, more importantly, the boundary: step 3 is the bridge for what ARM alone can express, while step 4 shows the same governance content handled as typed configuration because a first-class resource exists for it. Keep the template's surface as small as that division allows. Output names on sibling modules are illustrative; match them to the versions you pin.
Required: name, management_group_id, location.
Template source (exactly one, enforced at plan): template_content, template_spec_version_id.
Template inputs: parameters_content.
Diagnostics (secret-disclosure control): debug_level.
Universal tail: tags, timeouts.
Full object() schemas
variable "name" { type = string }
variable "management_group_id" { type = string }
variable "location" { type = string } # deployment metadata region only
variable "template_content" {
type = string # JSON; mutually exclusive with template_spec_version_id
default = null
# validation: exactly one of template_content / template_spec_version_id
}
variable "template_spec_version_id" {
type = string # preferred for anything reused
default = null
}
variable "parameters_content" {
type = string # JSON; recorded in deployment history — no secrets
default = null
}
variable "debug_level" {
type = string # null = no debug logging; values defined by the ARM API, not a provider enum
default = null
}
variable "tags" { type = map(string), default = {} }
variable "timeouts" {
type = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
default = null
}| Output | Description | Kind |
|---|---|---|
id |
The deployment's Resource ID: /providers/Microsoft.Management/managementGroups/<group>/providers/Microsoft.Resources/deployments/<name> |
Passthrough |
name |
The deployment's name in the management group's deployment history | Passthrough |
management_group_id |
The management group the deployment ran at, in its CANONICAL form | Passthrough |
management_group_name |
Derived: the management group's NAME, parsed from the canonical ID | Derived |
location |
The region storing the deployment's METADATA, normalised by the provider ("East US 2" is stored as "eastus2") | Passthrough |
tags |
Tags on the deployment RECORD | Passthrough |
output_content |
The template's outputs as a JSON string -- parse with jsondecode() |
Passthrough |
template_source |
Derived: which of the two mutually exclusive sources supplied the template -- inline or template_spec_version |
Derived |
uses_a_template_spec |
Derived: true when a Template Spec version was deployed rather than inline JSON |
Derived |
parameter_names |
Derived: the NAMES of the parameters supplied to the template, sorted | Derived |
parameters_using_key_vault_references |
Derived: the parameters supplied as an ARM Key Vault reference rather than a literal |
Derived |
parameters_with_literal_values |
Derived: the parameters supplied as a literal value |
Derived |
parameters_put_literal_values_in_state |
Derived: true when at least one parameter carries a literal value, which is therefore in state in plaintext |
Derived |
records_request_or_response_content |
Derived, and a security signal rather than a logging one: true when debug_level causes the deployment's request or response payload to be written into the management group's deployment history |
Derived |
debug_level_none_produces_a_perpetual_diff |
Derived: true when debug_level is set to the literal "none" |
Derived |
update_omits_parameters_unless_parameters_content_itself_changed |
Always true at this scope, and it is a genuine divergence from the subscription-scoped sibling rather than a general property of the family | Constant |
destroy_deletes_only_the_deployment_record |
Always true, and the single most important thing to understand about this module | Constant |
deployment_is_always_incremental |
Always true, and emitted because its absence is what is dangerous elsewhere | Constant |
can_reach_every_scope_beneath_the_management_group |
Always true, and the reason the template deserves the review a template gets rather than the review a string gets | Constant |
plan_permits_a_replacement_azure_will_reject_on_location_change |
Always true | Constant |
refresh_requires_export_template_permission |
Always true, and it belongs in the permissions conversation rather than being discovered after someone is granted plan rights | Constant |
template_content_in_state_is_azures_export_not_the_supplied_string |
Always true, and it explains a diff that otherwise looks like drift | Constant |
output_content_shows_no_plan_diff_when_the_template_changes |
Always true at this scope, and it is a silent one | Constant |
No secret is accepted as a dedicated input and none is emitted deliberately.
output_contentreflects whatever the template chose to emit; treat it as untrusted with respect to secrets and do not have templates emit them.
- This is a bridge, not an escape hatch. Anything with a first-class
azurerm_*resource belongs in its own module, where it can be reviewed as typed configuration and where the type system catches a malformed input at parse time. A template is an opaque JSON payload to Terraform: the plan shows a string, so the review has to happen on the template itself. - Terraform does not manage what the template creates. State holds a deployment record, not an inventory. Destroying this resource removes the record; the resources the template created persist. That asymmetry is the strongest argument for keeping the template's surface small.
- The exclusive-or is enforced twice, deliberately. The provider's
ExactlyOneOfalready rejects zero or two at plan, so this is not a gap the module closes -- it is a better error message for a guarantee that already exists. The check lives ontemplate_contentand readstemplate_spec_version_idone-directionally, because Terraform rejects validations that reference each other; a matching check on the other variable would create a cycle. - Deployment history is the leakage path.
parameters_contentvalues are recorded there, anddebug_levelextends that to the template's full request and response content. Anyone with read access to the management group can read both. The safe pattern for a secret is an ARM Key Vault parameterreference, resolved by ARM at deployment time so the value never enters Terraform. debug_levelis a closed provider enum, and the module mirrors it.debug_levelIS a closed provider enum --StringInSliceover exactlynone,requestContent,responseContentandrequestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spellingnoneexplicitly produces a perpetual diff, because the provider's flatten returns the empty string for it while the attribute is not Computed -- leave it unset to mean none.locationis metadata, not placement. A management group has no region. This field is where the deployment record lives; the template decides where its resources go.- Scope reaches further than it looks. A management-group deployment can create subscription- and tenant-level resources, so the effective permission set is the union of everything the template touches.
features {}dependence. The module carries noprovider {}block. If it appears not to initialize in isolation, the cause is a missing caller-sideprovider "azurerm" { features {} }.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Template source ambiguity | exactly one source enforced at plan | — (no opt-out) |
| Template provenance | template spec steered as preferred for reuse | inline template_content |
| Secret handling | no dedicated secret input; Key Vault reference documented |
pass values through parameters_content |
| Debug logging | debug_level = null — no disclosure |
enable request/response content |
| Template outputs | documented as landing in state | have templates emit values anyway |
| Scope of use | documented as an ARM-only bridge | use it as a general deployment mechanism |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0— never a branch. - This library is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials. For this module the plan shows a JSON string rather than resources, so review the template alongside it.
terraform validateproves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises the template-source exclusive-or.terraform fmt -checkenforces canonical formatting.- Neither command validates the template itself. Whether the ARM JSON is well-formed, declares the right deployment schema, and passes ARM's own validation are apply-time facts -- as is whether the identity holds the rights the template's resources require.
terraform plandoes not pre-flight the template at all: the provider's template-validation call is made only from Create and Update, and there is noCustomizeDiffon this resource, so plan reaches ARM for the refresh and nothing more.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
id = "/providers/Microsoft.Management/managementGroups/mg-platform/providers/Microsoft.Resources/deployments/arm-only-feature-2026-08"
name = "arm-only-feature-2026-08"
management_group_id = "/providers/Microsoft.Management/managementGroups/mg-platform"
location = "eastus2"
output_content = "{\"policyDefinitionId\":{\"type\":\"String\",\"value\":\"/providers/Microsoft.Management/managementGroups/mg-platform/providers/Microsoft.Authorization/policyDefinitions/arm-authored-control\"}}"
| Symptom | Cause | Fix |
|---|---|---|
Provider configuration not present / features error |
No caller-side provider "azurerm" { features {} }. |
Add the provider block with features {} in the root module. |
Plan error: supply exactly one of template_content or template_spec_version_id |
Neither set, or both set. | Set exactly one. Prefer the template spec for anything reused. |
| Apply fails: invalid template schema | The template declares a resource-group or subscription deployment schema. | Use $schema ending in managementGroupDeploymentTemplate.json#. |
| Apply fails: authorization failed on a resource the template creates | Contributor at the management group does not cover the rights the template's resources need. |
Grant the specific Microsoft.*/write permissions the template requires, at the smallest scope that works. |
| Apply fails: resource provider not registered | The template uses a provider not registered on the target subscription. | Register it — a template deployment does not register providers for you. |
| A secret appeared in the portal's deployment history | It was passed through parameters_content, or debug_level was enabled. |
Move the secret to an ARM Key Vault parameter reference; set debug_level back to null. |
terraform destroy did not remove what the template created |
Terraform tracks the deployment record, not the resources. | Remove those resources deliberately, or manage them with typed azurerm_* modules instead. |
| A secret ended up in Terraform state | The template emitted it, and output_content is a pass-through. |
Stop the template emitting it; rotate the value. |
| Deployment times out | Management-group deployments fanning out across subscriptions run long. | Raise timeouts.create / timeouts.update. |
- azurerm provider —
azurerm_management_group_template_deployment - Management group deployments with ARM templates
- Use Key Vault to pass a secure parameter value
- Azure template specs
- Sibling modules:
terraform-azurerm-management-group,terraform-azurerm-subscription-template-deploymentandterraform-azurerm-tenant-template-deployment(the narrower and the wider deployment scopes — each needs its own template$schema),terraform-azurerm-policy-definition,terraform-azurerm-management-group-policy-set-definition,terraform-azurerm-key-vault. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."