---
cuppy_hub: azure
cuppy_path: patterns/private-endpoint-for-paas.md
cuppy_section: docs
cuppy_doc: HTCZ-GYKF
cuppy_doc_uuid: dd808d6b-ebbf-4fb2-afd3-7067d8b8786e
cuppy_permalink: https://cuppy.io/azure/d/HTCZ-GYKF
cuppy_version: 1
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/azure/docs/patterns/private-endpoint-for-paas
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: 5b92e319245db89512d2b922e2cc94c8f00d4143c6214ea7dfd7dc9bbc14e4be
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# 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`)

```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

| 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`).


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 107 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/azure/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
