Manage a data-plane object — an App Configuration key-value, a Key Vault secret, an AI Foundry agent — on a parent resource owned elsewhere. Reaches the service's data plane directly (where ARM and
azurermcannot), with write-only secret values and export-nothing defaults. Wraps theazapi_data_plane_resourceprimitive. Built for the Azureazapiprovider v2.x (~> 2.10).
This module wraps the azapi_data_plane_resource primitive — for objects that live in a service's
data plane, not in ARM:
- 🗄️ Data-plane objects — App Configuration key-values & feature flags, Key Vault secrets, AI Foundry
agents, and similar — under a required
parent_id. - 🔐 Write-only secret values — secret data-plane values go in
sensitive_body, merge-patched at apply and never persisted to state. - 🚫 Export nothing by default —
response_export_values = []. - 🪪 Data-plane RBAC — access is governed by data-plane roles (e.g. App Configuration Data Owner), not only control-plane RBAC — prefer Entra ID over keys.
💡 Why it matters: not everything in Azure is an ARM resource. Configuration key-values live inside an App Configuration store; secrets live inside a Key Vault's data plane.
azurermoften cannot manage them. This module reaches the data plane directly — and because those objects frequently hold config secrets, it routes secret values through write-onlysensitive_body.
⚠️ This primitive has notags/location/identity/schema_validation_enabled/ignore_null_property.parent_idis required. Thetypecarries a data-plane version (often@1.0), not a control-plane ARM api-version.
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 + data-plane auth"]:::ext
azurerm["azurerm / other team<br/>owns the parent"]:::sib
parent["Parent ARM resource<br/>e.g. App Configuration store"]:::keystone
subgraph tier1["Tier-1 azapi primitives (sibling modules)"]
direction TB
res["terraform-azapi-resource<br/>creates the parent"]:::sib
upd["terraform-azapi-update-resource<br/>patch existing"]:::sib
act["terraform-azapi-resource-action<br/>control-plane action"]:::sib
dp["terraform-azapi-data-plane-resource<br/>THIS · data-plane object"]:::this
end
caller --> dp
res -->|"id as parent_id"| dp
azurerm -->|"id as parent_id"| dp
dp -->|"manage object in data plane of"| parent
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 sits alongside the other three tier-1
azapiprimitives (terraform-azapi-resource,terraform-azapi-update-resource,terraform-azapi-resource-action). It consumes theidof a parent resource (created byterraform-azapi-resource,azurerm, or another team) as itsparent_id, and manages an object in that parent's data plane.update-resourceandresource-actionare shown as family members only — this module has no direct data flow with them.
flowchart TD
subgraph inputs["Typed inputs (the contract)"]
direction TB
t["type @ data-plane version<br/>parent_id (required) · name"]:::ext
b["body (data-plane object)"]:::ext
s["sensitive_body (write-only)"]:::secret
beh["behavior tail<br/>no schema_validation / null"]:::ext
end
this["azapi_data_plane_resource.this<br/>one data-plane object"]:::this
parent["Parent resource data plane<br/>e.g. App Config store"]:::keystone
out["Outputs<br/>id · name · output"]:::keystone
t --> this
b --> this
s -. "merge-patched, never in state".-> this
beh --> this
this -->|"create / update object in"| parent
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_data_plane_resource.this— one data-plane object oftype@versionunder the requiredparent_id; routes secret values 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):
- 🧱
parent_idis required and immutable (force-new);nameis also force-new. - 🔢 The
typeversion is a data-plane version (e.g.@1.0for App Configuration key-values), not a control-plane ARM api-version. - ➖ No
schema_validation_enabled/ignore_null_property/tags/location/identity— they do not exist on this primitive. The typed inputs and the service's own validation are the body guards. - 🔐 Secret values go in write-only
sensitive_body; keepresponse_export_values = []so read-backs do not pull secret data-plane values into state.
This manages a data-plane object, so the apply identity needs the relevant data-plane role on the parent, plus enough control-plane access to resolve it:
- App Configuration key-values → App Configuration Data Owner (data-plane) on the store.
- Key Vault secrets → a Key Vault data-plane secrets role (e.g. Key Vault Secrets Officer).
- AI Foundry / Cognitive Services objects → the service's data-plane data role.
- Control-plane:
Microsoft.<RP>/<resource>/readon the parent, and RP registration forMicrosoft.<RP>.
⚠️ Prefer Entra ID (data-plane RBAC) over access keys, and disable local/key auth on the parent where possible. Route role design through Cloud Security / IAM.
- The parent resource already exists and its data plane is reachable from the runner (network / private endpoint as applicable).
- Data-plane authentication is configured (Entra ID preferred).
- The chosen
type@version(a data-plane version such as1.0) is valid for the parent service. - Terraform
>= 1.12; azapi~> 2.10; caller setsenable_preflight = true(recommended). - Auth configured by the caller — OIDC / workload-identity federation preferred.
terraform-azapi-data-plane-resource/
├── providers.tf # required_providers (Azure/azapi ~> 2.10) + required_version >= 1.12.0; no provider block
├── variables.tf # type, parent_id (required), name, body, sensitive_body, behavior tail
├── main.tf # one keystone azapi_data_plane_resource.this; total renderer; dynamic timeouts
├── outputs.tf # id (primary), name, output (opt-in)
├── examples/
│ └── basic/ # an App Configuration key-value
├── README.md # you are here
└── SCOPE.md # lightweight cross-module contract
module "feature_flag" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-casey/providers/Microsoft.AppConfiguration/configurationStores/appcs-casey"
name = "FeatureFlags:NewUI"
body = {
properties = { value = "enabled", contentType = "text/plain" }
}
}
provider "azapi" {
enable_preflight = true
}
⚠️ For a secret value, pass it viasensitive_bodyinstead ofbody. Pin the module by tag (?ref=v1.0.0), never a branch.
| Input | Type | Source |
|---|---|---|
parent_id |
string (parent ARM ID) |
the module that OWNS the parent (terraform-azapi-resource, an azurerm App Config store / Key Vault) |
| Output | Description | Consumed by |
|---|---|---|
id |
ID of the data-plane resource | downstream references |
name |
Name (identifier segment) of the object | downstream config |
output |
Opt-in read-back of behavior.response_export_values ({} by default) |
downstream config |
1 · Minimal — an App Configuration key-value
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Timeout"
body = { properties = { value = "30" } }
}2 · Key-value with a content type
module "kv_json" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Limits"
body = {
properties = {
value = jsonencode({ maxItems = 100, maxBytes = 1048576 }) # value is a string; jsonencode is fine HERE for the VALUE
contentType = "application/json"
}
}
}ℹ️
jsonencodehere builds the string value of a key — that is not the v1bodyidiom (thebodyitself is still native HCL).
3 · Feature flag (App Configuration)
module "feature_flag" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = ".appconfig.featureflag/NewUI"
body = {
properties = {
contentType = "application/vnd.microsoft.appconfig.ff+json;charset=utf-8"
value = jsonencode({ id = "NewUI", enabled = false })
}
}
}4 · Labeled key-value (per-environment override)
module "kv_prod" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Timeout$prod" # "key$label"
body = { properties = { value = "10" } }
}ℹ️ App Configuration encodes the label in the identifier as
key$label.
5 · Secret value via sensitive_body
module "kv_secret_ref" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:ApiToken"
# Secret value is write-only — never stored in state.
sensitive_body = {
properties = { value = var.api_token } # mark this variable sensitive in the caller
}
}🔒 Secret data-plane values belong in
sensitive_body, never inbody.
6 · Key Vault secret (data-plane)
module "vault_secret" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.KeyVault/vaults/secrets@7.5" # data-plane version
parent_id = module.key_vault.id
name = "db-connection-string"
sensitive_body = {
value = var.db_connection_string # write-only; mark the variable sensitive
}
}🔒 Requires a Key Vault data-plane secrets role (e.g. Key Vault Secrets Officer) on the vault.
7 · Read back a non-secret value via response_export_values
module "kv_read" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Region"
body = { properties = { value = "eastus2" } }
behavior = {
response_export_values = { etag = "properties.eTag" } # non-secret metadata
}
}
output "kv_etag" {
value = module.kv_read.output.etag
}
⚠️ Never export a secret path —outputlands in state.
8 · Force replacement with replace_triggers_external_values
module "kv_versioned" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Schema"
body = { properties = { value = var.schema_payload } }
replace_triggers_external_values = [var.schema_version]
}9 · Transient-error retry policy
module "kv_retry" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Flag"
body = { properties = { value = "on" } }
behavior = {
retry = {
error_message_regex = ["TooManyRequests", "ServiceUnavailable"]
interval_seconds = 10
max_interval_seconds = 120
}
}
}10 · Custom request headers (request_options)
module "kv_headers" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Mode"
body = { properties = { value = "active" } }
request_options = {
create_headers = { "x-ms-client-request-id" = "casey-bootstrap" }
}
}
⚠️ Do not place secrets in headers.
11 · for_each at scale — a whole config namespace
locals {
settings = {
"Service:Region" = "eastus2"
"Service:Timeout" = "30"
"Service:MaxRetries" = "3"
"Service:LogLevel" = "info"
}
}
module "config" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
for_each = local.settings
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = each.key
body = { properties = { value = each.value } }
}💡 The
for_eachlives on the caller's module call; each instance is one key-value, keyed by its name.
12 · Drift-handling tuning (ignore_casing / ignore_missing_property)
module "kv_strict" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:Mode"
body = { properties = { value = "active" } }
behavior = {
ignore_casing = false # surface casing drift (default)
ignore_missing_property = true # un-echoed (e.g. secret) props don't show as drift (default)
}
}13 · 🏗️ End-to-end composition (mandatory) — create the store, then seed it
# 1) Create the App Configuration store (control plane, via terraform-azapi-resource)
module "app_config" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores@2024-05-01"
name = "appcs-casey-orders"
parent_id = module.resource_group.id
location = "eastus2"
body = {
sku = { name = "standard" }
properties = {
disableLocalAuth = true # Entra-only data-plane access
publicNetworkAccess = "Disabled"
}
}
}
# 2) Seed its data plane with key-values (this module, parent_id wired from output)
module "config" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
for_each = { "Service:Region" = "eastus2", "Service:Timeout" = "30" }
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id # ← wired from output
name = each.key
body = { properties = { value = each.value } }
}
# 3) A secret value goes through the write-only channel
module "api_token" {
source = "git::https://github.com/microsoftexpert/terraform-azapi-data-plane-resource?ref=v1.0.0"
type = "Microsoft.AppConfiguration/configurationStores/keyValues@1.0"
parent_id = module.app_config.id
name = "Service:ApiToken"
sensitive_body = { properties = { value = var.api_token } }
}🏗️ Control plane creates the store with
disableLocalAuth = true; the data-plane module then seeds it over Entra ID — no keys, secrets write-only.
Core — type (required), parent_id (required), name.
Body — body (the data-plane object), sensitive_body, sensitive_body_version.
Behavior tail — behavior (casing/missing/export/locks/retry — no schema_validation / null), timeouts.
Lifecycle / advanced — replace_triggers_external_values, replace_triggers_refs, request_options.
Full object schemas
type = string # "<Namespace>/<resource>@<data-plane version>"; validated ^.+/.+@.+$; resource-type portion force-new
parent_id = string # the parent resource whose data plane this object lives in; REQUIRED; force-new
name = optional(string) # identifier segment; force-new
body = optional(any, {}) # native HCL data-plane object; NO secrets here
sensitive_body = optional(any) # sensitive = true; write-only; secret values 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), [])
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
replace_triggers_refs = optional(list(string), [])
request_options = optional(object({ # per-operation headers/query params
create_headers/read_headers/update_headers/delete_headers = optional(map(string), {})
create_query_parameters/.../delete_query_parameters = optional(map(list(string)), {})
}), {})ℹ️
bodyis typedanybecause this primitive manages arbitrary data-plane types (the documented exception).
| Output | Description | Sensitive |
|---|---|---|
id |
Primary. ID of the data-plane resource. | no |
name |
Name (identifier segment). | no |
output |
Opt-in read-back of behavior.response_export_values ({} by default). |
no — never export secret paths |
Control plane vs data plane. ARM (and azurerm) manage the resource — the App Configuration store,
the Key Vault. This module manages objects inside that resource's data plane — the key-values, the
secrets. parent_id is the bridge: the ARM ID of the parent whose data plane you are writing into. The
parent must already exist and its data plane must be reachable from the runner (mind private endpoints).
Data-plane auth is distinct from control-plane RBAC. A 403 here usually means the runner lacks the
data-plane role (e.g. App Configuration Data Owner), even if it has plenty of control-plane rights.
Prefer Entra ID data-plane RBAC over access keys, and set disableLocalAuth = true on the parent (see
the end-to-end example) so key auth is off entirely.
Secrets and the data-plane version. Secret values (a Key Vault secret's value, a secret config
setting) go in write-only sensitive_body; keep response_export_values = [] so read-backs do not pull
them into state. Note the type version is a data-plane version (e.g. @1.0, @7.5), not an ARM
control-plane api-version — a frequent source of confusion.
What this primitive lacks. No schema_validation_enabled (no embedded validator on the data plane),
no ignore_null_property, no tags/location/identity. The typed variables.tf and the service's own
validation are the body guards. main.tf is a total renderer — dynamic timeouts, null-guarded retry,
direct passthrough for the behavior controls and replace_triggers_*.
| 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 non-secret paths |
| Casing drift | behavior.ignore_casing = false |
set true |
| Concurrency | behavior.locks = [] available |
populate with ARM IDs |
| Data-plane auth | Entra ID data-plane RBAC (caller-configured) | access keys (discouraged) |
| Body shape | native HCL body (never jsonencode the body) |
— |
Authoring is plan-only (regulated-FI posture) — a human applies from a controlled CI context:
cd terraform-azapi-data-plane-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. Data-plane reachability and auth only exercise against a live parent at plan/apply — a separate, human-reviewed step.
id = "<parent data-plane object ref>"
name = "Service:Region"
output = {} # nothing exported by default| Symptom | Cause | Fix |
|---|---|---|
403 / auth error at apply |
Runner lacks the data-plane role on the parent, or the data plane is unreachable | Grant the data-plane role (e.g. App Configuration Data Owner); check network / private endpoint |
| A secret value landed in state | Secret placed in body |
Pass it via sensitive_body; keep behavior.response_export_values = [] |
Wrong version in type |
Used a control-plane ARM api-version | Use the service's data-plane version (e.g. @1.0, @7.5) |
| Re-apply wants to replace | You changed parent_id or name (both force-new), or a replace_triggers_* value |
Expected for those changes |
- Terraform azapi provider —
azapi_data_plane_resourceresource reference - Azure App Configuration — key-value / feature-flag data-plane reference (Microsoft Learn)
- Azure Key Vault — secret data-plane reference (Microsoft Learn)
- Sibling modules —
terraform-azapi-resource(creates the parent),terraform-azapi-resource-action SCOPE.md(this module)
💙 "Infrastructure as Code should be standardized, consistent, and secure."