IaC Azure

Terraform Module: Azure Key Vault — RBAC-first secret storage with purge protection baked in

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

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

TerraformAzureKey VaultModuleIaC
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