Quick take — A production-ready Terraform module for Azure Key Vault on azurerm ~> 4.0: RBAC authorization, soft-delete and purge protection, network ACLs, private-endpoint readiness, and diagnostic logging — all var-driven. 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 "key_vault" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-key-vault?ref=v1.0.0"
name = "..." # Globally unique vault name (3-24 chars, starts with a l…
location = "..." # Azure region, e.g. `centralindia`.
resource_group_name = "..." # Resource group to create the vault in.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Key Vault is the managed secret store for the Microsoft Cloud: it holds secrets (connection strings, API tokens), keys (RSA/EC keys for envelope encryption, customer-managed keys), and certificates (TLS certs with auto-rotation). It is a regional, HSM-backed service that fronts everything behind Azure AD identity, so the access control story is “who is this principal and what are they allowed to do” rather than a network firewall alone.
The trouble is that a correct Key Vault has a dozen footguns. Get the data-plane authorization model wrong and you are stuck juggling brittle access policies forever. Forget purge_protection_enabled and a terraform destroy (or a fat-fingered portal click) can permanently erase keys that are encrypting your storage accounts and managed disks — there is no undo. Leave the default network ACL at Allow and the vault is reachable from the public internet. Skip diagnostic settings and you have no audit trail of who read which secret.
Wrapping Key Vault in a reusable module lets you encode those decisions once: RBAC authorization by default, soft-delete retention and purge protection on, a deny-by-default network ACL, optional private-endpoint wiring, and diagnostic logs shipped to Log Analytics. Every team that calls the module gets a compliant vault without re-litigating the same hardening checklist in every repo.
When to use it
- You provision more than one or two vaults (per app, per environment, per landing zone) and want them identical and compliant.
- You are standardizing on RBAC for the data plane and want to stop hand-writing
azurerm_key_vault_access_policyblocks. - You need customer-managed keys (CMK) for Storage, disks, or SQL TDE and therefore must have purge protection — Azure refuses CMK against a vault without it.
- You have a regulatory requirement (PCI, ISO 27001, SOC 2) for audit logging and network isolation on secret stores.
- You do not need this module for a one-off throwaway vault in a sandbox where the hardening just gets in your way — though even then, the defaults are sane.
Module structure
terraform-module-azure-key-vault/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_key_vault + RBAC + diagnostics + private endpoint
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name/uri + identity-friendly attributes
# versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# main.tf
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "this" {
name = var.name
location = var.location
resource_group_name = var.resource_group_name
tenant_id = coalesce(var.tenant_id, data.azurerm_client_config.current.tenant_id)
sku_name = var.sku_name
# Data-plane authorization: RBAC is the modern, auditable model.
rbac_authorization_enabled = var.enable_rbac_authorization
# Safety rails — these are the ones that hurt when forgotten.
soft_delete_retention_days = var.soft_delete_retention_days
purge_protection_enabled = var.purge_protection_enabled
# Plane-level toggles for ARM/template deployment integration.
enabled_for_deployment = var.enabled_for_deployment
enabled_for_disk_encryption = var.enabled_for_disk_encryption
enabled_for_template_deployment = var.enabled_for_template_deployment
public_network_access_enabled = var.public_network_access_enabled
network_acls {
bypass = var.network_acls.bypass
default_action = var.network_acls.default_action
ip_rules = var.network_acls.ip_rules
virtual_network_subnet_ids = var.network_acls.virtual_network_subnet_ids
}
tags = var.tags
}
# Optionally grant the supplied principals data-plane roles when using RBAC.
resource "azurerm_role_assignment" "this" {
for_each = var.enable_rbac_authorization ? var.role_assignments : {}
scope = azurerm_key_vault.this.id
role_definition_name = each.value.role_definition_name
principal_id = each.value.principal_id
}
# Ship audit + AzurePolicyEvaluationDetails logs to Log Analytics.
resource "azurerm_monitor_diagnostic_setting" "this" {
count = var.log_analytics_workspace_id != null ? 1 : 0
name = "${var.name}-diag"
target_resource_id = azurerm_key_vault.this.id
log_analytics_workspace_id = var.log_analytics_workspace_id
enabled_log {
category = "AuditEvent"
}
enabled_log {
category = "AzurePolicyEvaluationDetails"
}
metric {
category = "AllMetrics"
}
}
# Optional private endpoint for full network isolation.
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint != null ? 1 : 0
name = "${var.name}-pe"
location = var.location
resource_group_name = var.resource_group_name
subnet_id = var.private_endpoint.subnet_id
private_service_connection {
name = "${var.name}-psc"
private_connection_resource_id = azurerm_key_vault.this.id
subresource_names = ["vault"]
is_manual_connection = false
}
dynamic "private_dns_zone_group" {
for_each = var.private_endpoint.private_dns_zone_ids != null ? [1] : []
content {
name = "default"
private_dns_zone_ids = var.private_endpoint.private_dns_zone_ids
}
}
tags = var.tags
}
# variables.tf
variable "name" {
description = "Globally unique Key Vault name (3-24 chars, alphanumeric and hyphens, must start with a letter)."
type = string
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$", var.name))
error_message = "Key Vault name must be 3-24 chars, start with a letter, contain only letters/digits/hyphens, and not end with a hyphen."
}
}
variable "location" {
description = "Azure region for the vault, e.g. 'centralindia'."
type = string
}
variable "resource_group_name" {
description = "Name of the resource group to create the vault in."
type = string
}
variable "tenant_id" {
description = "Azure AD tenant ID. Defaults to the provider's current tenant when null."
type = string
default = null
}
variable "sku_name" {
description = "Vault SKU: 'standard' (software-protected keys) or 'premium' (HSM-backed keys)."
type = string
default = "standard"
validation {
condition = contains(["standard", "premium"], var.sku_name)
error_message = "sku_name must be either 'standard' or 'premium'."
}
}
variable "enable_rbac_authorization" {
description = "Use Azure RBAC for the data plane instead of legacy access policies. Strongly recommended."
type = bool
default = true
}
variable "soft_delete_retention_days" {
description = "Days to retain soft-deleted vault objects before permanent purge (7-90)."
type = number
default = 90
validation {
condition = var.soft_delete_retention_days >= 7 && var.soft_delete_retention_days <= 90
error_message = "soft_delete_retention_days must be between 7 and 90."
}
}
variable "purge_protection_enabled" {
description = "Block permanent deletion until the retention window elapses. Required for CMK scenarios. Cannot be disabled once enabled."
type = bool
default = true
}
variable "enabled_for_deployment" {
description = "Allow Azure VMs to retrieve certificates stored as secrets."
type = bool
default = false
}
variable "enabled_for_disk_encryption" {
description = "Allow Azure Disk Encryption to retrieve secrets and unwrap keys."
type = bool
default = false
}
variable "enabled_for_template_deployment" {
description = "Allow Azure Resource Manager to retrieve secrets during template deployment."
type = bool
default = false
}
variable "public_network_access_enabled" {
description = "Whether the vault is reachable over the public endpoint. Set false when using private endpoints exclusively."
type = bool
default = true
}
variable "network_acls" {
description = "Network firewall rules. default_action 'Deny' enforces an allow-list."
type = object({
bypass = optional(string, "AzureServices")
default_action = optional(string, "Deny")
ip_rules = optional(list(string), [])
virtual_network_subnet_ids = optional(list(string), [])
})
default = {}
validation {
condition = contains(["Allow", "Deny"], var.network_acls.default_action)
error_message = "network_acls.default_action must be 'Allow' or 'Deny'."
}
validation {
condition = contains(["AzureServices", "None"], var.network_acls.bypass)
error_message = "network_acls.bypass must be 'AzureServices' or 'None'."
}
}
variable "role_assignments" {
description = "Map of RBAC role assignments to create at the vault scope when RBAC is enabled. Keyed by an arbitrary label."
type = map(object({
role_definition_name = string
principal_id = string
}))
default = {}
}
variable "private_endpoint" {
description = "Optional private endpoint configuration. Omit (null) to skip."
type = object({
subnet_id = string
private_dns_zone_ids = optional(list(string))
})
default = null
}
variable "log_analytics_workspace_id" {
description = "Log Analytics workspace resource ID for diagnostic settings. Omit (null) to disable diagnostics."
type = string
default = null
}
variable "tags" {
description = "Tags applied to all created resources."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the Key Vault."
value = azurerm_key_vault.this.id
}
output "name" {
description = "Name of the Key Vault."
value = azurerm_key_vault.this.name
}
output "vault_uri" {
description = "DNS URI used by the data plane (e.g. for secret references and CSI driver)."
value = azurerm_key_vault.this.vault_uri
}
output "tenant_id" {
description = "Tenant ID the vault is bound to."
value = azurerm_key_vault.this.tenant_id
}
output "rbac_authorization_enabled" {
description = "Whether the data plane uses RBAC (true) or access policies (false)."
value = azurerm_key_vault.this.rbac_authorization_enabled
}
output "private_endpoint_ip" {
description = "Private IP assigned to the vault's private endpoint, or null when not deployed."
value = try(azurerm_private_endpoint.this[0].private_service_connection[0].private_ip_address, null)
}
How to use it
data "azurerm_log_analytics_workspace" "platform" {
name = "law-platform-prod"
resource_group_name = "rg-observability-prod"
}
module "key_vault" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-key-vault?ref=v1.0.0"
name = "kv-payments-prod-cin"
location = "centralindia"
resource_group_name = "rg-payments-prod"
sku_name = "premium" # HSM-backed keys for CMK
enable_rbac_authorization = true
purge_protection_enabled = true
soft_delete_retention_days = 90
# Disk encryption needs to unwrap keys from this vault.
enabled_for_disk_encryption = true
public_network_access_enabled = false
network_acls = {
default_action = "Deny"
bypass = "AzureServices"
virtual_network_subnet_ids = [azurerm_subnet.app.id]
}
role_assignments = {
app_secrets_reader = {
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
platform_admin = {
role_definition_name = "Key Vault Administrator"
principal_id = var.platform_team_group_object_id
}
}
private_endpoint = {
subnet_id = azurerm_subnet.private_endpoints.id
private_dns_zone_ids = [azurerm_private_dns_zone.vault.id]
}
log_analytics_workspace_id = data.azurerm_log_analytics_workspace.platform.id
tags = {
environment = "prod"
costcenter = "payments"
managed_by = "terraform"
}
}
# Downstream: store a secret in the vault this module created, then
# reference its URI from an App Service setting.
resource "azurerm_key_vault_secret" "db_connection" {
name = "payments-db-connection"
value = var.payments_db_connection_string
key_vault_id = module.key_vault.id
}
resource "azurerm_linux_web_app" "payments" {
name = "app-payments-prod"
location = "centralindia"
resource_group_name = "rg-payments-prod"
service_plan_id = azurerm_service_plan.payments.id
site_config {}
app_settings = {
# Key Vault reference resolved at runtime via the app's managed identity.
"DB_CONNECTION" = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.db_connection.versionless_id})"
}
identity {
type = "SystemAssigned"
}
}
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/key_vault/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-key-vault?ref=v1.0.0"
}
inputs = {
name = "..."
location = "..."
resource_group_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/key_vault && 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 vault name (3-24 chars, starts with a letter). |
location |
string |
— | Yes | Azure region, e.g. centralindia. |
resource_group_name |
string |
— | Yes | Resource group to create the vault in. |
tenant_id |
string |
null |
No | Azure AD tenant ID; defaults to the provider’s current tenant. |
sku_name |
string |
"standard" |
No | standard or premium (HSM-backed). |
enable_rbac_authorization |
bool |
true |
No | Use Azure RBAC for the data plane instead of access policies. |
soft_delete_retention_days |
number |
90 |
No | Soft-delete retention window (7-90 days). |
purge_protection_enabled |
bool |
true |
No | Block permanent deletion; required for CMK. Irreversible once on. |
enabled_for_deployment |
bool |
false |
No | Let VMs fetch certificates stored as secrets. |
enabled_for_disk_encryption |
bool |
false |
No | Let Azure Disk Encryption unwrap keys. |
enabled_for_template_deployment |
bool |
false |
No | Let ARM retrieve secrets during deployment. |
public_network_access_enabled |
bool |
true |
No | Expose the public endpoint; set false for private-only. |
network_acls |
object |
{} |
No | Firewall rules; default_action defaults to Deny. |
role_assignments |
map(object) |
{} |
No | RBAC role assignments at vault scope (RBAC mode only). |
private_endpoint |
object |
null |
No | Private endpoint config (subnet_id, optional DNS zone IDs). |
log_analytics_workspace_id |
string |
null |
No | Workspace ID for diagnostic settings; null disables them. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Key Vault (used as key_vault_id downstream). |
name |
Name of the Key Vault. |
vault_uri |
Data-plane DNS URI (e.g. for secret references and the CSI driver). |
tenant_id |
Tenant ID the vault is bound to. |
rbac_authorization_enabled |
Whether the data plane uses RBAC (true) or access policies. |
private_endpoint_ip |
Private IP of the vault’s private endpoint, or null if not deployed. |
Enterprise scenario
A payments platform runs PCI-DSS workloads in Central India and needs customer-managed keys for its Storage accounts and SQL TDE. The platform team calls this module once per environment with sku_name = "premium" and purge_protection_enabled = true (Azure rejects CMK against any vault lacking purge protection), wires a private endpoint into the spoke VNet, and assigns the Key Vault Crypto Service Encryption User role to the Storage account’s managed identity via the role_assignments map. Diagnostic logs flow to the central Log Analytics workspace, giving the security team a queryable AuditEvent trail of every key unwrap for their quarterly audit.
Best practices
- Default to RBAC, never access policies. RBAC scopes cleanly to managed identities and Azure AD groups, shows up in Activity Log, and avoids the 1,024-entry access-policy ceiling. Assign least-privilege roles like
Key Vault Secrets User(read) rather than blanketAdministrator. - Always keep
purge_protection_enabled = truein prod. It is the only thing standing between aterraform destroyand permanently losing the keys that encrypt your disks and storage. It is irreversible by design — treat that as a feature, and use separate sandbox vaults for experimentation. - Close the network with
default_action = "Deny"plus a private endpoint andpublic_network_access_enabled = false. Keepbypass = "AzureServices"so trusted first-party services (Disk Encryption, Backup) still reach the vault. - Name for global uniqueness and clarity. Use a
kv-<workload>-<env>-<region>convention (e.g.kv-payments-prod-cin); vault names share a global namespace, and soft-deleted names stay reserved for the retention window, so collisions surface as confusing apply failures. - Send diagnostics to Log Analytics on day one. Enable the
AuditEventcategory so you can answer “who read this secret and when” — without it, there is no retroactive audit trail. - Rotate, do not redeploy. Store secrets with versioning and reference them via
@Microsoft.KeyVault(SecretUri=...)versionless URIs so rotation is a data-plane operation, not a Terraform change that risks drift on the vault itself.