Azure Private Endpoints: Locking Down PaaS Network Access


Executive Snapshot

Virtual Network«AzureVirtualNetwork»Spoke VNet[Workload subnet]«AzureDNS»Private DNS Zone[privatelink.*]Private Endpoint NICClient VM«AzurePrivateLink»Azure Private Link[Mapped resource]«AzureStorage»Storage Account[Public access disabled]resolve FQDNprivate IPprivate traffic
VNet reaches storage privately via Private Link, endpoint NIC and Private DNS.
Category Recommendation
Primary Use Case Eliminate public internet exposure for PaaS services
Supported Services Storage, SQL Database, Key Vault, Service Bus, Cosmos DB, and 90+ others
Network Scope Single VNet subnet → private IP via NIC
DNS Strategy Azure Private DNS Zones (mandatory for name resolution)
Cost Driver Billed per endpoint/hour + data processing; budget ~$8–10 USD/endpoint/month
Complementary Controls Service Endpoints, NSGs, Private DNS Resolver, Azure Firewall
Identity Complement Combine with Azure AD RBAC — network isolation alone is insufficient
Pitfall #1 Forgetting to disable public network access on the resource after endpoint creation
Pitfall #2 DNS misconfiguration causes resolution to fall back to public FQDN

TL;DR

flowchart TD
    Client([Client in VNet]) --> DNS[Query PaaS FQDN]
    DNS --> PDZ[Private DNS Zone
resolves to private IP] PDZ --> PE[Private Endpoint NIC
in subnet] PE --> Link[Azure Private Link] Link --> PaaS[PaaS service] PaaS --> Block{Public access?} Block -->|Disabled| Allow([Traffic served privately]) Pub([Internet client]) --> Block Block -->|Blocked| Deny([Connection refused]) classDef step fill:#1f6feb,stroke:#58a6ff,color:#fff; classDef gate fill:#3a2d00,stroke:#d29922,color:#fff; classDef ok fill:#238636,stroke:#2ea043,color:#fff; classDef bad fill:#8b1a1a,stroke:#f85149,color:#fff; class DNS,PDZ,PE,Link,PaaS step; class Block gate; class Client,Allow ok; class Pub,Deny bad;
Private DNS resolves the FQDN to a private endpoint while public access stays blocked.
  • Azure Private Endpoints project a PaaS service into your VNet as a private IP address, so traffic never traverses the public internet.
  • Private DNS Zones are non-optional — without correct DNS wiring, clients resolve the public FQDN and bypass the endpoint entirely.
  • Disabling public network access on the target resource is a separate, required step that most tutorials gloss over.
  • NSGs on the private endpoint subnet are now supported (since 2021) and should be used to add a defence-in-depth layer.
  • A single private endpoint covers one resource instance; scale-out means one endpoint per resource, per region.

Introduction

Azure PaaS services — Storage Accounts, Azure SQL Database, Key Vault, Service Bus, and dozens more — are, by default, reachable over the public internet. Authentication protects them, but your data still travels across Microsoft's public endpoints, exposed to network-level threats, misconfigured firewall rules, and accidental public access. Regulatory frameworks like PCI-DSS, HIPAA, and ISO 27001 increasingly require that sensitive workloads communicate over private networks only.

Azure Private Endpoints solve this cleanly. Rather than relying on Service Endpoints (which keep traffic on the Microsoft backbone but still use public IPs), Private Endpoints inject a dedicated Network Interface Card (NIC) with a private IP directly into your Virtual Network. The PaaS service appears as if it were just another VM on your subnet. Combined with proper DNS configuration and public access disabled at the resource level, you achieve genuine network isolation — no public IP reachability, full audit trail, and compatibility with on-premises connectivity via VPN or ExpressRoute.

This tutorial walks you through the complete, production-grade implementation: endpoint creation, DNS zone linkage, NSG hardening, and verification — using both the Azure CLI and Bicep for repeatability.


Prerequisites

  • An active Azure subscription with Contributor or Owner rights on the target resource group
  • Azure CLI ≥ 2.55 (az --version) or Azure PowerShell ≥ 10.x
  • An existing Virtual Network with at least one subnet (CIDR /27 or larger recommended for endpoint subnets)
  • Target PaaS resource already created (this tutorial uses an Azure Storage Account and Azure SQL Database as examples)
  • az bicep installed (az bicep install) if following the IaC path
  • Basic familiarity with Azure networking concepts (VNets, subnets, DNS)

How Azure Private Endpoints Work

Before touching the CLI, it's worth internalising the traffic flow. The diagram below shows what happens when a VM inside your VNet queries a Storage Account after a private endpoint is configured.

