I have setup a single KeyVault and 2 Function Apps using Bicep. To test I build the bicep and input the ARM template into "Deploy custom template" on Azure portal. KeyVault deployed with public network access disabled, a private endpoint, and both are linked:
...
var is_private_access = SUBNET_ID != ''
resource KeyVault 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {
name: key_vault_unique_name
location: REGION
properties: {
accessPolicies: ENABLE_RBAC_AUTHORIZATION ? [] : ACCESS_POLICIES
createMode: CREATE_MODE
enabledForDeployment: ENABLED_FOR_DEPLOYMENT
enabledForDiskEncryption: ENABLED_FOR_DISK_ENCRYPTION
enabledForTemplateDeployment: ENABLED_FOR_TEMPLATE_DEPLOYMENT
enablePurgeProtection: ENABLE_PURGE_PROTECTION
enableRbacAuthorization: ENABLE_RBAC_AUTHORIZATION
enableSoftDelete: ENABLE_SOFT_DELETE
networkAcls: {}
publicNetworkAccess: is_private_access ? 'disabled' : 'enabled'
sku: {
family: 'A'
name: 'Standard'
}
softDeleteRetentionInDays: SOFT_DELETE_RETENTION_DAYS
tenantId: TENANT_ID
vaultUri: VAULT_URI
}
}
resource PrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-02-01' = if (is_private_access) {
name: take('VaultPrivateEndpoint-${KEY_VAULT_UNIQUE_STRING}', 64)
location: REGION
properties: {
subnet: {
id: SUBNET_ID
}
privateLinkServiceConnections: [
{
name: '${KeyVault.name}-file-private-link-connection'
properties: {
privateLinkServiceId: KeyVault.id
groupIds: [
'vault'
]
}
}
]
}
}
resource VaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = if (is_private_access) {
name: 'privatelink.vaultcore.azure.net'
}
resource PrivateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (is_private_access) {
parent: VaultPrivateDnsZone
name: take('virtual-network-link-${KEY_VAULT_UNIQUE_STRING}', 64)
location: REGION
properties: {
registrationEnabled: false
virtualNetwork: {
id: VNET_ID
}
}
}
resource PrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-11-01' = if (is_private_access) {
parent: PrivateEndpoint
name: 'VaultDnsGroup'
properties: {
privateDnsZoneConfigs: [
{
name: 'vaultConfig'
properties: {
privateDnsZoneId: VaultPrivateDnsZone.id
}
}
]
}
}
Now the Function Apps get created one at a time, each in its own VNet and subnet. Private endpoints and links are created for each subnet to the Private DNS 'privatelink.vaultcore.azure.net'. First link goes smoothly, references to secrets can be pulled to fill environment variables of that Function App. Once the second private link deploys neither can connect.
After a ton of troubleshooting I found what happens exactly at that moment. Second link registers an A record in the private DNS zone with the same subdomain (.privatelink.vaultcore.azure.net) and overrides the IP address. There seems to be no method to deploy 2 private endpoints from different VNets for the same KeyVault.
Manually I managed to find a workaround, overriding the A record to have multiple IPs. This method did not work as part of the ARM template deployment so far since the IPs are generated during deployment and I cannot reference them from the private endpoint resource - else I get an error saying the deployer can't read the endpoint's properties that were not set at initialization.
Additional context: I followed this KeyVault private deployment guide, and found a troubleshooting guide for some similar scenarios. But found the information misleading since they suggest a private DNS zone per VNet when you can't create multiple of those in a single resource group with the same name, which has to be "privatelink.vaultcore.azure.net".
I ended up using a powershell script that runs in place of the resource creating the DNS zone group. It saves the current A record IP addresses, creates the group himself, then appends the IP addresses back into the DNS record. That was the only way I found of making it work in my situation.
Bicep resource used: Microsoft.Resources/deploymentScripts@2020-10-01
And here is the script:
param(
[string] $deploymentResourceGroupName,
[string] $keyVaultName,
[string] $privateEndpointName,
[string] $dnsGroupName
)
# 1
try {
$originalDnsZoneRecord = Get-AzPrivateDnsRecordSet -ResourceGroupName $deploymentResourceGroupName -ZoneName "privatelink.vaultcore.azure.net" -Name $keyVaultName -RecordType A -ErrorAction SilentlyContinue
$originalDnsRecordIpAddresses = if ($originalDnsZoneRecord) {
$originalDnsZoneRecord.Records.Ipv4Address
}
}
catch {
$originalDnsRecordIpAddresses = $()
}
# 2
$zone = Get-AzPrivateDnsZone -ResourceGroupName $deploymentResourceGroupName -Name "privatelink.vaultcore.azure.net"
$dnsZoneGroupConfig = New-AzPrivateDnsZoneConfig -Name $dnsGroupName -PrivateDnsZoneId $zone.ResourceId
$originalDnsZoneGroup = Get-AzPrivateDnsZoneGroup -ResourceGroupName $deploymentResourceGroupName -PrivateEndpointName $privateEndpointName
$dnsZoneGroupName = ""
if ($originalDnsZoneGroup.Count -eq 0) {
$dnsZoneGroupName = "vaultDnsZoneGroup"
Set-AzPrivateDnsZoneGroup -ResourceGroupName $deploymentResourceGroupName -PrivateEndpointName $privateEndpointName -name $dnsZoneGroupName -PrivateDnsZoneConfig $dnsZoneGroupConfig
} else {
$dnsZoneGroupName = $originalDnsZoneGroup.Name
}
# 3
$newDnsZoneGroup = Get-AzPrivateDnsZoneGroup -ResourceGroupName $deploymentResourceGroupName -PrivateEndpointName $privateEndpointName -name $dnsZoneGroupName
$newIp = $newDnsZoneGroup.PrivateDnsZoneConfigs.RecordSets.IpAddresses
$currentDnsZoneRecord = Get-AzPrivateDnsRecordSet -ResourceGroupName $deploymentResourceGroupName -ZoneName "privatelink.vaultcore.azure.net" -Name $keyVaultName -RecordType A
$currentDnsRecordIpAddresses = $currentDnsZoneRecord.Records.Ipv4Address
# 4
$allIpAddresses = $($originalDnsRecordIpAddresses; $newIp)
foreach ($ip in $allIpAddresses) {
if ($currentDnsRecordIpAddresses -NotContains $ip) {
Add-AzPrivateDnsRecordConfig -RecordSet $currentDnsZoneRecord -Ipv4Address $ip
}
}
Set-AzPrivateDnsRecordSet -RecordSet $currentDnsZoneRecord