Private endpoint for a PaaS service — subnet, private DNS zone, VNet link and zone group, with known-bad configurations
The handoff that puts a PaaS service (a storage account's blob endpoint here; the same shape for Key Vault, Container Registry, SQL or Cosmos DB) on a private IP — a VNet with private subnets, an undelegated endpoint subnet, the privatelink zone created once and linked to every client VNet, the zone group that manages the A record, then public access disabled — in Bicep that compiles, with the known-bad configurations, their exact symptoms, and which side is wrong.
markdownpatternv1HTCZ-GYKFstage: buildtrust: officialservice: networkingservice: storageinterface: iacinterface: cliprivate endpoint BicepMicrosoft.Network/privateEndpoints@2026-01-01privateDnsZoneGroups BicepMicrosoft.Network/privateDnsZones@2024-06-01virtualNetworkLinks Bicepprivatelink.blob environment().suffixes.storagegroupIds blobprivate endpoint subnet not delegateddefaultOutboundAccess false Bicepdisable public network access after private endpointhub spoke private DNS zoneprivate endpoint known-badwhich side is wrong private endpoint
Private endpoint for a PaaS service
Client side: the caller's VNet resolves the service's normal host name to the endpoint's private IP, through a private DNS zone linked to that VNet. Service side: the endpoint connection is approved, and the service's own firewall (public network access) is closed only after private access works. API versions with Bicep 0.48.1 types: Network 2026-01-01 (2026-03-01 has none), Private DNS 2024-06-01.
Known-good (main.bicep)
@description('Existing storage account to reach privately.')
param storageAccountName string
param location string = resourceGroup().location
@description('VNet address space; must not overlap any VNet or network it will peer or connect with.')
param addressPrefix string = '10.20.0.0/16'
resource storage 'Microsoft.Storage/storageAccounts@2026-06-01' existing = {
name: storageAccountName
}
resource vnet 'Microsoft.Network/virtualNetworks@2026-01-01' = {
name: 'vnet-app'
location: location
properties: {
addressSpace: {
addressPrefixes: [
addressPrefix
]
}
subnets: [
{
name: 'snet-app'
properties: {
addressPrefix: cidrSubnet(addressPrefix, 24, 0)
defaultOutboundAccess: false
delegations: [
{
name: 'webapp'
properties: {
serviceName: 'Microsoft.Web/serverFarms'
}
}
]
}
}
{
name: 'snet-private-endpoints'
properties: {
addressPrefix: cidrSubnet(addressPrefix, 24, 1)
defaultOutboundAccess: false
privateEndpointNetworkPolicies: 'Disabled'
}
}
]
}
}
// One zone per name, linked to every VNet that resolves it
resource blobZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
name: 'privatelink.blob.${environment().suffixes.storage}'
location: 'global'
}
resource blobZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
parent: blobZone
name: 'link-${vnet.name}'
location: 'global'
properties: {
virtualNetwork: {
id: vnet.id
}
registrationEnabled: false
resolutionPolicy: 'Default'
}
}
resource blobEndpoint 'Microsoft.Network/privateEndpoints@2026-01-01' = {
name: 'pe-${storage.name}-blob'
location: location
properties: {
subnet: {
id: vnet.properties.subnets[1].id
}
privateLinkServiceConnections: [
{
name: 'blob'
properties: {
privateLinkServiceId: storage.id
groupIds: [
'blob'
]
}
}
]
}
}
// Azure writes, updates and deletes the A record through the zone group
resource blobZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2026-01-01' = {
parent: blobEndpoint
name: 'default'
properties: {
privateDnsZoneConfigs: [
{
name: 'blob'
properties: {
privateDnsZoneId: blobZone.id
}
}
]
}
}
output privateEndpointId string = blobEndpoint.id
Deploy into the resource group that holds the storage account (existing resolves there), then verify from a resource in snet-app's VNet: nslookup <account>.blob.core.windows.net must return an address in 10.20.1.0/24. Only then close the public side: az storage account update -n <account> -g <rg> --public-network-access Disabled.
Adapting it:
- Other services: change
groupIdsand the zone name (privatelink.vaultcore.azure.netfor Key Vaultvault,privatelink.azurecr.ioplus the regional data zone for Container Registry,privatelink.database.windows.netfor SQLsqlServer:reference/networking/private-endpoints-and-dns.md). A storage account needs one endpoint per sub-resource it uses (blob,dfs,queue,table,file). - Hub and spoke: create each zone once in the hub and link it to every spoke; put endpoints in spokes or the hub, but never create a second zone with the same name.
- The app subnet is delegated to
Microsoft.Web/serverFarmsfor App Service VNet integration; the endpoint subnet must stay undelegated. Both subnets setdefaultOutboundAccess: falseexplicitly.
Known-bad
| Configuration | Symptom |
|---|---|
| Endpoint created, no zone link to the client's VNet | nslookup returns the public IP through a privatelink CNAME; with public access disabled, Storage 403 This request is not authorized to perform this operation. |
| Endpoint without a zone group; A record added by hand, endpoint later re-created | The record points at the old IP: timeouts |
A second privatelink.blob.core.windows.net zone in a spoke, linked to that spoke | That spoke resolves only the accounts in its zone; other accounts return NXDOMAIN |
| Custom DNS server in the VNet that doesn't forward to 168.63.129.16 | Public IP or NXDOMAIN from VMs; correct answers from the portal's tests |
| Endpoint subnet delegated | PrivateEndpointCreationNotAllowedAsSubnetIsDelegated |
| Public network access disabled before DNS was verified | Every caller outside the private path gets 403, including deployment pipelines and the portal's data views |
Firewall UDR 0.0.0.0/0 expected to inspect endpoint traffic | Traffic bypasses it through the endpoint's /32 route (reference/networking/nsgs-and-routes.md) |
| Cross-subscription or service-managed endpoint (Front Door, managed VNets) | Connection Pending until the target owner approves |
Which side is wrong
- The name resolves to a public IP or NXDOMAIN → client-side DNS: zone link, zone records, custom DNS forwarding.
- Resolves privately, times out → the path: connection state, NSGs, peering, VNet integration (
guides/networking/diagnose-connectivity.md). - Connects, 403 → the service: public access settings for callers outside, or data roles for the identity (
reference/storage/access-errors.md).