sequenceDiagram
    participant VM as VM (10.0.1.10)
    participant DNS as Azure Private DNS Zone
(privatelink.blob.core.windows.net) participant NIC as Private Endpoint NIC
(10.0.2.5) participant SA as Storage Account
(mystorageacct) VM->>DNS: Resolve mystorageacct.blob.core.windows.net DNS-->>VM: CNAME → mystorageacct.privatelink.blob.core.windows.net → 10.0.2.5 VM->>NIC: TCP :443 → 10.0.2.5 (stays inside VNet) NIC->>SA: Forwarded internally by Azure fabric SA-->>VM: Response (private path only)

The CNAME indirection is the key insight: Azure automatically adds a CNAME record (mystorageacct.blob.core.windows.netmystorageacct.privatelink.blob.core.windows.net) on the public DNS when a private endpoint exists. Your Private DNS Zone then provides the A record that maps the privatelink FQDN to the private IP. If your VNet is not linked to that Private DNS Zone, the resolver falls through to public DNS and returns the public IP — the endpoint is bypassed.


Section 1: Prepare Your Network

1.1 Create a Dedicated Subnet for Private Endpoints

Best practice is to isolate private endpoints in their own subnet. This simplifies NSG rules and policy targeting.

# Variables — adjust to your environment
RG="rg-networking-prod"
VNET="vnet-prod-eastus"
PE_SUBNET="snet-private-endpoints"
PE_SUBNET_CIDR="10.0.2.0/27"
LOCATION="eastus"

# Create the subnet (private endpoint network policies disabled by default post-2021,
# but we explicitly set it to ensure NSG support is enabled)
az network vnet subnet create \
  --resource-group "$RG" \
  --vnet-name "$VNET" \
  --name "$PE_SUBNET" \
  --address-prefixes "$PE_SUBNET_CIDR" \
  --private-endpoint-network-policies Enabled

Note on --private-endpoint-network-policies: Setting this to Enabled allows NSGs and UDRs to apply to the private endpoint NIC — this is what you want for defence-in-depth. Prior to mid-2021, this flag had to be Disabled for endpoints to function at all; that limitation is now lifted.

1.2 Attach a Restrictive NSG

NSG_NAME="nsg-private-endpoints"

# Create NSG
az network nsg create \
  --resource-group "$RG" \
  --name "$NSG_NAME" \
  --location "$LOCATION"

# Allow HTTPS from the workload subnet only (10.0.1.0/24 = app tier)
az network nsg rule create \
  --resource-group "$RG" \
  --nsg-name "$NSG_NAME" \
  --name "Allow-AppTier-HTTPS" \
  --priority 100 \
  --protocol Tcp \
  --source-address-prefixes "10.0.1.0/24" \
  --source-port-ranges "*" \
  --destination-address-prefixes "10.0.2.0/27" \
  --destination-port-ranges 443 \
  --access Allow \
  --direction Inbound

# Deny everything else inbound
az network nsg rule create \
  --resource-group "$RG" \
  --nsg-name "$NSG_NAME" \
  --name "Deny-All-Inbound" \
  --priority 4096 \
  --protocol "*" \
  --source-address-prefixes "*" \
  --destination-address-prefixes "*" \
  --destination-port-ranges "*" \
  --access Deny \
  --direction Inbound

# Associate NSG with the endpoint subnet
az network vnet subnet update \
  --resource-group "$RG" \
  --vnet-name "$VNET" \
  --name "$PE_SUBNET" \
  --network-security-group "$NSG_NAME"

Section 2: Create the Private Endpoint

2.1 Storage Account Private Endpoint (CLI)

SA_NAME="mystorageacctprod"
SA_RG="rg-storage-prod"
PE_NAME="pe-storage-blob-prod"

# Get the Storage Account resource ID
SA_ID=$(az storage account show \
  --name "$SA_NAME" \
  --resource-group "$SA_RG" \
  --query "id" -o tsv)

# Create the private endpoint targeting the blob sub-resource
az network private-endpoint create \
  --resource-group "$RG" \
  --name "$PE_NAME" \
  --location "$LOCATION" \
  --vnet-name "$VNET" \
  --subnet "$PE_SUBNET" \
  --private-connection-resource-id "$SA_ID" \
  --group-id "blob" \
  --connection-name "conn-storage-blob-prod"

Common --group-id values per service:

Service Sub-resource (--group-id)
Storage (Blob) blob
Storage (File) file
Azure SQL Database sqlServer
Key Vault vault
Service Bus namespace
Cosmos DB (SQL) Sql
Azure Container Registry registry

