Quick take — A reusable hashicorp/azurerm ~> 4.0 module for Azure App Configuration: Standard SKU, soft-delete/purge protection, customer-managed-key encryption, private endpoint, and a managed identity wired in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "app_configuration" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-configuration?ref=v1.0.0"
name = "..." # Globally unique store name (5-50 chars, alphanumeric an…
resource_group_name = "..." # Resource group that holds the store.
location = "..." # Azure region for the store and endpoint.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure App Configuration is a managed service that holds your application’s key-value settings and feature flags in one place, separate from code and separate from secrets (those still belong in Key Vault, and App Configuration references them with {"uri":"..."} Key Vault references). Instead of baking connection strings and toggles into every container image or App Service app-settings blob, your apps read a single endpoint and pull configuration at startup — with labels for environments, point-in-time snapshots, and a feature-flag schema that the Microsoft.FeatureManagement libraries understand natively.
Wrapping azurerm_app_configuration in a module matters because the production-grade footprint is more than one resource. A real store needs the Standard SKU (the free SKU caps you at one store per subscription and has no SLA, no soft-delete, no private link, no CMK), it needs local_auth_enabled = false so nobody falls back to access keys, it needs a private endpoint so the data plane never traverses the public internet, and increasingly it needs customer-managed-key encryption backed by a user-assigned identity with Get/WrapKey/UnwrapKey on a Key Vault key. This module bundles that opinionated, secure-by-default shape so every team gets the same hardened store from one module block.
When to use it
- You run more than a couple of microservices or App Service / AKS / Function workloads that share configuration and you want one source of truth with environment labels (
dev,qa,prod) instead of duplicated app settings. - You need feature flags managed centrally (gradual rollout, kill switches) consumed by
Microsoft.FeatureManagementwithout a redeploy. - You have a compliance requirement for customer-managed keys, private networking, and no shared-key auth — App Configuration on the free SKU cannot do any of these.
- You want configuration snapshots and soft-delete / purge protection so an accidental key deletion or a bad rollout is recoverable.
If you only have a single app with a handful of settings, the platform-native app-settings block is cheaper and simpler — App Configuration earns its keep at fleet scale.
Module structure
terraform-module-azure-app-configuration/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# CMK requires an identity that can reach the Key Vault key. We accept a
# user-assigned identity id from the caller; if none is supplied we create one.
create_identity = var.customer_managed_key_id != null && var.identity_id == null
identity_id = var.customer_managed_key_id == null ? var.identity_id : (
local.create_identity ? azurerm_user_assigned_identity.this[0].id : var.identity_id
)
identity_type = local.identity_id != null ? "UserAssigned" : null
}
resource "azurerm_user_assigned_identity" "this" {
count = local.create_identity ? 1 : 0
name = "${var.name}-id"
resource_group_name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_app_configuration" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku = var.sku
# Hard off-switch for access keys: data plane is Entra ID (RBAC) only.
local_auth_enabled = var.local_auth_enabled
# Recoverability. Soft delete retention is only honoured on the standard SKU.
soft_delete_retention_days = var.soft_delete_retention_days
purge_protection_enabled = var.purge_protection_enabled
# When a private endpoint is in play, lock the public data plane down.
public_network_access = var.public_network_access_enabled ? "Enabled" : "Disabled"
dynamic "identity" {
for_each = local.identity_type == null ? [] : [1]
content {
type = local.identity_type
identity_ids = [local.identity_id]
}
}
dynamic "encryption" {
for_each = var.customer_managed_key_id == null ? [] : [1]
content {
key_vault_key_identifier = var.customer_managed_key_id
identity_client_id = local.create_identity ? azurerm_user_assigned_identity.this[0].client_id : var.identity_client_id
}
}
tags = var.tags
}
# Optional: lock the data plane behind a private endpoint.
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint_subnet_id == null ? 0 : 1
name = "${var.name}-pe"
resource_group_name = var.resource_group_name
location = var.location
subnet_id = var.private_endpoint_subnet_id
private_service_connection {
name = "${var.name}-psc"
private_connection_resource_id = azurerm_app_configuration.this.id
subresource_names = ["configurationStores"]
is_manual_connection = false
}
dynamic "private_dns_zone_group" {
for_each = var.private_dns_zone_ids == null ? [] : [1]
content {
name = "default"
private_dns_zone_ids = var.private_dns_zone_ids
}
}
tags = var.tags
}
# Optional: seed initial keys and feature flags so a fresh store is usable.
resource "azurerm_app_configuration_key" "kv" {
for_each = var.keys
configuration_store_id = azurerm_app_configuration.this.id
key = each.value.key
label = each.value.label
type = each.value.vault_key_reference == null ? "kv" : "vault"
value = each.value.vault_key_reference == null ? each.value.value : null
vault_key_reference = each.value.vault_key_reference
content_type = each.value.content_type
# local_auth is disabled, so Terraform's SP needs the
# "App Configuration Data Owner" role for these data-plane writes.
depends_on = [azurerm_app_configuration.this]
}
resource "azurerm_app_configuration_feature" "flags" {
for_each = var.feature_flags
configuration_store_id = azurerm_app_configuration.this.id
name = each.value.name
label = each.value.label
description = each.value.description
enabled = each.value.enabled
depends_on = [azurerm_app_configuration.this]
}
variables.tf
variable "name" {
type = string
description = "Globally unique name of the App Configuration store (5-50 chars, alphanumeric and hyphens)."
validation {
condition = can(regex("^[a-zA-Z0-9-]{5,50}$", var.name))
error_message = "name must be 5-50 characters and contain only letters, numbers, and hyphens."
}
}
variable "resource_group_name" {
type = string
description = "Name of the resource group that will hold the store."
}
variable "location" {
type = string
description = "Azure region for the store and its endpoint (e.g. centralindia)."
}
variable "sku" {
type = string
default = "standard"
description = "App Configuration SKU. Use 'standard' for SLA, soft-delete, private link, and CMK; 'free' is single-store and unsupported in production."
validation {
condition = contains(["free", "standard"], var.sku)
error_message = "sku must be either 'free' or 'standard'."
}
}
variable "local_auth_enabled" {
type = bool
default = false
description = "When false, disables access-key (connection string) auth so the data plane is Entra ID / RBAC only. Recommended."
}
variable "public_network_access_enabled" {
type = bool
default = false
description = "When false, the public data-plane endpoint is disabled (use with a private endpoint)."
}
variable "soft_delete_retention_days" {
type = number
default = 7
description = "Days a deleted store is recoverable (standard SKU only). Allowed range is 1-7."
validation {
condition = var.soft_delete_retention_days >= 1 && var.soft_delete_retention_days <= 7
error_message = "soft_delete_retention_days must be between 1 and 7."
}
}
variable "purge_protection_enabled" {
type = bool
default = true
description = "When true, a soft-deleted store cannot be permanently purged before retention expires."
}
variable "private_endpoint_subnet_id" {
type = string
default = null
description = "Subnet resource id for the private endpoint. When null, no private endpoint is created."
}
variable "private_dns_zone_ids" {
type = list(string)
default = null
description = "Private DNS zone ids (privatelink.azconfig.io) to associate with the private endpoint."
}
variable "customer_managed_key_id" {
type = string
default = null
description = "Versioned or versionless Key Vault key identifier for CMK encryption. When null, Microsoft-managed keys are used."
}
variable "identity_id" {
type = string
default = null
description = "Existing user-assigned identity id to attach. If null and a CMK is set, the module creates one."
}
variable "identity_client_id" {
type = string
default = null
description = "Client id of the supplied identity_id, required for CMK when you bring your own identity."
}
variable "keys" {
type = map(object({
key = string
label = optional(string)
value = optional(string)
content_type = optional(string)
vault_key_reference = optional(string)
}))
default = {}
description = "Initial key-values to seed. Set vault_key_reference to a Key Vault secret id to create a Key Vault reference instead of a plain value."
}
variable "feature_flags" {
type = map(object({
name = string
label = optional(string)
description = optional(string, "")
enabled = optional(bool, false)
}))
default = {}
description = "Initial feature flags to seed for Microsoft.FeatureManagement consumers."
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to all resources created by the module."
}
outputs.tf
output "id" {
description = "Resource id of the App Configuration store."
value = azurerm_app_configuration.this.id
}
output "name" {
description = "Name of the App Configuration store."
value = azurerm_app_configuration.this.name
}
output "endpoint" {
description = "Data-plane endpoint (e.g. https://<name>.azconfig.io) for SDK/RBAC access."
value = azurerm_app_configuration.this.endpoint
}
output "primary_read_key_connection_string" {
description = "Primary read-only connection string. Empty when local_auth is disabled; prefer RBAC."
value = try(azurerm_app_configuration.this.primary_read_key[0].connection_string, null)
sensitive = true
}
output "identity_principal_id" {
description = "Principal id of the user-assigned identity created by the module (null when not created)."
value = local.create_identity ? azurerm_user_assigned_identity.this[0].principal_id : null
}
output "private_endpoint_ip" {
description = "Private IP allocated to the store's private endpoint (null when no PE is created)."
value = try(azurerm_private_endpoint.this[0].private_service_connection[0].private_ip_address, null)
}
How to use it
module "app_configuration" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-configuration?ref=v1.0.0"
name = "kv-appcfg-prod-cin"
resource_group_name = azurerm_resource_group.platform.name
location = "centralindia"
sku = "standard"
# Secure-by-default data plane.
local_auth_enabled = false
public_network_access_enabled = false
# Private link into the platform spoke.
private_endpoint_subnet_id = azurerm_subnet.private_endpoints.id
private_dns_zone_ids = [azurerm_private_dns_zone.azconfig.id]
# Customer-managed key; module spins up the identity and you grant it on the key.
customer_managed_key_id = azurerm_key_vault_key.appcfg_cmk.versionless_id
# Seed a couple of settings and a feature flag.
keys = {
api_base = {
key = "Api:BaseUrl"
label = "prod"
value = "https://api.kloudvin.com"
}
db_conn = {
key = "ConnectionStrings:Sql"
label = "prod"
vault_key_reference = azurerm_key_vault_secret.sql_conn.id
}
}
feature_flags = {
new_checkout = {
name = "NewCheckout"
label = "prod"
description = "Gradual rollout of the redesigned checkout flow."
enabled = false
}
}
tags = {
env = "prod"
costcenter = "platform"
}
}
# The module created the CMK identity — grant it on the Key Vault key (RBAC).
resource "azurerm_role_assignment" "appcfg_cmk" {
scope = azurerm_key_vault.platform.id
role_definition_name = "Key Vault Crypto User"
principal_id = module.app_configuration.identity_principal_id
}
# Downstream: hand the endpoint to an App Service so it reads config via RBAC.
resource "azurerm_linux_web_app" "api" {
name = "kv-api-prod-cin"
resource_group_name = azurerm_resource_group.platform.name
location = "centralindia"
service_plan_id = azurerm_service_plan.api.id
site_config {}
app_settings = {
"AZURE_APPCONFIG_ENDPOINT" = module.app_configuration.endpoint
}
identity {
type = "SystemAssigned"
}
}
# Let the web app's identity read configuration (data-plane RBAC, no keys).
resource "azurerm_role_assignment" "api_reader" {
scope = module.app_configuration.id
role_definition_name = "App Configuration Data Reader"
principal_id = azurerm_linux_web_app.api.identity[0].principal_id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/app_configuration/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-app-configuration?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/app_configuration && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Globally unique store name (5-50 chars, alphanumeric and hyphens). |
resource_group_name |
string |
— | Yes | Resource group that holds the store. |
location |
string |
— | Yes | Azure region for the store and endpoint. |
sku |
string |
"standard" |
No | free or standard; production needs standard. |
local_auth_enabled |
bool |
false |
No | Disable access-key auth so the data plane is RBAC-only. |
public_network_access_enabled |
bool |
false |
No | Toggle the public data-plane endpoint. |
soft_delete_retention_days |
number |
7 |
No | Recovery window for a deleted store (1-7, standard SKU). |
purge_protection_enabled |
bool |
true |
No | Block permanent purge before retention expires. |
private_endpoint_subnet_id |
string |
null |
No | Subnet id for the private endpoint; null skips it. |
private_dns_zone_ids |
list(string) |
null |
No | privatelink.azconfig.io zone ids for the PE. |
customer_managed_key_id |
string |
null |
No | Key Vault key id for CMK; null uses Microsoft-managed keys. |
identity_id |
string |
null |
No | Existing user-assigned identity id; module creates one if null and CMK is set. |
identity_client_id |
string |
null |
No | Client id for a bring-your-own identity used with CMK. |
keys |
map(object) |
{} |
No | Initial key-values; set vault_key_reference for Key Vault references. |
feature_flags |
map(object) |
{} |
No | Initial feature flags for Microsoft.FeatureManagement. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource id of the App Configuration store. |
name |
Name of the store. |
endpoint |
Data-plane endpoint (https://<name>.azconfig.io) for SDK/RBAC access. |
primary_read_key_connection_string |
Primary read-only connection string (null/empty when local auth is disabled). |
identity_principal_id |
Principal id of the module-created user-assigned identity, for granting on the CMK. |
private_endpoint_ip |
Private IP of the store’s private endpoint, when one is created. |
Enterprise scenario
A retail platform team runs forty .NET microservices across three AKS clusters (dev, qa, prod) and needs one governed source of configuration. They deploy this module once per environment with local_auth_enabled = false, a private endpoint into each spoke, and CMK backed by the regional Key Vault. Application settings are partitioned by label so the same key (ConnectionStrings:Sql) resolves differently per environment, database connection strings are stored as Key Vault references rather than plain values, and the NewCheckout feature flag lets product managers dial the redesigned checkout from 0 to 100 percent of traffic without a single redeploy.
Best practices
- Kill access keys. Keep
local_auth_enabled = falseand grantApp Configuration Data Reader(workloads) orApp Configuration Data Owner(CI/CD that seeds keys) via managed identities. Connection strings are a credential-leak waiting to happen — the leaked-secrets lesson applies here too. - Put the data plane on private link. Set
public_network_access_enabled = false, attach the private endpoint, and wire theprivatelink.azconfig.ioDNS zone; otherwise the store is reachable from the public internet even with RBAC. - Store secrets in Key Vault, reference them here. Use the
vaultkey type (vault_key_reference) so App Configuration holds a pointer, never the secret value, and rotation stays a Key Vault concern. - Use labels and snapshots, not separate stores per env where avoidable. Labels (
dev/qa/prod) keep keys consistent and reduce drift; take a configuration snapshot before a risky rollout so you can roll config back independently of code. - Right-size for cost. Standard SKU bills a flat daily charge plus a generous free request allotment per store, so consolidate teams onto a shared store per environment rather than one store per service; reserve
freestrictly for throwaway sandboxes. - Name and tag deterministically. Follow
kv-appcfg-<env>-<region>(the name is globally unique and shows in the.azconfig.iohost), keeppurge_protection_enabled = true, and tagenv/costcenterso the store shows up correctly in cost and policy reports.