Manage a hardened Azure Key Vault with its keys, secrets, and certificates as one unit — RBAC-authorized, purge-protected, and private by default — on
hashicorp/azurerm ~> 4.0.
- 🔐 Creates one
azurerm_key_vaulthardened by default: RBAC authorization, purge protection, 90-day soft delete, public network access off. - 🗝️ Manages
azurerm_key_vault_key(generated server-side — no key material leaves Azure),azurerm_key_vault_secret, andazurerm_key_vault_certificate(import or policy-generated) as keyed maps. - 🚦 Optional
network_aclsdefault to Deny. (Certificatecontactsis accepted but cannot be applied to a new vault — see example 9.) - 🔒 Secret values and imported certificate contents are provisioned out of band; no secret is ever emitted.
💡 Why it matters: the vault is the root of trust for a regulated workload. A vault that is RBAC-only, purge-protected, and private on the empty call means the safe posture is the default — every relaxation is a deliberate, reviewable opt-out.
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 TD
RG["terraform-azurerm-resource-group"]
KV["terraform-azurerm-key-vault"]
AKV["azurerm_key_vault"]
CHILD["keys / secrets / certificates (for_each)"]
RA["terraform-azurerm-role-assignments (data-plane RBAC)"]
PE["terraform-azurerm-private-endpoint"]
CMK["Storage / SQL / Disk (CMK via key id)"]
AP["terraform-azurerm-key-vault-access-policy"]
CC["terraform-azurerm-key-vault-certificate-contacts"]
CI["terraform-azurerm-key-vault-certificate-issuer"]
RG -->|"resource_group_name + location"| KV
KV --> AKV
AKV --> CHILD
RA -->|"grants data-plane roles"| KV
KV -->|"id"| PE
KV -->|"key versionless_id"| CMK
KV -->|"key_vault_id"| AP
KV -->|"key_vault_id"| CC
KV -->|"key_vault_id"| CI
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class KV this;
class AKV key;
flowchart LR
I1["name / location / tenant_id / sku_name"]
I2["RBAC authZ, purge protection,<br/>soft-delete 90, public access off"]
I3["network_acls (Deny) / contacts"]
I4["keys / secrets / certificates (maps)"]
KV["azurerm_key_vault.this"]
K["azurerm_key_vault_key.this (for_each)"]
S["azurerm_key_vault_secret.this (for_each)"]
C["azurerm_key_vault_certificate.this (for_each)"]
O1["id / name / vault_uri"]
O2["key_ids / key_versionless_ids"]
O3["secret_ids / certificate_ids"]
I1 --> KV
I2 --> KV
I3 --> KV
I4 --> K
I4 --> S
I4 --> C
KV --> K
KV --> S
KV --> C
KV --> O1
K --> O2
S --> O3
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class KV key;
class K this;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_key_vault.this |
1 (keystone) | The hardened vault. |
azurerm_key_vault_key.this |
0..N (for_each) |
Server-generated keys. |
azurerm_key_vault_secret.this |
0..N (for_each) |
Secrets (values out of band). |
azurerm_key_vault_certificate.this |
0..N (for_each) |
Imported or policy-generated certificates. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite
-
🔴
contactsCANNOT BE SET ON A NEW VAULT. The provider's create function refuses the block outright; it exists on the 4.x line only so pre-existing vaults that already carry contacts can still be updated. Reported rather than refused here, because avalidation {}cannot tell a create from an update and refusing would strand those callers. -
🔴 The vault NAME is globally unique across all of Azure, and a destroyed vault keeps it. Soft delete holds the name for the retention window, and with purge protection on — this module's default — the vault cannot be purged early to release it. Re-creating a vault with the same name inside that window fails with what looks like someone else having taken it.
-
🔴 Purge protection cannot be turned off once on. Not force-new, not updatable, and not undone by destroying and re-creating the vault, because a soft-deleted vault retains the setting. Several Azure features (customer-managed keys on Storage, SQL, Machine Learning) require it, which is why this module defaults it on despite the cost.
-
⚠️ sku_nameis LOWERCASE and case-sensitive —"Standard"is refused. That is the opposite convention from most Azure SKU arguments in this provider. -
⚠️ Raising the SKU topremiumdoes not convert existing keys to HSM-backed ones. Those must be created again as HSM types, and a key cannot be exported to be re-imported — so "upgrade to premium" means issuing new keys and rotating everything that uses them. -
⚠️ On the pinned 4.x line the provider carries BOTHrbac_authorization_enabledand the deprecatedenable_rbac_authorization, eachConflictsWiththe other. This module uses the new name; older examples use the old one, and setting both fails at plan. -
⚠️ soft_delete_retention_daysis sent only when it is NOT 90, and the provider's read ASSUMES 90 when the API returns nothing for it — so a vault whose retention Azure does not report reads back as 90 regardless. -
nameis globally unique and immutable;resource_group_name,location, andtenant_idare immutable too. -
With
rbac_authorization_enabled = true(the default), access policies are not used — grant data-plane access with the role-assignments module (e.g. Key Vault Secrets User) at the vault scope. -
purge_protection_enabled = true(default) cannot be disabled once enabled, and a purge-protected vault cannot be permanently deleted until its soft-delete window elapses — deliberate for production. -
Managing secrets/keys/certs requires the deploying identity to hold the data-plane role (e.g. Key Vault Administrator) because RBAC authZ is on — a management-plane role alone is not enough.
-
Secret
valueis provider-sensitive (redacted in plan); imported certificatecontentsare secret material — provision both out of band.
- Key Vault Contributor (management plane) on the target resource group to create/update the vault.
- A data-plane role for the deploying identity when RBAC authZ is on: Key Vault Administrator (or the narrower Crypto/Secrets/Certificates Officer roles) at the vault scope, so Terraform can manage keys/secrets/certificates.
- The
Microsoft.KeyVaultresource provider registered on the subscription. - An existing resource group and the target Entra ID
tenant_id. - For private access, a subnet and a private endpoint + private DNS zone (
privatelink.vaultcore.azure.net). - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
terraform-azurerm-key-vault/
├── providers.tf # required_version + azurerm ~> 4.0; no provider block
├── variables.tf # name, rg, location, tenant_id, sku, hardening toggles, network_acls, keys/secrets/certificates
├── main.tf # azurerm_key_vault.this + for_each keys / secrets / certificates
├── outputs.tf # id, name, vault_uri, key_ids, key_versionless_ids, secret_ids, certificate_ids
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical Terraform ignore set
provider "azurerm" {
features {}
}
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-platform-prod-eus2"
resource_group_name = "rg-security-prod-eastus2"
location = "eastus2"
tenant_id = var.tenant_id
}ℹ️ The empty call is RBAC-only, purge-protected, private. Pin the module by tag (
?ref=v1.0.0), never a branch.
Consumes
| Input | Type | From |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / resource group (location) |
tenant_id |
string |
caller (the Entra tenant) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Vault Resource ID | the key-vault-* config modules (access-policy, certificate-contacts, certificate-issuer, managed-storage-account), role assignments, private endpoints, diagnostics |
name |
Vault name | reference |
vault_uri |
Data-plane URI | SDK/app configuration |
key_versionless_ids |
Map key key → versionless key ID | CMK wiring (Storage/SQL/Disk) |
secret_ids / certificate_ids |
child maps | reference |
1 · Minimal (hardened)
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-min-eus2"
resource_group_name = "rg-security-eastus2"
location = "eastus2"
tenant_id = var.tenant_id
}🔒 RBAC authZ, purge protection, 90-day soft delete, no public access — all on by default.
2 · Premium (HSM-backed keys)
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-hsm-eus2"
resource_group_name = "rg-security-eastus2"
location = "eastus2"
tenant_id = var.tenant_id
sku_name = "premium"
}3 · Network ACLs (allow specific subnets)
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-acl-eus2"
resource_group_name = "rg-security-eastus2"
location = "eastus2"
tenant_id = var.tenant_id
network_acls = {
virtual_network_subnet_ids = [var.app_subnet_id]
ip_rules = ["203.0.113.0/24"]
}
}🔒
default_actiondefaults toDeny; only listed subnets/IPs are allowed.
4 · Generate an RSA key
keys = {
cmk = {
key_type = "RSA"
key_size = 3072
key_opts = ["wrapKey", "unwrapKey"]
}
}5 · Generate an EC key
keys = {
signing = {
key_type = "EC"
curve = "P-256"
key_opts = ["sign", "verify"]
}
}6 · Store a secret (value out of band)
module "kv" {
# ...
secrets = {
db-password = { value = var.db_password } # populated from a secret store / random_password, never a literal
}
}🔒
valueflows into a provider-sensitive attribute and is redacted from plan; provision it out of band.
7 · Self-signed certificate (policy-generated)
certificates = {
app-tls = {
certificate_policy = {
issuer_parameters = { name = "Self" }
key_properties = { exportable = true, key_type = "RSA", reuse_key = true, key_size = 2048 }
secret_properties = { content_type = "application/x-pkcs12" }
x509_certificate_properties = {
subject = "CN=app.internal.contoso.com"
validity_in_months = 12
key_usage = ["digitalSignature", "keyEncipherment"]
subject_alternative_names = { dns_names = ["app.internal.contoso.com"] }
}
lifetime_action = [{
action = { action_type = "AutoRenew" }
trigger = { days_before_expiry = 30 }
}]
}
}
}8 · Import a certificate (PFX out of band)
certificates = {
imported = {
certificate = {
contents = var.pfx_base64 # provisioned out of band
password = var.pfx_password
}
}
}9 · Certificate contacts — what NOT to write on a new vault
❌ This cannot be applied to a vault this module creates.
contacts = [{ email = "pki@example.com", name = "PKI Team" }]🔴 The provider refuses
contacton any NEW key vault, from inside its create function: "contactfield is not supported for new key vaults". The field survives on the pinned~> 4.0line only so that vaults which already carry contacts — deployed before it was withdrawn — can still be updated without being re-created. The whole feature sits behind an internal 5.0 flag and is going away.ℹ️ So why does this module still accept the value? Because a
validation {}block fires on every plan and cannot tell a create from an update. Refusing a non-empty list would stop a caller who already has contacts on a pre-existing vault from planning at all — and, since a failed validation blocksterraform destroytoo, would leave that vault unmanageable through the module. It is reported through thecontacts_cannot_be_set_on_a_new_vaultoutput instead.✅ Do this instead. Certificate expiry notification belongs in Azure Monitor or Event Grid, both of which observe the vault's certificate events without this field and can route to an action group you control.
10 · Enable for disk encryption
module "kv" {
# ...
enabled_for_disk_encryption = true
}11 · Public access (opt-in)
module "kv" {
# ...
public_network_access_enabled = true
network_acls = { default_action = "Allow" }
}
⚠️ Opening public access removes the private guardrail — use only with justification.
12 · for_each — a vault per environment
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
for_each = toset(["dev", "prod"])
name = "kv-${each.key}-eus2"
resource_group_name = "rg-security-eastus2"
location = "eastus2"
tenant_id = var.tenant_id
}13 · Grant data-plane access via RBAC
module "kv" { # ... }
module "kv_roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.kv.id
role_assignments = {
secrets-user = {
role_definition_name = "Key Vault Secrets User"
principal_id = var.app_principal_id
}
}
}🔒 With RBAC authZ on, data-plane access is granted by role assignment, not access policies.
14 · 🏗️ End-to-end composition
A resource group, a hardened vault, an identity granted secrets access, and a CMK key wired into a storage account.
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-security-prod-eastus2"
location = "eastus2"
}
module "id" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
name = "id-storage-cmk"
resource_group_name = module.rg.name
location = module.rg.location
}
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-plat-prod-eus2"
resource_group_name = module.rg.name
location = module.rg.location
tenant_id = var.tenant_id
keys = {
storage-cmk = { key_type = "RSA", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"] }
}
}
module "kv_roles" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.kv.id
role_assignments = {
cmk-user = {
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_id = module.id.principal_id
}
}
}
# module.kv.key_versionless_ids["storage-cmk"] + module.id.id feed the storage account's customer_managed_key.💡 The vault is RBAC-only; the identity is granted crypto access by role, and the key's versionless ID drives storage CMK — least privilege, end to end.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
✅ | — | Vault name (3–24). Immutable. |
resource_group_name |
string |
✅ | — | Containing resource group. Immutable. |
location |
string |
✅ | — | Azure region. Immutable. |
tenant_id |
string |
✅ | — | Entra tenant ID. |
sku_name |
string |
— | "standard" |
standard / premium. |
rbac_authorization_enabled |
bool |
— | true |
Use RBAC (not access policies). |
purge_protection_enabled |
bool |
— | true |
Purge protection. |
soft_delete_retention_days |
number |
— | 90 |
7–90. |
public_network_access_enabled |
bool |
— | false |
Public reachability. |
network_acls |
object |
— | null |
ACLs (default_action Deny). |
contacts |
list(object) |
— | [] |
Certificate contacts. |
keys / secrets / certificates |
map(object) |
— | {} |
Child objects. |
tags |
map(string) |
— | {} |
Tags. |
timeouts |
object |
— | null |
Optional timeouts. |
Full variable schemas (children)
keys = map(object({
name = optional(string), key_type = string, key_opts = list(string),
key_size = optional(number), curve = optional(string),
expiration_date = optional(string), not_before_date = optional(string), tags = optional(map(string))
})) # key_type ∈ RSA | RSA-HSM | EC | EC-HSM (validated)
secrets = map(object({
name = optional(string), value = string, content_type = optional(string),
expiration_date = optional(string), not_before_date = optional(string), tags = optional(map(string))
})) # value is provider-sensitive; provision out of band
certificates = map(object({
name = optional(string)
certificate = optional(object({ contents = string, password = optional(string) })) # import
certificate_policy = optional(object({ issuer_parameters, key_properties, secret_properties,
x509_certificate_properties = optional(...), lifetime_action = optional(list(...)) })) # generate
tags = optional(map(string))
}))| Output | Description | Notes |
|---|---|---|
id |
Vault Resource ID | Emitted first. |
name |
Vault name | — |
location |
Azure region, in the canonical form Azure uses. | Read from the resource, not var.location. |
vault_uri |
Data-plane URI | — |
key_ids / key_versionless_ids |
Key maps | Versionless for rotation-following CMK. |
secret_ids / certificate_ids |
Child maps | Values never emitted. |
- Hardened empty call. RBAC authZ, purge protection, 90-day soft delete, and no public access are the defaults; each is a documented opt-out.
- RBAC, not access policies. The module deliberately omits
access_policy; data-plane access is granted with role assignments at the vault scope, which is auditable and least-privilege. - Data-plane role needed to manage children. Because RBAC is on, the deploying identity must hold a data-plane role (e.g. Key Vault Administrator) to create keys/secrets/certificates.
- Keys are server-side.
azurerm_key_vault_keygenerates material in the vault; no private key leaves Azure. Secret values and imported certificate contents are provisioned out of band and never emitted. features {}dependence. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }(Key Vault behavior such as purge-on-destroy is a providerfeaturesconcern).
| Concern | Secure default (empty call) | Opt-out |
|---|---|---|
| Authorization | rbac_authorization_enabled = true |
set false (access policies) |
| Purge protection | purge_protection_enabled = true |
set false |
| Soft delete | soft_delete_retention_days = 90 |
lower to as few as 7 |
| Public network access | public_network_access_enabled = false |
set true |
| Network default action | Deny (when network_acls set) |
Allow |
| Deployment access flags | all false |
enable per need |
cd terraform-azurerm-key-vault
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the name, sku, soft-delete, and key-type validations) and canonically formatted, with no cloud calls. What it does not exercise: whether the deploying identity holds the data-plane role, global name uniqueness, and certificate policy validity — those surface only under terraform plan/apply against real credentials from CI.
$ terraform output
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-security-prod-eastus2/providers/Microsoft.KeyVault/vaults/kv-plat-prod-eus2"
name = "kv-plat-prod-eus2"
vault_uri = "https://kv-plat-prod-eus2.vault.azure.net/"
key_versionless_ids = {
"storage-cmk" = "https://kv-plat-prod-eus2.vault.azure.net/keys/storage-cmk"
}| Symptom | Cause | Fix |
|---|---|---|
| 403 managing secrets/keys | RBAC on but identity lacks a data-plane role | Grant Key Vault Administrator (or scoped officer) at the vault. |
| Vault name rejected | Not globally unique, or not 3–24 chars | Choose a unique, valid name. |
| Cannot delete vault | Purge protection + soft delete window | Wait out the window, or plan for it in non-prod. |
| Access policy ignored | RBAC authZ is on | Use role assignments, not access policies. |
| Secret value visible worry | — | value is provider-sensitive and redacted; provision out of band. |
| Cert policy apply error | Missing required policy sub-fields | Provide issuer_parameters/key_properties/secret_properties. |
- Provider resources:
azurerm_key_vault,azurerm_key_vault_key,azurerm_key_vault_secret,azurerm_key_vault_certificate - Sibling modules:
terraform-azurerm-role-assignments,terraform-azurerm-user-assigned-identity,terraform-azurerm-private-endpoint,terraform-azurerm-storage-account,terraform-azurerm-resource-group - This module's cross-module contract:
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."