2.2 Verify the Endpoint and Private IP

az network private-endpoint show \
  --resource-group "$RG" \
  --name "$PE_NAME" \
  --query "{State:provisioningState, PrivateIP:customDnsConfigs[0].ipAddresses[0]}" \
  -o table

Expected output:

State      PrivateIP
---------  -----------
Succeeded  10.0.2.4

Section 3: Configure Private DNS (The Critical Step)

3.1 Create the Private DNS Zone

DNS_ZONE="privatelink.blob.core.windows.net"

az network private-dns zone create \
  --resource-group "$RG" \
  --name "$DNS_ZONE"

3.2 Link the DNS Zone to Your VNet

az network private-dns link vnet create \
  --resource-group "$RG" \
  --zone-name "$DNS_ZONE" \
  --name "link-vnet-prod" \
  --virtual-network "$VNET" \
  --registration-enabled false

Set --registration-enabled false for private endpoint DNS zones. Auto-registration is for VM hostnames, not PaaS endpoints, and enabling it here creates unnecessary noise.

3.3 Create the DNS Zone Group (Auto-wires the A Record)

The DNS Zone Group is the mechanism that automatically creates and manages the A record inside your Private DNS Zone whenever the endpoint is provisioned or updated. This is strongly preferred over manually creating A records.

az network private-endpoint dns-zone-group create \
  --resource-group "$RG" \
  --endpoint-name "$PE_NAME" \
  --name "zonegroup-blob" \
  --private-dns-zone "$DNS_ZONE" \
  --zone-name "privatelink-blob"

Verify the A record was created automatically:

az network private-dns record-set a list \
  --resource-group "$RG" \
  --zone-name "$DNS_ZONE" \
  -o table

Section 4: Disable Public Network Access

This is the step that most teams miss. Creating a private endpoint alone does not block public access — you must explicitly disable the public endpoint on the resource.

Storage Account

az storage account update \
  --name "$SA_NAME" \
  --resource-group "$SA_RG" \
  --public-network-access Disabled

Azure SQL Database (Server Level)

SQL_SERVER="sql-prod-eastus"
SQL_RG="rg-sql-prod"

az sql server update \
  --name "$SQL_SERVER" \
  --resource-group "$SQL_RG" \
  --enable-public-network false

Key Vault

KV_NAME="kv-prod-eastus"
KV_RG="rg-keyvault-prod"

az keyvault update \
  --name "$KV_NAME" \
  --resource-group "$KV_RG" \
  --public-network-access Disabled

Section 5: Infrastructure as Code with Bicep

For repeatable, auditable deployments, the following Bicep module provisions a private endpoint, links the DNS zone, and disables public access atomically.

// modules/private-endpoint.bicep
// Deploy: az deployment group create -g <rg> -f main.bicep

@description('Name of the private endpoint')
param privateEndpointName string

@description('Azure region for deployment')
param location string = resourceGroup().location

@description('Resource ID of the PaaS resource to connect')
param targetResourceId string

@description('Sub-resource type (e.g. blob, vault, sqlServer)')
param groupId string

@description('Resource ID of the subnet for the endpoint NIC')
param subnetId string

@description('Resource ID of the Private DNS Zone')
param privateDnsZoneId string

@description('Short label used in DNS zone group and connection names')
param connectionLabel string

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-09-01' = {
  name: privateEndpointName
  location: location
  properties: {
    subnet: {
      id: subnetId
    }
    privateLinkServiceConnections: [
      {
        name: 'conn-${connectionLabel}'
        properties: {
          privateLinkServiceId: targetResourceId
          groupIds: [
            groupId
          ]
        }
      }
    ]
  }
}

resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-09-01' = {
  parent: privateEndpoint
  name: 'dnsZoneGroup-${connectionLabel}'
  properties: {
    privateDnsZoneConfigs: [
      {
        name: 'config-${connectionLabel}'
        properties: {
          privateDnsZoneId: privateDnsZoneId
        }
      }
    ]
  }
}

output privateEndpointId string = privateEndpoint.id
output privateEndpointNicId string = privateEndpoint.properties.networkInterfaces[0].id

Call this module from your main.bicep for each PaaS resource that needs isolation, passing in the appropriate groupId and targetResourceId.


Section 6: Verify End-to-End Connectivity

Run these checks from a VM or Azure Bastion session inside the VNet.

# 1. DNS resolution — should return the private IP (10.0.2.x), NOT a public IP
nslookup mystorageacctprod.blob.core.windows.net

