This Terraform module creates an Azure Key Vault using the Azure Verified Module (AVM) with secure configurations including support for multiple private endpoints across different Azure subscriptions.
- ✅ Uses Azure Verified Module (AVM) for Key Vault
- ✅ Multi-subscription support - Hub-and-spoke architecture ready
- ✅ Dual private endpoints - Support for primary and secondary private endpoints
- ✅ Flexible networking - Connect to same or different subnets
- ✅ Multiple DNS zones - Support for private DNS zones in different subscriptions
- ✅ RBAC role assignments - Built-in support for Key Vault Secrets Officer and Secrets User roles
- ✅ Private endpoint with existing private DNS zone integration
- ✅ Public network access disabled
- ✅ Purge protection enabled
- ✅ Soft delete with 7-day retention
- ✅ Telemetry disabled
- ✅ Network ACLs with deny-by-default policy
- ✅ Automatic tenant ID detection
This module supports flexible hub-and-spoke architectures:
┌─────────────────────────────────────────────────────────────┐
│ Spoke Subscription (Key Vault Lives Here) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Key Vault │ │
│ │ ├─ Private Endpoint 1 (Primary) ────────┐ │ │
│ │ └─ Private Endpoint 2 (Secondary) ────┐ │ │ │
│ └────────────────────────────────────│───│───┘ │ │
│ │ │ │ │
│ ┌─────────────────────────────────┐ │ │ │ │
│ │ VNet (Spoke) │ │ │ │ │
│ │ └─ Subnet (Private Endpoints)───┼──┘ │ │ │
│ └──────────────────────────────────┘ │ │ │
└─────────────────────────────────────────────┼───────────┘ │
│ │
┌─────────────────────────────────────────────┼───────────┐ │
│ Hub Subscription (Shared Networking) │ │ │
│ ┌──────────────────────────────────────────┘ │ │
│ │ Private DNS Zone (Primary) │ │
│ │ privatelink.vaultcore.azure.net │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────┼───────────┐
│ Secondary Subscription (Optional) │ │
│ ┌──────────────────────────────────────────┘ │
│ │ Private DNS Zone (Secondary) │
│ │ privatelink.vaultcore.azure.net │
│ └──────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────┘
Before using this module, ensure you have:
-
Spoke Subscription: Where the Key Vault will be created
- Existing Azure Resource Group
- Existing Virtual Network and Subnet (for private endpoints)
-
Hub Subscription: For shared networking resources
- Existing Private DNS Zone (
privatelink.vaultcore.azure.net)
- Existing Private DNS Zone (
-
Secondary Subscription (Optional): For additional DNS resolution
- Existing Private DNS Zone (if you want a second private endpoint)
-
Appropriate permissions to create resources in all subscriptions
This module requires provider configurations to be passed from the caller. The module expects:
- Default
azurermprovider: For the spoke subscription (where Key Vault resources are created) azurerm.hubprovider: For the hub subscription (where primary DNS zone resides)azurerm.secondaryprovider: For the secondary subscription (optional, for secondary DNS zone)
Important: Configure these providers in your root module before calling this module. Example:
# Root module provider configuration
provider "azurerm" {
features {
key_vault {
purge_soft_delete_on_destroy = true
recover_soft_deleted_key_vaults = true
}
}
subscription_id = var.spoke_subscription_id
}
provider "azurerm" {
alias = "hub"
features {}
subscription_id = var.hub_subscription_id
}
provider "azurerm" {
alias = "secondary"
features {}
subscription_id = var.secondary_subscription_id
}Then pass these providers to the module using the providers argument (see usage examples below).
Single private endpoint with DNS in hub subscription:
module "key_vault" {
source = "./tfmodule-keyvault"
# Provider configurations (REQUIRED)
providers = {
azurerm = azurerm # Spoke subscription
azurerm.hub = azurerm.hub # Hub subscription
azurerm.secondary = azurerm.secondary # Secondary subscription (required even if not used)
}
# Required variables
resource_group_name = "rg-myapp-prod"
environment = "prod"
# Networking (shared by both endpoints)
virtual_network_name = "vnet-spoke-prod"
subnet_name = "subnet-privateendpoints"
virtual_network_resource_group_name = "rg-network-prod"
# DNS Configuration (in hub)
private_dns_zone_name = "privatelink.vaultcore.azure.net"
private_dns_zone_resource_group_name = "rg-dns-hub"
# Private Endpoint Configuration
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = false
# Optional: Custom Key Vault name
key_vault_name = "kv-myapp-prod-001"
tags = {
Environment = "production"
Application = "myapp"
}
}Both private endpoints in the same subnet, but with DNS zones in different subscriptions:
module "key_vault" {
source = "./tfmodule-keyvault"
# Provider configurations (REQUIRED)
providers = {
azurerm = azurerm # Spoke subscription
azurerm.hub = azurerm.hub # Hub subscription
azurerm.secondary = azurerm.secondary # Secondary subscription
}
# Required variables
resource_group_name = "rg-myapp-prod"
environment = "prod"
# Shared Networking (both endpoints use this)
virtual_network_name = "vnet-spoke-prod"
subnet_name = "subnet-privateendpoints"
virtual_network_resource_group_name = "rg-network-prod"
# Primary DNS Configuration (in hub subscription)
private_dns_zone_name = "privatelink.vaultcore.azure.net"
private_dns_zone_resource_group_name = "rg-dns-hub"
# Enable both private endpoints
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = true
# Secondary DNS Configuration (in secondary subscription)
# Note: secondary_private_dns_zone_name defaults to "privatelink.vaultcore.azure.net"
secondary_private_dns_zone_resource_group_name = "rg-dns-secondary"
key_vault_name = "kv-myapp-prod-001"
tags = {
Environment = "production"
}
}Private endpoints in different subnets (e.g., different regions or networks):
module "key_vault" {
source = "./tfmodule-keyvault"
# Provider configurations (REQUIRED)
providers = {
azurerm = azurerm # Spoke subscription
azurerm.hub = azurerm.hub # Hub subscription
azurerm.secondary = azurerm.secondary # Secondary subscription
}
resource_group_name = "rg-myapp-prod"
environment = "prod"
# Shared defaults (used by primary)
virtual_network_name = "vnet-spoke-uksouth"
subnet_name = "subnet-pe-uksouth"
virtual_network_resource_group_name = "rg-network-uksouth"
private_dns_zone_name = "privatelink.vaultcore.azure.net"
private_dns_zone_resource_group_name = "rg-dns-hub"
# Enable both endpoints
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = true
# Override networking for secondary endpoint
secondary_virtual_network_name = "vnet-spoke-ukwest"
secondary_subnet_name = "subnet-pe-ukwest"
secondary_virtual_network_resource_group_name = "rg-network-ukwest"
# Secondary DNS zone (uses default name)
secondary_private_dns_zone_resource_group_name = "rg-dns-secondary"
key_vault_name = "kv-myapp-prod-001"
}Enable or disable endpoints independently:
# Deploy with only secondary endpoint
enable_primary_private_endpoint = false
enable_secondary_private_endpoint = true
# Or deploy with both
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = true
# Or deploy with neither (for testing public access scenarios)
enable_primary_private_endpoint = false
enable_secondary_private_endpoint = falseCreate multiple Key Vaults conditionally:
module "key_vault" {
source = "./tfmodule-keyvault"
count = var.create_key_vault ? 1 : 0
providers = {
azurerm = azurerm
azurerm.hub = azurerm.hub
azurerm.secondary = azurerm.secondary
}
resource_group_name = "rg-myapp-prod"
environment = "prod"
virtual_network_name = "vnet-spoke-prod"
subnet_name = "subnet-privateendpoints"
enable_primary_private_endpoint = true
}Create Key Vaults for multiple environments:
module "key_vault" {
source = "./tfmodule-keyvault"
for_each = toset(["dev", "test", "prod"])
providers = {
azurerm = azurerm
azurerm.hub = azurerm.hub
azurerm.secondary = azurerm.secondary
}
resource_group_name = "rg-myapp-${each.key}"
environment = each.key
key_vault_name = "kv-myapp-${each.key}"
virtual_network_name = "vnet-spoke-${each.key}"
subnet_name = "subnet-privateendpoints"
enable_primary_private_endpoint = true
tags = {
Environment = each.key
}
}Ensure Key Vault is created after specific resources:
resource "azurerm_resource_group" "main" {
name = "rg-myapp-prod"
location = "UK South"
}
resource "azurerm_virtual_network" "main" {
name = "vnet-spoke-prod"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
address_space = ["10.0.0.0/16"]
}
module "key_vault" {
source = "./tfmodule-keyvault"
providers = {
azurerm = azurerm
azurerm.hub = azurerm.hub
azurerm.secondary = azurerm.secondary
}
# Ensure network is fully configured before creating Key Vault
depends_on = [
azurerm_virtual_network.main,
azurerm_subnet.main,
azurerm_private_dns_zone_virtual_network_link.hub
]
resource_group_name = azurerm_resource_group.main.name
environment = "prod"
virtual_network_name = azurerm_virtual_network.main.name
subnet_name = "subnet-privateendpoints"
enable_primary_private_endpoint = true
}Grant service principals or managed identities access to manage or read secrets:
module "key_vault" {
source = "./tfmodule-keyvault"
# Provider configurations (REQUIRED)
providers = {
azurerm = azurerm
azurerm.hub = azurerm.hub
azurerm.secondary = azurerm.secondary
}
# ... other configuration ...
# Grant full secret management permissions (create, read, update, delete)
secret_officers = [
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", # DevOps Service Principal
"ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj" # Admin Managed Identity
]
# Grant read-only secret access
secret_users = [
"11111111-2222-3333-4444-555555555555", # Application Managed Identity
"66666666-7777-8888-9999-000000000000", # Web App Managed Identity
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" # Function App Managed Identity
]
tags = {
Environment = "production"
}
}Important Notes:
- Use principal IDs (Object IDs), not Application IDs
- For Service Principals: Find in Azure AD → Enterprise Applications → Object ID
- For Managed Identities: Find in the managed identity resource → Properties → Principal ID
- Secrets should not be managed in Terraform to avoid state file exposure
✅ Modern Terraform Module - No internal provider configurations, fully compatible with:
depends_on- Control module dependenciescount- Create multiple instances conditionallyfor_each- Create multiple instances from a map or set
✅ Multi-Provider Support - Accepts provider configurations from caller for flexible multi-subscription deployments
| Name | Version |
|---|---|
| terraform | >= 1.5.0 |
| azurerm | >= 3.70.0 |
| random | >= 3.4.0 |
This module expects the following providers to be configured by the caller:
| Provider Alias | Purpose |
|---|---|
azurerm (default) |
Spoke subscription - Where Key Vault and its resources are created |
azurerm.hub |
Hub subscription - For primary private DNS zone lookups |
azurerm.secondary |
Secondary subscription - For secondary private DNS zone (optional but must be configured) |
random |
Generates unique suffixes for resource naming |
| Name | Description | Type | Required |
|---|---|---|---|
| resource_group_name | Name of the existing resource group where the Key Vault will be created | string |
yes |
| environment | Environment name (e.g., dev, test, prod) | string |
yes |
| virtual_network_name | Name of the existing virtual network (used by both private endpoints unless overridden) | string |
yes |
| subnet_name | Name of the existing subnet for private endpoints (used by both unless overridden) | string |
yes |
| Name | Description | Type | Default |
|---|---|---|---|
| enable_primary_private_endpoint | Enable or disable the primary private endpoint | bool |
true |
| enable_secondary_private_endpoint | Enable or disable the secondary private endpoint | bool |
false |
| Name | Description | Type | Default |
|---|---|---|---|
| primary_virtual_network_name | Override VNet for primary endpoint | string |
Uses virtual_network_name |
| primary_subnet_name | Override subnet for primary endpoint | string |
Uses subnet_name |
| primary_virtual_network_resource_group_name | Override VNet RG for primary endpoint | string |
Uses virtual_network_resource_group_name |
| primary_private_endpoint_name | Custom name for primary private endpoint | string |
{key_vault_name}-pe-primary |
| primary_network_interface_name | Custom name for primary NIC | string |
{key_vault_name}-pe-primary-nic |
| primary_private_service_connection_name | Custom name for primary PSC | string |
psc-{key_vault_name}-primary |
| Name | Description | Type | Default |
|---|---|---|---|
| secondary_virtual_network_name | Override VNet for secondary endpoint | string |
Uses virtual_network_name |
| secondary_subnet_name | Override subnet for secondary endpoint | string |
Uses subnet_name |
| secondary_virtual_network_resource_group_name | Override VNet RG for secondary endpoint | string |
Uses virtual_network_resource_group_name |
| secondary_private_dns_zone_name | Name of private DNS zone for secondary endpoint | string |
privatelink.vaultcore.azure.net |
| secondary_private_dns_zone_resource_group_name | Resource group of secondary DNS zone | string |
null |
| create_secondary_dns_zone_vnet_links | Create virtual network links for secondary DNS zone | bool |
true |
| secondary_private_endpoint_name | Custom name for secondary private endpoint | string |
{key_vault_name}-pe-secondary |
| secondary_network_interface_name | Custom name for secondary NIC | string |
{key_vault_name}-pe-secondary-nic |
| secondary_private_service_connection_name | Custom name for secondary PSC | string |
psc-{key_vault_name}-secondary |
| Name | Description | Type | Default |
|---|---|---|---|
| key_vault_name | Custom name for the Key Vault | string |
{environment}-{random}-kv |
| sku_name | Key Vault SKU (standard or premium) | string |
standard |
| virtual_network_resource_group_name | Resource group of VNet (if different) | string |
resource_group_name |
| private_dns_zone_name | Primary DNS zone name | string |
privatelink.vaultcore.azure.net |
| private_dns_zone_resource_group_name | Primary DNS zone resource group | string |
resource_group_name |
| allowed_ip_ranges | IP ranges allowed to access Key Vault | list(string) |
[] |
| additional_subnet_ids | Additional subnet IDs allowed to access | list(string) |
[] |
| secret_officers | Principal IDs to grant Key Vault Secrets Officer role | list(string) |
[] |
| secret_users | Principal IDs to grant Key Vault Secrets User role | list(string) |
[] |
| tags | Tags to assign to resources | map(string) |
{} |
| Name | Description |
|---|---|
| key_vault_id | The ID of the Key Vault |
| key_vault_name | The name of the Key Vault |
| key_vault_uri | The URI of the Key Vault |
| key_vault_location | The location of the Key Vault |
| key_vault_resource_group_name | The resource group containing the Key Vault |
| key_vault_tenant_id | The tenant ID of the Key Vault |
| Name | Description |
|---|---|
| private_endpoints | Information about all private endpoints |
| primary_private_endpoint_enabled | Whether primary endpoint is enabled |
| secondary_private_endpoint_enabled | Whether secondary endpoint is enabled |
| primary_virtual_network_id | Primary VNet ID |
| primary_subnet_id | Primary subnet ID |
| secondary_virtual_network_id | Secondary VNet ID (if enabled) |
| secondary_subnet_id | Secondary subnet ID (if enabled) |
| primary_dns_zone_id | Primary DNS zone ID |
| secondary_dns_zone_id | Secondary DNS zone ID (if configured) |
| secondary_dns_zone_vnet_link_id | Secondary DNS zone virtual network link ID (if created) |
| Name | Description |
|---|---|
| network_acls | Network ACL configuration |
| resource_group_location | Resource group location |
| current_tenant_id | Current tenant ID |
| current_client_id | Current client ID |
| secret_officers | Principal IDs granted Secrets Officer role |
| secret_users | Principal IDs granted Secrets User role |
The module automatically creates virtual network links for the secondary private DNS zone when:
- Secondary private endpoint is enabled (
enable_secondary_private_endpoint = true) - Secondary DNS zone is in a different subscription (
secondary_private_dns_zone_resource_group_nameis specified) - Link creation is enabled (
create_secondary_dns_zone_vnet_links = true- default)
| Scenario | Description | Link Created |
|---|---|---|
| Spoke networking | Secondary endpoint uses spoke VNet, DNS zone in secondary subscription | ✅ Links secondary DNS zone → spoke VNet |
| Secondary networking | Secondary endpoint uses secondary VNet, DNS zone in secondary subscription | ✅ Links secondary DNS zone → secondary VNet |
| Same subscription | DNS zone in same subscription as VNet | ❌ Not needed - Azure handles this |
| Primary DNS zone | Primary DNS zone already has links | ❌ Not created - assumed to exist |
If you manage DNS zone links externally:
module "key_vault" {
# ... other configuration ...
create_secondary_dns_zone_vnet_links = false
}This module implements several security best practices:
- Private Endpoints: All traffic routed through private endpoints
- Multi-Subscription Support: Isolate DNS zones and networking
- Network ACLs: Default deny policy with explicit allow rules
- Public Access: Disabled by default
- Purge Protection: Enabled to prevent accidental deletion
- Soft Delete: 7-day retention period for recovery
- Flexible Access Control: Support for multiple private endpoints
- RBAC Role Assignments: Built-in support for Key Vault Secrets Officer and Secrets User roles
- No Secrets in State: Module does not manage secrets to avoid state file exposure
The module supports automatic assignment of the following Azure RBAC roles:
- Purpose: Full secret management capabilities
- Permissions: Create, read, update, delete, list secrets
- Use Case: DevOps pipelines, administrators, secret rotation services
- Variable:
secret_officers
- Purpose: Read-only secret access
- Permissions: Read and list secrets
- Use Case: Applications, microservices, functions that only need to read secrets
- Variable:
secret_users
For Service Principals:
az ad sp show --id <application-id> --query id -o tsvFor Managed Identities:
az identity show --name <identity-name> --resource-group <rg-name> --query principalId -o tsvFor User-Assigned Managed Identities attached to resources:
# For VM
az vm identity show --name <vm-name> --resource-group <rg-name> --query principalId -o tsv
# For App Service
az webapp identity show --name <app-name> --resource-group <rg-name> --query principalId -o tsv- State File Security: Secrets stored in Terraform state are in plain text
- Drift Management: External secret updates will be overwritten by Terraform
- Rotation Complexity: Secret rotation is difficult when managed by infrastructure code
Option 1: Use RBAC + External Management
# Grant permissions in Terraform
secret_officers = ["service-principal-id"]
# Then manage secrets separately using:
# - Azure CLI: az keyvault secret set
# - Azure PowerShell: Set-AzKeyVaultSecret
# - Azure DevOps variable groups
# - GitHub Actions secrets
# - Application code with Azure SDKOption 2: Separate Secrets Module (for initial population only) Create a separate Terraform workspace/state for one-time secret population, then manage externally.
Option 3: Azure DevOps/GitHub Pipelines Use pipeline tasks to populate secrets after Key Vault creation.
tfmodule-keyvault/
├── versions.tf # Terraform and provider version requirements
├── providers.tf # Provider configurations (spoke, hub, secondary)
├── data.tf # All data sources (networking, DNS zones)
├── locals.tf # Computed values and configurations
├── resources.tf # Direct resource creation (random string)
├── keyvault.tf # Key Vault module configuration
├── rbac.tf # RBAC role assignments
├── variables.tf # Input variable definitions
├── outputs.tf # Output value definitions
├── terraform.tfvars # Variable values (gitignore this!)
└── README.md # This file
Most common scenario - Key Vault in spoke, DNS in hub:
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = falseKey Vault accessible from multiple subscriptions via different DNS zones:
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = true
secondary_subscription_id = "different-subscription-id"Primary and secondary endpoints in different regions:
enable_primary_private_endpoint = true
enable_secondary_private_endpoint = true
secondary_virtual_network_name = "vnet-dr-region"
secondary_subnet_name = "subnet-pe-dr"Managed identities for applications need secret access:
secret_users = [
"11111111-2222-3333-4444-555555555555" # App Service Managed Identity
]If you can't resolve the Key Vault hostname:
- Verify private DNS zone is linked to the VNet
- Check the private endpoint is in "Approved" state
- Ensure DNS zone resource group name is correct
When using multiple subscriptions:
- Ensure you have appropriate RBAC permissions in all subscriptions
- Verify provider configurations have correct subscription IDs
- Check that networking resources exist in the specified subscriptions
If private endpoint fails to create:
- Verify subnet has no network policies blocking private endpoints
- Check subnet has available IP addresses
- Ensure Key Vault resource provider is registered
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests if applicable
- Update documentation
- Submit a pull request
This module is licensed under the MIT License. See LICENSE file for details.
For issues, questions, or contributions, please open an issue in the repository.