Deploys an ARM template (or a Template Spec version) into one resource group, with the
deployment_modethat can delete everything in it that the template does not declare. Targetshashicorp/azurerm ~> 4.0.
- π¦ Creates one
azurerm_resource_group_template_deploymentβ an ARM deployment into one resource group, from inline JSON or a versioned Template Spec. - π΄ Carries
deployment_mode, the only genuinely dangerous argument in this family:Completedeletes resources in the resource group that the template does not declare, and Azure performs that deletion, so it appears in no Terraform plan. - π Parses
parameters_contentand reports which parameters carry a literal value (written to state, unredacted) versus a Key Vault reference (a pointer only). - π© Emits four constants for facts nothing in a plan shows: who performs Complete-mode deletion, that a destroy
deletes what the template created unless the caller's provider block says otherwise, that parameter values are
read back into state, and that
output_contentis recomputed on any template change.
π‘ Why it matters: this resource is a hole in Terraform's model. The things it creates are not in your state β ARM owns them, Terraform owns only the deployment record. That is the trade-off you accept in exchange for deploying a template at all, and it means
deployment_mode,destroy, and every parameter value behave in ways the surrounding configuration cannot see.
If this module saves you time:
- β Star the repository β it is the cheapest signal that the work is worth continuing.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group, whose name is BOTH the target and the Complete-mode blast radius"]
SPEC["Azure template spec version, an immutable artifact and the more auditable of the two sources"]
KV["terraform-azurerm-key-vault, referenced by an ARM parameter so the secret never enters Terraform state"]
THIS["terraform-azurerm-resource-group-template-deployment"]
DEP["azurerm_resource_group_template_deployment: the DEPLOYMENT RECORD"]
ARM["ARM creates everything the template declares. Those resources are NOT in this state -- the central trade-off of any template-deployment module."]
DELETED["Complete mode: resources in the resource group the template does not declare, DELETED BY AZURE, in no plan"]
TYPED["terraform-azurerm-storage-account and other typed modules, preferred wherever a typed resource exists"]
SUBTD["terraform-azurerm-subscription-template-deployment: the same deployment one scope wider"]
MGTD["terraform-azurerm-management-group-template-deployment"]
TENTD["terraform-azurerm-tenant-template-deployment"]
RG -->|"resource_group_name"| THIS
SPEC -->|"template_spec_version_id, exclusive with template_content"| THIS
KV -->|"ARM parameter reference resolved at deploy time"| DEP
THIS -->|"creates"| DEP
DEP -->|"deploys"| ARM
DEP -->|"deployment_mode Complete also destroys"| DELETED
TYPED -->|"preferred where a typed resource exists"| ARM
THIS -->|"wider scope, and Complete mode does not exist there"| SUBTD
SUBTD --> MGTD
MGTD --> TENTD
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class THIS me
class DEP,ARM,DELETED keystone
class RG,SPEC,KV,TYPED,SUBTD,MGTD,TENTD sibling
Two edges deserve attention. terraform-azurerm-key-vault connects to the deployment, not to this module β
an ARM parameter reference is resolved by Azure at deploy time, which is how a secret reaches the template
without passing through Terraform state. And the DELETED node exists only at this scope: the wider
subscription, management-group and tenant deployments have no Complete mode at all.
flowchart TB
VNAME["var.name, force-new, 1 to 64 chars"]
VRG["var.resource_group_name, force-new"]
VMODE["var.deployment_mode, REQUIRED, Incremental or Complete, updatable in place"]
VTC["var.template_content, JSON"]
VTS["var.template_spec_version_id"]
VPC["var.parameters_content, free-form JSON that MAY carry secrets"]
VDBG["var.debug_level, off by default"]
THIS["azurerm_resource_group_template_deployment.this"]
OID["output id"]
ODEL["output deletes_resources_not_in_the_template"]
OLIT["outputs parameters_with_literal_values and parameters_using_key_vault_references"]
ODBG["output debug_logging_enabled"]
OOUT["output output_content, a JSON string for jsondecode"]
VNAME --> THIS
VRG --> THIS
VMODE -->|"Incremental is the safe value"| THIS
VTC -->|"exactly one of these two"| THIS
VTS -->|"exactly one of these two"| THIS
VPC -->|"values are read back into state, unredacted"| THIS
VDBG -->|"enabling it can log resolved secrets"| THIS
THIS --> OID
THIS --> OOUT
VMODE -->|"named for the consequence, not the enum"| ODEL
VPC -->|"derived from the JSON the module can actually parse"| OLIT
VDBG --> ODBG
classDef me fill:#0078D4,stroke:#004578,color:#ffffff
classDef keystone fill:#004578,stroke:#002438,color:#ffffff
classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
class THIS keystone
class OID,ODEL,OLIT,ODBG,OOUT me
class VNAME,VRG,VMODE,VTC,VTS,VPC,VDBG sibling
| Resource | Count | Notes |
|---|---|---|
azurerm_resource_group_template_deployment |
1 (this) |
name and resource_group_name force-new; everything else updates in place, including deployment_mode. |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm" { features {} }, auth, and subscription |
| Azure API provider | Microsoft.Resources β deployments |
| Scope | One resource group |
Schema notes that bite:
- π΄
deployment_modeis required, has no default, and is updatable in place. FlippingIncrementaltoCompleteis not a replacement β the next apply just reconciles destructively. - π΄ Complete-mode deletion is performed by Azure, not Terraform. It appears in no plan, removes no state entry, and is not limited to resources this configuration created.
- π΄
parameters_contentvalues are read back into state and are not marked sensitive. The provider's read path strips only each parameter'stypekey and keeps itsvalue. - π΄
terraform destroydeletes what the template created, unless the caller's provider block setsfeatures { template_deployment { delete_nested_items_during_deletion = false } }. That is caller configuration no module can set, and both settings have sharp edges. β οΈ template_contentandtemplate_spec_version_idareExactlyOneOf. The binary schema records no such constraint, so this module re-expresses it β as a validation ontemplate_contentreferencing the other variable one-directionally, since two variables validating each other is a cycle.β οΈ ACustomizeDiffmarksoutput_contentunknown whenever the template or parameters change. The provider's own source notes the cost: any change totemplate_contentalso causes every resource referencingoutput_contentto update.β οΈ The provider's name check is looser than Azure's. Its regex has no length bound, while Azure documents 1β64 characters; and its error message omits parentheses, which the regex and Azure both allow. This module enforces the documented limit.β οΈ Both content fields areOptional + Computedwith a JSON-normalisingStateFunc. Reformatting the same template produces no diff; omittingparameters_contentis not the same as passing{}.β οΈ Update always re-parsesparameters_content, even when unchanged β so a syntax error there fails an update that did not touch parameters. And whentemplate_contentis unchanged, update re-exports the deployed template from Azure and resubmits it.β οΈ Create runs an ARMvalidatecall before the write, which is a distinct action fromwrite.- βΉοΈ
tagsapply to the deployment record only β ARM does not propagate them to what the template creates.
| Scope | Role / permission | Why |
|---|---|---|
| The target resource group | Microsoft.Resources/deployments/write, /read, /delete |
Creating, refreshing and deleting the deployment record. |
| The target resource group | Microsoft.Resources/deployments/validate/action |
Create and update both run an ARM validation call before the write. A role granting only write fails at the validate step. |
| The target resource group | Microsoft.Resources/deployments/exportTemplate/action |
Every refresh exports the deployed template, and an update does too when template_content is unchanged. |
| The target resource group | whatever the template itself needs β usually Contributor | ARM creates the template's resources using the caller's identity. A template that assigns roles needs User Access Administrator or Owner; one that creates a Key Vault needs the Key Vault actions. |
| The target resource group | delete rights on every resource type in the template | A terraform destroy walks the template and deletes each resource, unless the provider's delete_nested_items_during_deletion is false. |
π΄ The permission this module needs is unbounded, because the template's contents are unbounded. Unlike every other module in this library, the required grant cannot be enumerated from the resource type β it is a function of the JSON the caller supplies. Read the template before granting; a template deployment is an arbitrary-code-execution primitive with Azure Resource Manager as the interpreter.
β οΈ A plan reads more than it looks like. A refresh exports the deployed template and reads back the deployed parameters, so plan access here does expose parameter values β including any literal secret. That is unusual for this library, where plan access is normally not credential access.
- An existing resource group this configuration owns. In
Completemode everything else in it is at risk. - A template: inline JSON, or a Template Spec version ID.
- Every resource provider the template uses, registered on the subscription β otherwise the deployment fails
at ARM validation with an unhelpful API-version error. See
terraform-azurerm-resource-provider-registration. - For a secret parameter: an existing Key Vault with the secret in it, and the vault configured to permit
template deployment reference (
enabled_for_template_deployment). - A decision about the provider's
delete_nested_items_during_deletionsetting, because both values have sharp edges β see the constants in Outputs.
terraform-azurerm-resource-group-template-deployment/
βββ providers.tf # required_version + the azurerm ~> 4.0 pin. No provider block.
βββ variables.tf # name, resource_group_name, deployment_mode, the two exclusive template sources,
β # parameters_content, debug_level, tags, timeouts
βββ main.tf # the keystone resource + a guarded jsondecode of the parameters + derived posture flags
βββ outputs.tf # id first, then the scope and mode, output_content, the derived flags, then four constants
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "vnet_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vnet"
resource_group_name = var.app_resource_group_name
# Incremental is the safe value. Complete DELETES resources the template does not declare.
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vnet.json")
parameters_content = jsonencode({
vnetName = { value = var.vnet_name }
})
}βΉοΈ The caller owns provider configuration, authentication and the mandatory
features {}block.π‘
file()orjsonencode()rather than a heredoc: a heredoc interpolates${...}, and a template assembled from Terraform values acquires one easily. ARM's own expression syntax uses[...], so a static template is safe either way β but the habit is worth keeping.
Consumes
| Input | Type | Source module |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group β name |
template_spec_version_id |
string |
a Template Spec version, managed out of band |
| a Key Vault for a secret parameter | β | terraform-azurerm-key-vault β id, referenced inside parameters_content |
| everything else | β | caller-supplied |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The deployment's Resource ID | reporting, imports |
output_content |
The template's outputs, as JSON | jsondecode(...) in the caller |
deletes_resources_not_in_the_template |
Whether Complete mode is in force | check blocks |
parameters_with_literal_values / ..._using_key_vault_references / parameters_put_literal_values_in_state |
Parameter secrecy posture | check blocks, review |
debug_logging_enabled |
Whether Azure is logging request/response bodies | check blocks |
| four constants | See Outputs | change review |
1 Β· The minimum safe call
module "vnet_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vnet"
resource_group_name = "rg-app"
deployment_mode = "Incremental"
template_content = jsonencode({
"$schema" = "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#"
contentVersion = "1.0.0.0"
resources = []
})
}βΉοΈ
deployment_modehas no default β the provider requires it. That is deliberate on the provider's part and this module keeps it: a silently-defaulted destructive mode would be worse than an argument you must type.
2 Β· Wiring the resource group
module "app_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-app"
location = "eastus"
}
module "vnet_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vnet"
resource_group_name = module.app_rg.name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vnet.json")
}π‘
resource_group_namewants the resource group'sname, not itsidβ the opposite of the cost-management modules in this library, and the mistake this module's validation catches.
3 Β· Parameters, and where a secret should not go
module "vm_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vm"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vm.json")
parameters_content = jsonencode({
# Fine: not a secret, and useful to see in a plan.
vmName = { value = "vm-app-01" }
# NOT fine: this value is written into Terraform state and shown in plan output.
# adminPassword = { value = var.admin_password }
# Fine: ARM resolves this at deploy time, so only the POINTER is in state.
adminPassword = {
reference = {
keyVault = { id = var.key_vault_id }
secretName = "vm-admin-password"
}
}
})
}π΄ The provider does not mark
parameters_contentsensitive, and neither does this module. Marking it would redact the whole blob from every plan β losing the reviewability ofvmNameand everything like it β and would still not encrypt state. The Key Vault reference form removes the secret instead of hiding it.
β οΈ The referenced vault needsenabled_for_template_deployment = true, or ARM cannot read the secret.
4 Β· Asserting no secret was passed literally
check "no_literal_parameters" {
assert {
condition = module.vm_deployment.parameters_put_literal_values_in_state == false
error_message = "A parameter was passed as a literal value, which puts it in Terraform state and plan output. Use an ARM Key Vault reference."
}
}
output "parameter_secrecy_review" {
value = {
literal = module.vm_deployment.parameters_with_literal_values
kv_referenced = module.vm_deployment.parameters_using_key_vault_references
}
}π That
checkis strict β most templates have legitimate literal parameters. The softer, more useful review is the output beside it: the module can see which parameters are literal, but not which are secrets, so the list is for a human. Assert the boolean only where the template is known to take credentials.
5 Β· Consuming the template's outputs
module "storage_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-storage"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/storage.json")
}
locals {
deployment_outputs = jsondecode(module.storage_deployment.output_content)
storage_account_id = local.deployment_outputs.storageAccountId.value
}
output "storage_account_id" {
value = local.storage_account_id
}βΉοΈ
output_contentis a JSON string rather than a typed object because an ARM output can be a string, a number, an object or an array.jsondecodegives the caller the shape they actually declared.
β οΈ Each output is wrapped:.storageAccountId.value, not.storageAccountId. And a template that emits a secret puts it in this string β treat that as a defect in the template, not something this module can redact.
6 Β· A Template Spec version instead of inline JSON
module "baseline_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-baseline"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_spec_version_id = var.baseline_template_spec_version_id
parameters_content = jsonencode({
environment = { value = "production" }
})
}π‘ The more auditable of the two sources: a Template Spec version is an immutable artifact with its own identity, rather than a copy of JSON in each configuration that uses it. The module emits
uses_a_template_specso a governance review can require it.
β οΈ The ID must end in/versions/<version>. A bare Template Spec ID is rejected at plan time.
7 Β· Complete mode, done deliberately
# A resource group whose ENTIRE contents are described by this one template.
module "app_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-app-declarative"
location = "eastus"
}
module "declarative_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-everything"
resource_group_name = module.app_rg.name
# Deliberate: this template is the sole description of the resource group's contents.
deployment_mode = "Complete"
template_content = file("${path.module}/templates/whole-environment.json")
}π΄ Complete mode is legitimate in exactly this shape and dangerous everywhere else: a resource group that no other configuration writes to, whose whole content is one template. The moment a second Terraform configuration, a portal change, or another team puts a resource in that group, the next apply deletes it β silently, because Azure performs the deletion and no Terraform plan mentions it.
β οΈ Consider aCanNotDeletemanagement lock on resources you cannot afford to lose.lifecycleis not valid inside amoduleblock, so it is not available to you here.
8 Β· Guarding against Complete mode arriving by accident
check "deployment_is_additive_only" {
assert {
condition = module.vnet_deployment.deletes_resources_not_in_the_template == false
error_message = "This deployment is in Complete mode and will delete resources in rg-app that the template does not declare. Azure performs that deletion, so it appears in no plan."
}
}π The output is named for the consequence rather than the enum, because
deployment_mode = "Complete"is what a reviewer skims past.deletes_resources_not_in_the_templateis not skimmable.
9 Β· Turning debug logging on, and off again
variable "debug_this_deployment" {
description = "Temporarily log ARM request and response bodies. Leave false."
type = bool
default = false
}
module "vm_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vm"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vm.json")
debug_level = var.debug_this_deployment ? "requestContent, responseContent" : null
}
check "debug_logging_is_off" {
assert {
condition = module.vm_deployment.debug_logging_enabled == false
error_message = "ARM debug logging is enabled for this deployment. It can record resolved secrets into the resource group's deployment history."
}
}π΄ This defeats the Key Vault reference mitigation. Microsoft's own warning: logging request or response content "could potentially expose sensitive data that is retrieved through the deployment operations" β which includes the value a
referenceparameter resolved to. The deployment history is readable by anyone with Reader on the resource group.βΉοΈ Note the exact string, with a space after the comma:
"requestContent, responseContent".
10 Β· Setting timeouts
module "big_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-platform"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/platform.json")
timeouts = {
create = "5h"
read = "10m"
update = "5h"
delete = "5h"
}
}βΉοΈ The defaults are already three hours for create, update and delete, because an ARM deployment can contain anything and the ceiling is set by the slowest resource in the template. Raise them for a template containing something genuinely slow, such as an ExpressRoute circuit or a large SQL Managed Instance.
11 Β· What a template edit does to everything downstream
module "storage_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-storage"
resource_group_name = var.app_resource_group_name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/storage.json")
}
# Anything consuming an output will show as changing when the template changes.
module "app_config" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = jsondecode(module.storage_deployment.output_content).resourceGroupName.value
location = "eastus"
}
β οΈ The provider marksoutput_contentunknown whenevertemplate_contentorparameters_contentchanges, so a one-line template edit produces a plan that also proposes changes to every resource reading an output. The provider's own source calls this "the adverse effect" of the fix it was added for. It is correct behaviour, and worth expecting before a small edit turns into a wide plan.π‘ Reformatting the template alone does not trigger it β the comparison is on normalised JSON.
12 Β· ποΈ End-to-end composition β registration, vault, deployment, and the guards
provider "azurerm" {
features {
template_deployment {
# Deliberate: a destroy of the deployment record leaves ARM's resources in place rather than
# deleting them. Read the constants in this module's Outputs before choosing either value.
delete_nested_items_during_deletion = false
}
}
# Required by the resource-provider module below.
resource_provider_registrations = "none"
}
data "azurerm_client_config" "current" {}
variable "template_takes_credentials" {
description = "Whether this template has any parameter that must not be a literal value."
type = bool
default = true
}
module "app_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-app"
location = "eastus"
management_locks = {
no_delete = {
lock_level = "CanNotDelete"
notes = "Holds resources created by an ARM deployment and therefore absent from Terraform state."
}
}
}
# 1. The template needs Microsoft.Compute registered, or ARM validation fails with an API-version error
# that names nothing useful.
module "compute_provider" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-provider-registration.git?ref=v1.0.0"
name = "Microsoft.Compute"
}
# 2. The vault holding the admin password. Its secret is created out of band.
module "app_vault" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-app-prod"
resource_group_name = module.app_rg.name
location = module.app_rg.location
tenant_id = data.azurerm_client_config.current.tenant_id
# Required for an ARM parameter reference: without it Azure cannot read the secret at deploy time.
enabled_for_template_deployment = true
}
# 3. The deployment. Incremental, because rg-app holds more than this template describes.
module "vm_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vm"
resource_group_name = module.app_rg.name
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vm.json")
parameters_content = jsonencode({
vmName = { value = "vm-app-01" }
adminPassword = {
reference = {
keyVault = { id = module.app_vault.id }
secretName = "vm-admin-password"
}
}
})
tags = {
owner = "platform"
}
depends_on = [module.compute_provider]
}
check "deployment_is_additive_only" {
assert {
condition = module.vm_deployment.deletes_resources_not_in_the_template == false
error_message = "rg-app holds resources this template does not declare. Complete mode would delete them, and Azure performs that deletion outside any Terraform plan."
}
}
check "credentials_are_not_literal" {
assert {
condition = var.template_takes_credentials == false || module.vm_deployment.parameters_put_literal_values_in_state == false || length(module.vm_deployment.parameters_using_key_vault_references) > 0
error_message = "This template takes credentials but no parameter uses a Key Vault reference -- a literal secret would be written into Terraform state."
}
}
check "debug_logging_is_off" {
assert {
condition = module.vm_deployment.debug_logging_enabled == false
error_message = "ARM debug logging is on, which can record resolved Key Vault values into the deployment history."
}
}
output "deployment_posture" {
value = {
id = module.vm_deployment.id
subscription = module.vm_deployment.subscription_id
template_source = module.vm_deployment.template_source
mode = module.vm_deployment.deployment_mode
literal_params = module.vm_deployment.parameters_with_literal_values
kv_params = module.vm_deployment.parameters_using_key_vault_references
vm_fqdn = jsondecode(module.vm_deployment.output_content).fqdn.value
}
}π Three guards, because this resource has three independent silent failure modes: destructive reconciliation, a secret in state, and a debugging setting left on. None of the three produces an error, and none appears in a plan diff.
π΄ The
providerblock'sdelete_nested_items_during_deletion = falseis shown to make the choice visible, not because it is the right answer. Withfalse, destroying this module orphans the VM β still running, still billed, described by nothing. With the default,terraform destroydeletes it. Pick knowingly; the module cannot.
β οΈ The management lock onrg-appexists precisely because ARM's resources are not in Terraform state, so Terraform's own protections do not cover them.
13 Β· Both template sources, or neither
module "vnet_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vnet"
resource_group_name = "rg-app"
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vnet.json")
template_spec_version_id = var.baseline_template_spec_version_id # <-- one too many
}Error: Invalid value for variable
Exactly one of template_content or template_spec_version_id must be set -- the provider
declares them mutually exclusive and requires one.
π‘ The provider expresses this as
ExactlyOneOf, which the binary schema does not record β so re-expressing it here buys a plan-time error with a readable message. Both halves of the pairing live ontemplate_contentand reference the other variable one-directionally: two variables validating each other is rejected as a cycle.
14 Β· Importing a deployment somebody ran by hand
import {
to = module.vnet_deployment.azurerm_resource_group_template_deployment.this
id = "/subscriptions/${var.subscription_id}/resourceGroups/rg-app/providers/Microsoft.Resources/deployments/deploy-vnet"
}
module "vnet_deployment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-template-deployment.git?ref=v1.0.0"
name = "deploy-vnet"
resource_group_name = "rg-app"
# Match what the existing deployment actually used, or the next apply re-runs it destructively.
deployment_mode = "Incremental"
template_content = file("${path.module}/templates/vnet.json")
}π΄ Get
deployment_moderight before you apply. Import brings the record under management; the first apply then reconciles. If the original wasIncrementaland your configuration saysComplete, that apply deletes everything in the resource group the template does not declare β and the plan will not say so.
β οΈ Importing also brings the deployedparameters_contentinto state, values included.
| Name | Type | Required | Summary |
|---|---|---|---|
name |
string |
β | 1β64 chars. Force-new. |
resource_group_name |
string |
β | The resource group's name. Force-new. |
deployment_mode |
string |
β | Incremental (safe) or Complete (deletes). Updatable. |
template_content |
string |
β | ARM template JSON. Exclusive with the next. |
template_spec_version_id |
string |
β | A Template Spec version ID. Exclusive with the previous. |
parameters_content |
string |
β | Parameter JSON. Values are stored unredacted. |
debug_level |
string |
β | Unset means none. Enabling can log secrets. |
tags |
map(string) |
β | On the deployment record only. |
timeouts |
object |
β | create / read / update / delete. |
Full schemas
variable "deployment_mode" {
type = string
}
variable "template_content" {
type = string
default = null
}
variable "template_spec_version_id" {
type = string
default = null
}
variable "parameters_content" {
type = string
default = null
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}Validation rules, all fifteen reached by the offline proof fixtures:
| Variable | Rules |
|---|---|
name |
1β64 characters (Azure's documented limit, which the provider's own regex does not bound); the documented character class, parentheses included |
resource_group_name |
non-empty; not a Resource ID |
deployment_mode |
Incremental or Complete, with the safe value named in the message |
template_content |
exactly one of it and template_spec_version_id; valid JSON when set |
template_spec_version_id |
an anchored Template Spec version Resource ID |
parameters_content |
valid JSON when set; every entry carries a value or a reference, guarded so a malformed document cannot make the decode throw |
debug_level |
the four documented values, including the one with a space after the comma |
tags |
none β a free-form map(string) with nothing closed to check |
timeouts |
each of the four keys matches a Go duration |
| Output | Type | Notes |
|---|---|---|
id |
string |
The deployment's Resource ID. |
name / resource_group_name |
string |
Identity and target. |
subscription_id |
string |
Parsed. Not sensitive β an identifier, not a credential. |
deployment_mode |
string |
As configured. |
output_content |
string |
The template's outputs as JSON. Use jsondecode. |
debug_level |
string |
Empty means nothing is logged. |
tags |
map(string) |
On the record only. |
deletes_resources_not_in_the_template |
bool |
Named for the consequence. Assert false. |
is_complete_mode |
bool |
The same fact in the provider's vocabulary. |
template_source |
string |
Which of the two exclusive sources was used. |
uses_a_template_spec |
bool |
Whether the source is an immutable versioned artifact. |
parameter_names |
list(string) |
Sorted. |
parameters_with_literal_values |
list(string) |
Sorted. These are in state and in plan output. |
parameters_using_key_vault_references |
list(string) |
Sorted. Pointers only. |
parameters_put_literal_values_in_state |
bool |
For a check. |
debug_logging_enabled |
bool |
For a check. |
complete_mode_deletion_is_performed_by_azure_and_appears_in_no_plan |
bool |
Constant true. |
destroying_this_deletes_what_the_template_created_unless_the_provider_is_told_otherwise |
bool |
Constant true. |
parameter_values_are_read_back_into_state_and_are_not_redacted |
bool |
Constant true. |
output_content_is_recomputed_whenever_the_template_or_parameters_change |
bool |
Constant true. |
21 outputs: 7 passthrough, 10 derived, 4 constant. No output is marked sensitive, and that is a considered
position rather than an omission β parameters_content and output_content can both carry secrets, the
provider marks neither, and marking them here would redact plan output without encrypting state. See Design
Principles.
The resources this module creates are not in your state. Terraform owns the deployment record; ARM owns
everything the template declares. That is the trade-off you accept in exchange for deploying a template at all,
and almost every surprise in this module follows from it. terraform destroy behaves according to a provider
setting rather than state. terraform plan cannot show you drift in a VM the template created. A management lock
is the only protection that reaches those resources. Where a typed azurerm resource exists, prefer it; use this
where one does not, or where the template is an artifact you are obliged to deploy as-is.
Complete mode is the sharpest edge in this library. It is required, has no default, is updatable in place,
and its effect is a deletion performed by Azure β so it appears in no plan, removes no state entry, and does not
distinguish between resources this configuration created and resources someone else's did. This suite's
secure-by-default rule cannot apply, because a required argument leaves no empty call to make safe. What the
module does instead is name the safe value inside the variable's description and its error message, and emit the
consequence as deletes_resources_not_in_the_template β a name a reviewer cannot skim past the way they skim
past "Complete".
The permission this module needs cannot be enumerated. Every other module in this library has a bounded RBAC
table derived from its resource type. Here the required grant is a function of the caller's JSON: a template that
assigns roles needs User Access Administrator, one that creates a vault needs the Key Vault actions. A template
deployment is an arbitrary-code-execution primitive with ARM as the interpreter, and the permissions table says
so rather than pretending otherwise. Note also that create and update run an ARM validate call before the
write, so a role granting only write fails at a step nothing in the schema mentions.
Parameter secrecy has a right answer, and it is not sensitive = true. The provider reads deployed
parameters back into state, keeping each value and stripping only its type, and does not mark the attribute
sensitive. Marking the module's variable sensitive would redact the entire parameters blob from every plan β
costing the reviewability of every ordinary parameter β while leaving the value in state, which is where it
matters. ARM's Key Vault reference form removes the secret instead: the pointer is in state and Azure resolves
the value at deploy time. The module cannot tell which parameter names are secrets, so it reports what it can
parse β parameters_with_literal_values and parameters_using_key_vault_references β and leaves the judgement
to a reviewer or a check.
And debug_level undoes that mitigation. Microsoft's own wording is that logging request or response content
"could potentially expose sensitive data that is retrieved through the deployment operations". That includes the
value a Key Vault reference resolved to, written into a deployment history readable by anyone with Reader on the
resource group. It is the one setting in this module that is safe by default and unsafe if forgotten, which is
why debug_logging_enabled exists to be asserted.
Two constraints re-expressed, and one deliberately not. ExactlyOneOf on the two template sources is absent
from the binary schema, so the module re-expresses it β placed on template_content and referencing the other
variable one-directionally, since two variables validating each other is a cycle. The name length limit is
enforced because Azure documents it and the provider's regex does not. But parameters_content is only checked
for JSON validity and the parameter-wrapper shape: whether a given parameter exists in the template, or has the
right type, is knowable only to ARM.
output_content churn is by design. A CustomizeDiff marks it unknown whenever the template or parameters
change, comparing normalised JSON so a reformat alone is free. The provider's source calls the consequence "the
adverse effect": everything referencing an output shows as changing too.
features {} is the caller's β and unusually, one of its sub-blocks changes what this module's destroy does.
| Concern | This module's position | Opt-out |
|---|---|---|
| Destructive reconciliation | π΄ No default possible β the provider requires deployment_mode. Incremental is named as the safe value in the description, the error message, and a derived flag |
the caller must type Complete |
| Secret parameters | Not marked sensitive, deliberately; the Key Vault reference form is documented as the actual fix, and the literal/reference split is emitted |
pass a literal and accept it in state |
| Debug logging | Off by default (unset means none), with Microsoft's warning quoted and a flag to assert |
set debug_level |
| Template source | Either accepted; the versioned Template Spec is documented as the more auditable, and emitted as a flag | β |
name length |
Azure's documented 1β64 enforced, which the provider does not | β |
| Scope confusion | A Resource ID passed where a name belongs is rejected | β |
| Resources created by the template | Documented as absent from Terraform state, with a management lock recommended | β |
π΄ This suite's secure-by-default rule genuinely cannot apply to
deployment_mode, and the compensations are the three available. The restrictive value is named inside the variable's own description and inside the validation error message; the resulting posture is emitted positively asdeletes_resources_not_in_the_templateso a policycheckcan require it; and the fact that the deletion is invisible to Terraform is recorded as a constant rather than left in prose. Saying the rule does not apply is more useful than restating it.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the source at a tag β ?ref=v1.0.0 β never a branch. Everything here is plan-only; a human applies from CI.
Before the first apply: read the template. Confirm deployment_mode, confirm every resource provider it uses is
registered, and confirm no parameter carries a literal secret.
| Covered by | What it proves |
|---|---|
terraform validate |
The configuration parses, types resolve, references exist. |
terraform fmt -check |
Canonical formatting. |
terraform console with a .tfvars file |
Variable validations actually fire. All fifteen validation blocks were reached by deliberately bad fixtures, with zero condition-evaluation errors, and every derived local was driven to more than one value. |
terraform plan (credentials required, not run here) |
ARM's own template validation runs as part of create and update β not at plan. |
What nothing static can prove, and it is a lot: whether the template is valid ARM, whether its parameters match, whether the identity can create what it declares, and what Complete mode would delete. The module validates the shape of the JSON it is given and nothing about its meaning.
complete_mode_deletion_is_performed_by_azure_and_appears_in_no_plan = true
debug_level = ""
debug_logging_enabled = false
deletes_resources_not_in_the_template = false
deployment_mode = "Incremental"
destroying_this_deletes_what_the_template_created_unless_the_provider_is_told_otherwise = true
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app/providers/Microsoft.Resources/deployments/deploy-vm"
is_complete_mode = false
name = "deploy-vm"
output_content = "{\"fqdn\":{\"type\":\"String\",\"value\":\"vm-app-01.eastus.cloudapp.azure.com\"}}"
output_content_is_recomputed_whenever_the_template_or_parameters_change = true
parameter_names = [
"adminPassword",
"vmName",
]
parameter_values_are_read_back_into_state_and_are_not_redacted = true
parameters_put_literal_values_in_state = true
parameters_using_key_vault_references = [
"adminPassword",
]
parameters_with_literal_values = [
"vmName",
]
resource_group_name = "rg-app"
subscription_id = "00000000-0000-0000-0000-000000000000"
tags = {
"owner" = "platform"
}
template_source = "template_content"
uses_a_template_spec = false
| Symptom | Cause | Fix |
|---|---|---|
| Resources vanished from the resource group and no plan mentioned them | deployment_mode = "Complete". Azure deleted everything the template does not declare. |
Switch to Incremental and add the check from example 8. Recreate from whichever configuration owned them. |
| Apply fails at a "validating" step before anything is created | Create and update run an ARM validate call first. The identity may lack deployments/validate/action, or the template may be invalid. |
Read the returned ARM message β it is the API's, not the provider's. Grant the validate action. |
| Apply fails with an API-version error naming a resource type | A resource provider the template uses is not registered on the subscription. | Register it β terraform-azurerm-resource-provider-registration. |
| An admin password is visible in the state file | parameters_content values are read back into state and are not redacted. |
Move it to an ARM Key Vault reference β example 3. Rotate the exposed secret. |
| A secret appeared in the deployment history despite using a Key Vault reference | debug_level was enabled; ARM logged the resolved value. |
Unset debug_level, rotate the secret, and assert debug_logging_enabled == false. |
| A one-line template edit produced a plan touching unrelated resources | The provider marks output_content unknown on any template or parameters change. |
Expected. Reformatting alone does not do it; a genuine change does. |
terraform destroy deleted a VM the template created |
The provider's default is to delete what the template provisioned. | Set features { template_deployment { delete_nested_items_during_deletion = false } } if that is not wanted β knowing it then orphans the resources. |
terraform destroy left everything running |
The same setting, set to false. The resources are orphaned. |
Delete them out of band, or manage them with typed modules. |
An update failed on parameters_content although parameters were not changed |
The update path re-parses parameters_content unconditionally. |
Fix the JSON. The module's own validation catches this at plan. |
| Plan shows no diff after reindenting the template | Both content fields are normalised before storage. | Working as intended. |
| A 70-character deployment name failed at apply, not at plan | The provider's name regex has no length bound; Azure's limit is 64. | This module now rejects it at plan. |
azurerm_resource_group_template_deploymentβ provider resource documentation- Microsoft.Resources/deployments template reference β Microsoft Learn, source of the 1β64 name limit and the
detailLevelwarning quoted above - Deployment modes β Microsoft Learn, on what Complete mode deletes
- Use Key Vault to pass a secure parameter value β Microsoft Learn
terraform-azurerm-subscription-template-deployment/-management-group-template-deployment/-tenant-template-deploymentβ the same deployment at wider scopes, none of which has aCompletemodeterraform-azurerm-resource-provider-registrationβ for a provider the template needsterraform-azurerm-key-vaultβ for a referenced secret parameterterraform-azurerm-resource-groupβ suppliesresource_group_name- This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."