# Expected output snippet:
# Name:    mystorageacctprod.privatelink.blob.core.windows.net
# Address: 10.0.2.4

# 2. Connectivity test on port 443
curl -o /dev/null -s -w "%{http_code}" \
  https://mystorageacctprod.blob.core.windows.net

# Should return 200 or 400 (auth required), NOT connection refused

# 3. Confirm public access is blocked from OUTSIDE the VNet
# Run this from your local machine — expect a connection error or 403
curl --max-time 10 https://mystorageacctprod.blob.core.windows.net
# Expected: curl: (7) Failed to connect ... or HTTP 403

PowerShell equivalent (from an on-premises machine connected via VPN/ExpressRoute):

# Resolve DNS — must return private IP
Resolve-DnsName -Name "mystorageacctprod.blob.core.windows.net" -Type A |
    Select-Object Name, IPAddress

# Test TCP connectivity
Test-NetConnection -ComputerName "mystorageacctprod.blob.core.windows.net" `
                   -Port 443 |
    Select-Object ComputerName, RemoteAddress, TcpTestSucceeded

Architecture Overview: Hub-and-Spoke with Centralised Private Endpoints

Enterprise environments typically centralise private endpoints in a shared-services hub VNet and resolve DNS via Azure Private DNS Resolver or a custom DNS forwarder. Here is the recommended topology:

graph TD
    subgraph On-Premises
        OPN[Corporate Network]
    end

    subgraph Hub VNet ["Hub VNet (10.0.0.0/16)"]
        GW[VPN / ExpressRoute Gateway]
        FW[Azure Firewall]
        DNS[Private DNS Resolver
Inbound Endpoint] PE_SUBNET_HUB["Private Endpoint Subnet
(10.0.2.0/27)"] PE1[PE: Storage] PE2[PE: SQL] PE3[PE: Key Vault] DNS_ZONE1[Private DNS Zone
privatelink.blob.core.windows.net] DNS_ZONE2[Private DNS Zone
privatelink.database.windows.net] DNS_ZONE3[Private DNS Zone
privatelink.vaultcore.azure.net] end subgraph Spoke1 ["Spoke VNet A — App Tier"] APP[App VMs / AKS] end subgraph Spoke2 ["Spoke VNet B — Data Tier"] DATA[Data Services] end OPN -- ExpressRoute/VPN --> GW GW --> FW FW --> PE_SUBNET_HUB PE_SUBNET_HUB --> PE1 PE_SUBNET_HUB --> PE2 PE_SUBNET_HUB --> PE3 DNS --> DNS_ZONE1 DNS --> DNS_ZONE2 DNS --> DNS_ZONE3 APP -- VNet Peering --> FW DATA -- VNet Peering --> FW OPN -- DNS queries --> DNS

Key design decision: DNS zones are linked to the Hub VNet only. Spokes resolve DNS via the Private DNS Resolver inbound endpoint (or a DNS forwarder VM), which forwards to Azure's internal resolver that is zone-aware. This avoids linking every zone to every spoke individually.


Troubleshooting

Error: "Private endpoint connection is in Pending state"

Cause: The target resource requires manual approval (common for cross-subscription or cross-tenant endpoints).
Fix: Approve the connection on the target resource:

# List pending connections
az network private-endpoint-connection list \
  --id "$SA_ID" \
  --query "[?properties.privateLinkServiceConnectionState.status=='Pending']" \
  -o table

# Approve
CONNECTION_ID=$(az network private-endpoint-connection list \
  --id "$SA_ID" \
  --query "[0].id" -o tsv)

az network private-endpoint-connection approve \
  --id "$CONNECTION_ID" \
  --description "Approved by network team"

Error: DNS resolves to public IP instead of private IP

Cause: VNet is not linked to the Private DNS Zone, or the DNS Zone Group was not created.
Fix:

# Check VNet links on the zone
az network private-dns link vnet list \
  --resource-group "$RG" \
  --zone-name "privatelink.blob.core.windows.net" \
  -o table

# Check the zone group exists on the endpoint
az network private-endpoint dns-zone-group show \
  --resource-group "$RG" \
  --endpoint-name "$PE_NAME" \
  --name "zonegroup-blob"

If using a custom DNS server (not Azure-provided DNS 168.63.129.16), ensure conditional forwarders are configured for each privatelink.* zone pointing to 168.63.129.16.

Error: "Public network access is disabled" when connecting from approved IP

Cause: After disabling public access, even the Azure Portal "Connect" blade and service firewall rules are bypassed — only private endpoint traffic is accepted.
Fix: Confirm you are connecting from within the VNet (or on-premises via VPN/ER). If you need break-glass public access, temporarily re-enable it:

az storage account update \
  --name "$SA_NAME" \
  --resource-group "$SA_RG" \
  --public-network-access Enabled

Error: NSG rules blocking private endpoint traffic

Cause: After enabling --private-endpoint-network-policies, NSG rules must explicitly permit traffic to the private IP.
Fix: Review effective security rules on the endpoint NIC:

NIC_ID=$(az network private-endpoint show \
  --resource-group "$RG" \
  --name "$PE_NAME" \
  --query "networkInterfaces[0].id" -o tsv)

az network nic show-effective-nsg \
  --ids "$NIC_ID" \
  -o table

Pitfalls to Avoid

  1. Creating the endpoint but not disabling public access. Your private endpoint is secure; the service's public endpoint is still wide open. Always run the --public-network-access Disabled update as part of the same deployment pipeline.

  2. Using Service Endpoints instead of Private Endpoints for compliance workloads. Service Endpoints keep traffic on the Microsoft backbone but the service still has a public IP. Auditors and PCI/HIPAA requirements typically mandate Private Endpoints where available.

  3. Skipping the DNS Zone Group in favour of manual A records. Manual A records don't update when the endpoint's private IP changes (e.g., after a re-deployment). Always use DNS Zone Groups.

  4. Deploying endpoints in spoke VNets in a hub-and-spoke topology. This forces you to link every DNS zone to every spoke. Centralise endpoints in the hub and route all traffic through Azure Firewall for inspection and logging.

  5. Forgetting on-premises DNS forwarders. Machines on-premises resolving via their corporate DNS server won't automatically use the Private DNS Zone. Configure a conditional forwarder on your on-premises DNS for each privatelink.* zone pointing at the Azure Private DNS Resolver inbound endpoint IP.

  6. One endpoint for multiple sub-resources. A single storage account has separate endpoints for blob, file, queue, table, and dfs. Each requires its own endpoint and DNS zone. Inventory all sub-resources before planning your endpoint count and IP allocation.

  7. Ignoring cost at scale. At ~$0.01/hour per endpoint plus data processing fees, 50 endpoints across 3 regions add up to ~$1,080/month before data charges. Use Azure Cost Management tags on endpoint resources to track spend.


Key Takeaways

  • Azure Private Endpoints assign a real private IP from your VNet to a PaaS service, making public-internet access structurally unnecessary rather than just discouraged.
  • DNS is the most failure-prone component — always use DNS Zone Groups and validate resolution from inside the VNet before declaring success.
  • Disabling public network access is a separate, mandatory action on each PaaS resource; the endpoint alone does not block the public endpoint.
  • NSGs now apply to private endpoint subnets (with --private-endpoint-network-policies Enabled), enabling you to restrict which source subnets can reach each endpoint.
  • In hub-and-spoke networks, centralise endpoints in the hub and resolve DNS through Azure Private DNS Resolver to avoid sprawling zone-to-VNet link configurations.
  • Bicep/ARM DNS Zone Groups automate A-record lifecycle — use them in IaC rather than maintaining manual DNS records that drift over time.
  • Plan your IP space carefully — each endpoint consumes one private IP; sub-resources (blob, file, queue) each need a separate endpoint.

Next Steps

  1. Enable Azure Policy — assign the built-in policy [Preview]: Configure Azure Storage accounts to use private DNS zones (Policy ID: 75973700-529f-4de2-b3f1-aa9a8bc4e56e) to automatically remediate non-compliant storage accounts at scale.
  2. Integrate with Microsoft Defender for Cloud — enable the "PaaS services should use private endpoints" security recommendation and set its severity to High in your secure score.
  3. Audit with Azure Monitor — create a diagnostic setting on each PaaS resource to stream logs to a Log Analytics Workspace, then query AzureDiagnostics to confirm zero traffic arriving via public endpoints.
  4. Test with Azure Network Watcher — use the Connection Troubleshoot tool to validate end-to-end private path reachability from your application VMs.
  5. Review the Private Link Pricing calculator at azure.microsoft.com/pricing/details/private-link and build endpoint cost into your landing zone chargeback model.

Related Articles

  • [Azure Private DNS Resolver: Replacing Custom DNS Forwarder VMs]/azure/azure-private-dns-resolver-guide/
  • [Azure Firewall Premium in Hub-and-Spoke: TLS Inspection and IDPS Configuration]/azure/azure-firewall-premium-hub-spoke/
  • [Zero-Trust Networking on Azure: Private Endpoints, Conditional Access, and Defender for Cloud]/azure/zero-trust-networking-azure/
Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *