Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Management Group Template Deployment Terraform Module

Deploys an ARM template at management-group scope (azurerm_management_group_template_deployment) for the tenant- and subscription-level constructs Terraform cannot express directly. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🌉 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 ExactlyOneOf on 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_content is optional-and-computed, template_spec_version_id is 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_content and debug_level as secret-disclosure paths, because both write values into the management group's deployment history.
  • 📤 Passes the template's own outputs back as output_content for jsondecode().
  • ⏱️ Gives create and update real 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.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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 ExactlyOneOf on 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_content is optional-and-computed, template_spec_version_id is optional only. The module's check lives on template_content and references the other variable one-directionally, because Terraform rejects validations that reference each other.
  • name, management_group_id, and location are force-new.
  • location stores 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_content values 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_level is a secret-disclosure control, not a verbosity dial: enabling request or response content writes template inputs and outputs into that same history. debug_level IS a closed provider enum -- StringInSlice over exactly none, requestContent, responseContent and requestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spelling none explicitly 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 ($schema ending in managementGroupDeploymentTemplate.json#). A resource-group-scoped template is rejected.
  • output_content is a pass-through: anything the template emits lands in Terraform state.

🔑 Required Azure RBAC Roles / Permissions

  • Contributor at 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 needs Microsoft.Authorization/policyDefinitions/write; one that creates management groups needs Microsoft.Management/managementGroups/write.
  • To deploy from a template spec, Microsoft.Resources/templateSpecs/versions/read on 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.

Azure Prerequisites

  • 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.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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(...) or jsonencode({...}) 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 needs managementGroupDeploymentTemplate.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 as securestring is 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 type key before storing parameters_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_content is 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 to null afterwards.

⚠️ debug_level IS a closed provider enum -- StringInSlice over exactly none, requestContent, responseContent and requestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spelling none explicitly 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 create and update real 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. name is 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 typed azurerm_* 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.


📥 Inputs

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
}

🧾 Outputs

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_content reflects whatever the template chose to emit; treat it as untrusted with respect to secrets and do not have templates emit them.

🧠 Architecture Notes

  • 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 ExactlyOneOf already 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 on template_content and reads template_spec_version_id one-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_content values are recorded there, and debug_level extends 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 parameter reference, resolved by ARM at deployment time so the value never enters Terraform.
  • debug_level is a closed provider enum, and the module mirrors it. debug_level IS a closed provider enum -- StringInSlice over exactly none, requestContent, responseContent and requestContent, responseContent, compared case-sensitively. The space after the comma in the fourth value is part of the string. Spelling none explicitly 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.
  • location is 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 no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

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

🚀 Runbook

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 / apply from CI against real credentials. For this module the plan shows a JSON string rather than resources, so review the template alongside it.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema, and exercises the template-source exclusive-or.
  • terraform fmt -check enforces 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 plan does not pre-flight the template at all: the provider's template-validation call is made only from Create and Update, and there is no CustomizeDiff on this resource, so plan reaches ARM for the refresh and nothing more.

💬 Example Output

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\"}}"

🔍 Troubleshooting

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.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."