IaC Azure

Terraform Module: Azure App Configuration — centralized config and feature flags with a private, customer-managed-key store

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

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 configlive/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 configlive/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

TerraformAzureApp ConfigurationModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading