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

Created 21 Oct 2026 · Modified 23 Oct 2026 · 1 version

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 groupIds and the zone name (privatelink.vaultcore.azure.net for Key Vault vault, privatelink.azurecr.io plus the regional data zone for Container Registry, privatelink.database.windows.net for SQL sqlServer: 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/serverFarms for App Service VNet integration; the endpoint subnet must stay undelegated. Both subnets set defaultOutboundAccess: false explicitly.

Known-bad

ConfigurationSymptom
Endpoint created, no zone link to the client's VNetnslookup 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-createdThe record points at the old IP: timeouts
A second privatelink.blob.core.windows.net zone in a spoke, linked to that spokeThat 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.16Public IP or NXDOMAIN from VMs; correct answers from the portal's tests
Endpoint subnet delegatedPrivateEndpointCreationNotAllowedAsSubnetIsDelegated
Public network access disabled before DNS was verifiedEvery 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 trafficTraffic 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).

Read this document as markdown — the same bytes an agent receives.