Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Resource Group Template Deployment Terraform Module

Deploys an ARM template (or a Template Spec version) into one resource group, with the deployment_mode that can delete everything in it that the template does not declare. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat


🧩 Overview

  • πŸ“¦ 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: Complete deletes 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_content and 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_content is 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.


❀️ Support this project

If this module saves you time:


πŸ—ΊοΈ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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

βœ… Provider / Versions

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_mode is required, has no default, and is updatable in place. Flipping Incremental to Complete is 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_content values are read back into state and are not marked sensitive. The provider's read path strips only each parameter's type key and keeps its value.
  • πŸ”΄ terraform destroy deletes what the template created, unless the caller's provider block sets features { template_deployment { delete_nested_items_during_deletion = false } }. That is caller configuration no module can set, and both settings have sharp edges.
  • ⚠️ template_content and template_spec_version_id are ExactlyOneOf. The binary schema records no such constraint, so this module re-expresses it β€” as a validation on template_content referencing the other variable one-directionally, since two variables validating each other is a cycle.
  • ⚠️ A CustomizeDiff marks output_content unknown whenever the template or parameters change. The provider's own source notes the cost: any change to template_content also causes every resource referencing output_content to 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 are Optional + Computed with a JSON-normalising StateFunc. Reformatting the same template produces no diff; omitting parameters_content is not the same as passing {}.
  • ⚠️ Update always re-parses parameters_content, even when unchanged β€” so a syntax error there fails an update that did not touch parameters. And when template_content is unchanged, update re-exports the deployed template from Azure and resubmits it.
  • ⚠️ Create runs an ARM validate call before the write, which is a distinct action from write.
  • ℹ️ tags apply to the deployment record only β€” ARM does not propagate them to what the template creates.

πŸ”‘ Required Azure RBAC Roles / Permissions

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.


Azure Prerequisites

  • An existing resource group this configuration owns. In Complete mode 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_deletion setting, because both values have sharp edges β€” see the constants in Outputs.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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() or jsonencode() 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.


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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_mode has 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_name wants the resource group's name, not its id β€” 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_content sensitive, and neither does this module. Marking it would redact the whole blob from every plan β€” losing the reviewability of vmName and everything like it β€” and would still not encrypt state. The Key Vault reference form removes the secret instead of hiding it.

⚠️ The referenced vault needs enabled_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 check is 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_content is a JSON string rather than a typed object because an ARM output can be a string, a number, an object or an array. jsondecode gives 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_spec so 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 a CanNotDelete management lock on resources you cannot afford to lose. lifecycle is not valid inside a module block, 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_template is 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 reference parameter 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 marks output_content unknown whenever template_content or parameters_content changes, 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 provider block's delete_nested_items_during_deletion = false is shown to make the choice visible, not because it is the right answer. With false, destroying this module orphans the VM β€” still running, still billed, described by nothing. With the default, terraform destroy deletes it. Pick knowingly; the module cannot.

⚠️ The management lock on rg-app exists 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 on template_content and 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_mode right before you apply. Import brings the record under management; the first apply then reconciles. If the original was Incremental and your configuration says Complete, that apply deletes everything in the resource group the template does not declare β€” and the plan will not say so.

⚠️ Importing also brings the deployed parameters_content into state, values included.


πŸ“₯ Inputs

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

🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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 as deletes_resources_not_in_the_template so a policy check can 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.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

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


πŸ§ͺ Testing

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.


πŸ’¬ Example Output

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

πŸ” Troubleshooting

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.

πŸ”— Related Docs

  • azurerm_resource_group_template_deployment β€” provider resource documentation
  • Microsoft.Resources/deployments template reference β€” Microsoft Learn, source of the 1–64 name limit and the detailLevel warning 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 a Complete mode
  • terraform-azurerm-resource-provider-registration β€” for a provider the template needs
  • terraform-azurerm-key-vault β€” for a referenced secret parameter
  • terraform-azurerm-resource-group β€” supplies resource_group_name
  • This module's SCOPE.md

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."