Patch a subset of properties on an existing ARM resource without owning its lifecycle β set the one property
azurermcannot express, flip a preview flag, or manage a slice of a resource another team created. Wraps theazapi_update_resourceprimitive with the secure defaults; ondestroyit does nothing, so it never fights the owner. Built for the Azureazapiprovider v2.x (~> 2.10).
This module wraps the azapi_update_resource primitive β a surgical patch tool for resources you do
not own:
- π©Ή Patch, don't own β manages only the properties present in
body; everything else on the resource is left untouched. Create and delete are never performed. - 𧨠Destroy is a no-op β removing the module stops managing those properties; their last-applied values remain. It never deletes the target.
- π― Single, unambiguous target β by
resource_id. (The provider treatsname/parent_idas mutually exclusive withresource_id, so this module exposes the one clear mode.) - π Write-only secrets β rotate a secret property via
sensitive_body, never persisted to state. - π« Export nothing by default β
response_export_values = [].
π‘ Why it matters: platform teams constantly need to set one property on a resource someone else owns β enforce
minimumTlsVersion = "TLS1_2"on an account another team created, or set a field the typed provider hasn't caught up to.azapi_update_resourcepatches exactly what you list and leaves the rest alone, so two teams can co-manage one resource without a tug-of-war.
β οΈ This primitive has notags/location/identity/schema_validation_enabled/ignore_null_propertyβ they are absent from the provider schema and therefore from this module.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
caller["Caller root config<br/>provider azapi + OIDC auth"]:::ext
res["terraform-azapi-resource<br/>creates resources"]:::sib
azurerm["azurerm / other team<br/>owns the target"]:::sib
upd["terraform-azapi-update-resource<br/>THIS Β· patch existing"]:::this
target["Existing ARM resource<br/>lifecycle owned elsewhere"]:::keystone
caller --> upd
res -->|"id as resource_id"| upd
azurerm -->|"id as resource_id"| upd
upd -->|"PATCH selected properties"| target
upd -. "destroy = no-op".-> target
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
classDef sib fill:#E6F2FB,color:#073B6B,stroke:#0078D4,stroke-width:1px;
classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
βΉοΈ Blue = this module. It consumes the
idof a resource created byterraform-azapi-resource,azurerm, or another team, and patches a property slice of it.
flowchart TD
subgraph inputs["Typed inputs (the contract)"]
direction TB
t["type @ api-version<br/>resource_id (target)"]:::ext
b["body (property subset to patch)"]:::ext
s["sensitive_body (write-only)"]:::secret
beh["behavior tail<br/>no schema_validation / null"]:::ext
end
this["azapi_update_resource.this<br/>patch only Β· destroy = no-op"]:::this
ex["Existing ARM resource<br/>owned elsewhere"]:::keystone
out["Outputs<br/>id Β· resource_id Β· output"]:::keystone
t --> this
b --> this
s -. "merge-patched, never in state".-> this
beh --> this
this -->|"PATCH listed properties only"| ex
this --> out
classDef this fill:#0078D4,color:#fff,stroke:#004578,stroke-width:3px;
classDef keystone fill:#004578,color:#fff,stroke:#0078D4,stroke-width:2px;
classDef secret fill:#FCE8E6,color:#8B1A10,stroke:#D13438,stroke-width:1px;
classDef ext fill:#f2f2f2,color:#333333,stroke:#999999,stroke-width:1px;
Resource inventory (1 azapi_* block):
azapi_update_resource.thisβ targets the existing resource byresource_idand PATCHes only the properties present inbody; routes secret properties through write-onlysensitive_body.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azapi provider | ~> 2.10 (validated against v2.10.0) |
| Provider config | None inside the module β the caller configures provider "azapi" + auth. |
Schema notes that bite (verified against the azapi v2.10.0 binary schema):
- π§¨
destroyperforms no operation. The patched properties remain on the resource; the module does not revert them. Removing it from config simply stops managing those properties. - π―
name/parent_idare mutually exclusive withresource_idβ and an explicitname = nullstill counts as "set." This module targets byresource_idonly; compose the ID from pieces withprovider::azapi::build_resource_id(parent_id, type, name). - β No
schema_validation_enabled/ignore_null_property/tags/location/identityon this primitive β they do not exist in the schema. - π§± Changing
typeorresource_idre-targets (force-new) the update resource.
Type-dependent (the target type is caller-supplied). The apply identity needs, on the target resource:
Microsoft.<RP>/<resource>/readand/writeβ to read current state and PATCH it. No create or delete is performed by this module.- RP registration:
Microsoft.<RP>already registered (the target exists, so normally it is). - Least-privilege: a custom role limited to read + write on the target type (or a service-specific Contributor), scoped to the target resource β never subscription Owner.
β οΈ Route role design to Cloud Security / IAM. This module assigns no roles.
- The target resource already exists (this module does not create it).
- The chosen
typeapi-version is valid for the target in its region. - Terraform
>= 1.12; azapi~> 2.10; caller setsenable_preflight = true(recommended). - Auth configured by the caller β OIDC / workload-identity federation preferred.
terraform-azapi-update-resource/
βββ providers.tf # required_providers (Azure/azapi ~> 2.10) + required_version >= 1.12.0; no provider block
βββ variables.tf # type, resource_id (target), body (patch subset), sensitive_body, behavior tail
βββ main.tf # one keystone azapi_update_resource.this; total renderer; dynamic timeouts
βββ outputs.tf # id (primary), resource_id, output (opt-in)
βββ examples/
β βββ basic/ # enforce TLS 1.2 on an existing storage account
βββ README.md # you are here
βββ SCOPE.md # lightweight cross-module contract
module "enforce_tls" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-casey/providers/Microsoft.Storage/storageAccounts/stcaseyexample001"
# Patch ONLY this property; everything else on the account is left untouched.
body = {
properties = { minimumTlsVersion = "TLS1_2" }
}
}
provider "azapi" {
enable_preflight = true
}
β οΈ Pin the module by tag (?ref=v1.0.0), never a branch.
| Input | Type | Source |
|---|---|---|
resource_id |
string (ARM ID) |
the module/team that OWNS the target (terraform-azapi-resource, an azurerm resource) |
| Output | Description | Consumed by |
|---|---|---|
id |
ARM resource ID of the patched resource | downstream references |
resource_id |
The resolved target ARM ID | auditing / chaining |
output |
Opt-in read-back of behavior.response_export_values ({} by default) |
downstream config |
1 Β· Minimal β enforce TLS 1.2 on an existing storage account
module "enforce_tls" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = "/subscriptions/.../resourceGroups/rg-casey/providers/Microsoft.Storage/storageAccounts/stcasey001"
body = { properties = { minimumTlsVersion = "TLS1_2" } }
}2 Β· Lock network access to default-deny
module "net_deny" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
body = {
properties = {
publicNetworkAccess = "Disabled"
networkAcls = { defaultAction = "Deny", bypass = "AzureServices" }
}
}
}π Only
publicNetworkAccessandnetworkAclsare managed here β the account's other properties stay owned by whoever created it.
3 Β· Disable shared-key auth (Entra-first)
module "entra_only" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
body = { properties = { allowSharedKeyAccess = false } }
}4 Β· Patch tags on an existing resource
module "retag" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
# ARM tags are a top-level property; patch them through body.
body = {
tags = {
data_classification = "internal"
cost_center = "CC-1042"
}
}
}βΉοΈ
azapi_update_resourcehas no dedicatedtagsargument β setbody.tags(the listed keys are merged; unlisted tags are left untouched).
5 Β· Rotate a secret property via sensitive_body
module "rotate_admin_password" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Sql/servers@2023-08-01-preview"
resource_id = module.sql_server.id
sensitive_body = {
properties = { administratorLoginPassword = var.new_sql_password } # write-only; mark var sensitive
}
# Bump the version to force the rotated value to re-send.
sensitive_body_version = { "properties.administratorLoginPassword" = "v2" }
}π The new password is merge-patched at apply and never stored in state.
6 Β· Target by composed ID (build_resource_id)
module "patch_by_pieces" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = provider::azapi::build_resource_id(module.resource_group.id,
"Microsoft.Storage/storageAccounts",
"stcasey001", )
body = { properties = { minimumTlsVersion = "TLS1_2" } }
}π‘ The provider function composes a full ARM ID from
parent_id+ type + name β the clean way to target without hardcoding the whole ID string.
7 Β· Read back a patched value via response_export_values
module "patch_and_read" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
body = { properties = { minimumTlsVersion = "TLS1_2" } }
behavior = {
response_export_values = { tls = "properties.minimumTlsVersion" }
}
}
output "effective_tls" {
value = module.patch_and_read.output.tls
}
β οΈ Never export a secret path βoutputlands in state.
8 Β· Re-apply the patch on external change (replace_triggers_external_values)
module "patch_versioned" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Network/publicIPAddresses@2024-05-01"
resource_id = module.public_ip.id
body = { properties = { idleTimeoutInMinutes = var.idle_timeout } }
# Force a fresh patch when the policy version changes.
replace_triggers_external_values = [var.idle_timeout, var.policy_version]
}9 Β· Transient-error retry policy
module "patch_with_retry" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
body = { properties = { minimumTlsVersion = "TLS1_2" } }
behavior = {
retry = {
error_message_regex = ["TooManyRequests", "Conflict"]
interval_seconds = 15
max_interval_seconds = 120
}
}
}10 Β· Tune drift handling (ignore_casing / ignore_missing_property)
module "patch_strict" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id
body = { properties = { minimumTlsVersion = "TLS1_2" } }
behavior = {
ignore_casing = false # surface casing drift (default)
ignore_missing_property = true # un-echoed props (e.g. rotated secrets) don't show as drift (default)
}
}11 Β· Patch a child resource (subnet property)
module "subnet_pe_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Network/virtualNetworks/subnets@2024-05-01"
resource_id = "${module.vnet.id}/subnets/snet-apps"
body = {
properties = { privateEndpointNetworkPolicies = "Disabled" }
}
}12 Β· for_each at scale β patch many resources
locals {
accounts = {
app = module.storage_app.id
logs = module.storage_logs.id
data = module.storage_data.id
}
}
module "enforce_tls" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
for_each = local.accounts
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = each.value
body = { properties = { minimumTlsVersion = "TLS1_2", allowSharedKeyAccess = false } }
}π‘ One patch policy, applied across many resources β the
for_eachlives on the caller's module call.
13 Β· ποΈ End-to-end composition (mandatory) β create then patch
# 1) Create a storage account (owned by terraform-azapi-resource)
module "storage" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
name = "stcaseyorders001"
parent_id = module.resource_group.id
location = "eastus2"
body = { sku = { name = "Standard_LRS" }, kind = "StorageV2" }
}
# 2) A governance team enforces a property on it WITHOUT owning its lifecycle (this module)
module "enforce_tls" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-update-resource?ref=v1.0.0"
type = "Microsoft.Storage/storageAccounts@2025-06-01"
resource_id = module.storage.id # β wired from output
body = { properties = { minimumTlsVersion = "TLS1_2", allowSharedKeyAccess = false } }
}ποΈ The creating module and the patching module can live in different states / teams β they meet only at
resource_id. Ondestroyof the patch module, the enforced properties simply stay put.
Core β type (required), resource_id (required).
Body β body (the property subset to patch), sensitive_body, sensitive_body_version.
Behavior tail β behavior (casing/missing/export/locks/list-controls/retry β no schema_validation / null), timeouts.
Lifecycle / advanced β replace_triggers_external_values, request_options (read + update only).
Full object schemas
type = string # "<Namespace>/<resource>@<api-version>"; validated ^.+/.+@.+$
resource_id = string # full ARM ID, must start with "/"; force-new
body = optional(any, {}) # the property SUBSET to patch; native HCL; NO secrets here
sensitive_body = optional(any) # sensitive = true; write-only; secrets only
sensitive_body_version = optional(map(string), {})
behavior = optional(object({
ignore_casing = optional(bool, false)
ignore_missing_property = optional(bool, true)
response_export_values = optional(any, []) # export NOTHING by default
locks = optional(list(string), [])
ignore_other_items_in_list = optional(list(string), [])
list_unique_id_property = optional(map(string), {})
retry = optional(object({
error_message_regex = list(string)
interval_seconds = optional(number, 10) # 1..120
max_interval_seconds = optional(number, 180) # 1..300
}))
}), {})
timeouts = optional(object({ create, read, update, delete = optional(string) })) # default null
replace_triggers_external_values = optional(any) # default null
request_options = optional(object({ # read + update only (no create/delete of the target)
read_headers/update_headers = optional(map(string), {})
read_query_parameters/update_query_parameters = optional(map(list(string)), {})
}), {})βΉοΈ
bodyis typedanybecause this primitive patches arbitrary types (the documented exception).
| Output | Description | Sensitive |
|---|---|---|
id |
Primary. ARM resource ID of the patched resource. | no |
resource_id |
The resolved target ARM ID. | no |
output |
Opt-in read-back of behavior.response_export_values ({} by default). |
no β never export secret paths |
Patch semantics β own the properties, not the resource. azapi_update_resource manages exactly the
keys present in body and nothing else. This is the right tool when a resource's lifecycle is owned
elsewhere (another module, another team, click-ops) but you must enforce or set a few properties. On
destroy it performs no operation β the last-applied values stay on the resource, so removing the module
never deletes or reverts the target. If you need to revert a property, patch it back explicitly.
Single targeting mode, by design. The provider treats name/parent_id as mutually exclusive with
resource_id, and the framework counts an explicit name = null as "set" β so a module that wrote all
three would fail validation. This module exposes the single unambiguous mode (resource_id). To target
from pieces, compose the ID with provider::azapi::build_resource_id(parent_id, type, name) (Example 6).
Secrets and drift. Secret properties go in write-only sensitive_body; ignore_missing_property
defaults true so the API not echoing the secret back does not register as drift. Bump
sensitive_body_version to force a rotated value to re-send.
What this primitive lacks. No schema_validation_enabled (the embedded validator is not available on
update), no ignore_null_property, no tags/location/identity. Patch tags through body.tags; the
typed variables.tf and the service's own validation are the body guards.
| Concern | Secure default | Opt-out |
|---|---|---|
| Secrets | routed via sensitive_body (write-only, never in state) |
β (placing secrets in body is forbidden) |
| Missing-property drift | behavior.ignore_missing_property = true |
set false |
| Output exposure | behavior.response_export_values = [] (export nothing) |
pass explicit paths / a JMESPath map |
| Casing drift | behavior.ignore_casing = false |
set true |
| Concurrency | behavior.locks = [] available |
populate with ARM IDs |
| Targeting | by resource_id only (unambiguous) |
compose the ID via provider::azapi::build_resource_id |
| Body shape | native HCL body; only listed props managed |
β |
Authoring is plan-only (regulated-FI posture) β a human applies from a controlled CI context:
cd terraform-azapi-update-resource
terraform init -backend=false
terraform validate
terraform fmt -check -recursive
# NO `terraform apply` here. plan/apply against an environment is a separate, human-reviewed CI step.
rm -rf.terraform.terraform.lock.hcl
β οΈ Consume the module pinned to a tag β?ref=v1.0.0, never a branch.
terraform init -backend=false # azapi ~> 2.10 only
terraform validate # "Success! The configuration is valid."
terraform fmt -check -recursive # zero formatting differencesβΉοΈ Validate runs offline. The patch behavior (and "destroy is a no-op") only exercises against a real existing target at plan/apply β a separate, human-reviewed step.
id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/stcasey001"
resource_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/stcasey001"
output = {} # nothing exported by default| Symptom | Cause | Fix |
|---|---|---|
terraform destroy left the property changed |
Expected β update_resource destroy is a no-op | The last-applied patch remains; revert it explicitly if required |
Only one of name or resource_id can be set |
name/parent_id set somewhere alongside resource_id |
This module targets by resource_id only (they're mutually exclusive, even when null) |
| A secret/PII value landed in state or an output | Exported via behavior.response_export_values |
Set it back to [] |
| Secret shows as drift | Secret placed in body, or ignore_missing_property = false |
Move it to sensitive_body; keep ignore_missing_property = true |
| Re-apply wants to replace | You changed type or resource_id (re-targeting), or a replace_triggers_external_values value |
Expected for re-targeting |
- Terraform azapi provider β
azapi_update_resourceresource reference - azapi provider function β
build_resource_id(compose a target ARM ID from pieces) - Azure Resource Manager template reference β
learn.microsoft.com/azure/templates/<RP>/<resource> - Sibling modules β
terraform-azapi-resource(creates the target),terraform-azapi-resource-action SCOPE.md(this module)
π "Infrastructure as Code should be standardized, consistent, and secure."