Skip to main content

Private Endpoints

Private endpoints are how I remove PaaS services from the public internet in production environments. The default for storage accounts, SQL databases, Key Vault, and most other PaaS services is a public endpoint — accessible from anywhere, controlled only by firewall rules. Private endpoints replace that with a private IP inside the VNet. Traffic never leaves the Microsoft backbone.

Private Endpoint vs Service Endpoint

The question I get asked is why not just use service endpoints, which are free. My answer:

FeaturePrivate EndpointService Endpoint
IP AddressPrivate IP in your VNetPublic IP (service)
RoutingTraffic stays in VNetRoutes via Azure backbone
ScopeSpecific resource instanceEntire service (all Storage accounts)
Data Exfiltration Protection✅ Strong (locked to VNet)⚠️ Limited (service level)
On-Premises Access✅ Via VPN/ExpressRoute❌ Not supported
DNSRequires private DNS zoneNot needed
Cost$$$ (~$7.50/month per endpoint + data)$ Free
Use CaseHigh security, complianceCost-effective, basic security

I use private endpoints for all production PaaS services. The cost (~$7.50/month per endpoint) is trivial compared to the compliance and security value.

How to Create a Private Endpoint

Prerequisites

  • Azure PaaS Resource: Storage account, SQL Database, Key Vault, etc.
  • VNet with Subnet: Dedicated subnet recommended
  • I disable public access after testing private connectivity — not before. Testing through a private endpoint before cutting over public access has saved me more than once.

Create Private Endpoint for Storage Account

Using Azure Portal

  1. Navigate to Storage Account > Networking > Private endpoint connections
  2. Click + Private endpoint
  3. Basics:
    • Name: pe-storage-prod
    • Region: Same as VNet
  4. Resource:
    • Resource type: Microsoft.Storage/storageAccounts
    • Resource: Select your storage account
    • Target sub-resource: blob (or file, table, queue)
  5. Virtual Network:
    • VNet: Select your VNet
    • Subnet: snet-privatelink (dedicated subnet)
    • Private IP configuration: Dynamic (recommended)
  6. DNS:
    • Integrate with private DNS zone: Yes
    • Private DNS Zone: privatelink.blob.core.windows.net (auto-created)
  7. Click Review + create

Using Azure CLI

# Variables
RG=\"rg-app-prod\"
VNET=\"vnet-app-prod\"
SUBNET=\"snet-privatelink\"
STORAGE_ACCOUNT=\"stappprodata\"
LOCATION=\"eastus\"

# Create subnet for private endpoints (if not exists)
az network vnet subnet create \
--resource-group $RG \
--vnet-name $VNET \
--name $SUBNET \
--address-prefixes 10.1.4.0/24 \
--disable-private-endpoint-network-policies true # Required!

# Get storage account resource ID
STORAGE_ID=$(az storage account show \
--resource-group $RG \
--name $STORAGE_ACCOUNT \
--query id -o tsv)

# Create private endpoint
az network private-endpoint create \
--resource-group $RG \
--name pe-storage-blob-prod \
--vnet-name $VNET \
--subnet $SUBNET \
--private-connection-resource-id $STORAGE_ID \
--group-id blob \ # blob, file, table, queue, dfs
--connection-name pe-storage-connection \
--location $LOCATION

# Get private IP address
az network private-endpoint show \
--resource-group $RG \
--name pe-storage-blob-prod \
--query \"customDnsConfigs[0].ipAddresses[0]\" -o tsv

# Create private DNS zone
az network private-dns zone create \
--resource-group $RG \
--name privatelink.blob.core.windows.net

# Link DNS zone to VNet
az network private-dns link vnet create \
--resource-group $RG \
--zone-name privatelink.blob.core.windows.net \
--name dns-link-vnet-app \
--virtual-network $VNET \
--registration-enabled false

# Create DNS A record for storage account
az network private-dns record-set a create \
--resource-group $RG \
--zone-name privatelink.blob.core.windows.net \
--name $STORAGE_ACCOUNT

az network private-dns record-set a add-record \
--resource-group $RG \
--zone-name privatelink.blob.core.windows.net \
--record-set-name $STORAGE_ACCOUNT \
--ipv4-address <private-ip-from-above>

Terraform Example

# Resource Group
resource \"azurerm_resource_group\" \"app\" {\n name = \"rg-app-prod\"
location = \"East US\"
}

# VNet
resource \"azurerm_virtual_network\" \"app\" {\n name = \"vnet-app-prod\"
address_space = [\"10.1.0.0/16\"]
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
}

# Subnet for Application
resource \"azurerm_subnet\" \"app\" {\n name = \"snet-app\"
resource_group_name = azurerm_resource_group.app.name
virtual_network_name = azurerm_virtual_network.app.name
address_prefixes = [\"10.1.1.0/24\"]
}

# Subnet for Private Endpoints
resource \"azurerm_subnet\" \"privatelink\" {\n name = \"snet-privatelink\"
resource_group_name = azurerm_resource_group.app.name
virtual_network_name = azurerm_virtual_network.app.name
address_prefixes = [\"10.1.4.0/24\"]
}

# Storage Account
resource \"azurerm_storage_account\" \"app\" {\n name = \"stappprodata\"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
account_tier = \"Standard\"
account_replication_type = \"LRS\"

# Disable public access after private endpoint is configured
public_network_access_enabled = false

network_rules {
default_action = \"Deny\"
bypass = [\"AzureServices\"] # Allow trusted Azure services
}
}

# Private Endpoint for Storage Blob
resource \"azurerm_private_endpoint\" \"storage_blob\" {\n name = \"pe-storage-blob-prod\"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
subnet_id = azurerm_subnet.privatelink.id

private_service_connection {
name = \"pe-storage-connection\"
private_connection_resource_id = azurerm_storage_account.app.id
is_manual_connection = false
subresource_names = [\"blob\"] # blob, file, table, queue, dfs
}

private_dns_zone_group {
name = \"privatelink-dns-zone-group\"
private_dns_zone_ids = [azurerm_private_dns_zone.storage_blob.id]
}
}

# Private DNS Zone for Storage Blob
resource \"azurerm_private_dns_zone\" \"storage_blob\" {\n name = \"privatelink.blob.core.windows.net\"
resource_group_name = azurerm_resource_group.app.name
}

# Link DNS Zone to VNet
resource \"azurerm_private_dns_zone_virtual_network_link\" \"storage_blob\" {\n name = \"dns-link-vnet-app\"
resource_group_name = azurerm_resource_group.app.name
private_dns_zone_name = azurerm_private_dns_zone.storage_blob.name
virtual_network_id = azurerm_virtual_network.app.id
registration_enabled = false
}

# Private Endpoint for SQL Database
resource \"azurerm_mssql_server\" \"app\" {\n name = \"sql-app-prod\"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
version = \"12.0\"
administrator_login = \"sqladmin\"
administrator_login_password = var.sql_admin_password

public_network_access_enabled = false # Disable public access
}

resource \"azurerm_private_endpoint\" \"sql\" {\n name = \"pe-sql-prod\"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
subnet_id = azurerm_subnet.privatelink.id

private_service_connection {
name = \"pe-sql-connection\"
private_connection_resource_id = azurerm_mssql_server.app.id
is_manual_connection = false
subresource_names = [\"sqlServer\"]
}

private_dns_zone_group {
name = \"sql-dns-zone-group\"
private_dns_zone_ids = [azurerm_private_dns_zone.sql.id]
}
}

resource \"azurerm_private_dns_zone\" \"sql\" {\n name = \"privatelink.database.windows.net\"
resource_group_name = azurerm_resource_group.app.name
}

resource \"azurerm_private_dns_zone_virtual_network_link\" \"sql\" {\n name = \"sql-dns-link\"
resource_group_name = azurerm_resource_group.app.name
private_dns_zone_name = azurerm_private_dns_zone.sql.name
virtual_network_id = azurerm_virtual_network.app.id
}

# Private Endpoint for Key Vault
resource \"azurerm_key_vault\" \"app\" {\n name = \"kv-app-prod\"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = \"standard\"

public_network_access_enabled = false

network_acls {
default_action = \"Deny\"
bypass = \"AzureServices\"
}
}

resource \"azurerm_private_endpoint\" \"keyvault\" {\n name = \"pe-keyvault-prod\"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
subnet_id = azurerm_subnet.privatelink.id

private_service_connection {
name = \"pe-kv-connection\"
private_connection_resource_id = azurerm_key_vault.app.id
is_manual_connection = false
subresource_names = [\"vault\"]
}

private_dns_zone_group {
name = \"kv-dns-zone-group\"
private_dns_zone_ids = [azurerm_private_dns_zone.keyvault.id]
}
}

resource \"azurerm_private_dns_zone\" \"keyvault\" {\n name = \"privatelink.vaultcore.azure.net\"
resource_group_name = azurerm_resource_group.app.name
}

resource \"azurerm_private_dns_zone_virtual_network_link\" \"keyvault\" {\n name = \"kv-dns-link\"
resource_group_name = azurerm_resource_group.app.name
private_dns_zone_name = azurerm_private_dns_zone.keyvault.name
virtual_network_id = azurerm_virtual_network.app.id
}

Private DNS Zones for Common Services

ServicePrivate DNS Zone Name
Storage - Blobprivatelink.blob.core.windows.net
Storage - Fileprivatelink.file.core.windows.net
Storage - Queueprivatelink.queue.core.windows.net
Storage - Tableprivatelink.table.core.windows.net
SQL Databaseprivatelink.database.windows.net
Key Vaultprivatelink.vaultcore.azure.net
Cosmos DBprivatelink.documents.azure.com
Event Hubsprivatelink.servicebus.windows.net
Service Busprivatelink.servicebus.windows.net
App Serviceprivatelink.azurewebsites.net
Container Registryprivatelink.azurecr.io
Cognitive Servicesprivatelink.cognitiveservices.azure.com

CI/CD Integration

Deploy Private Endpoints with GitHub Actions

name: Deploy Private Endpoints

on:
push:
paths:
- 'terraform/networking/private-endpoints/**'

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}

- name: Setup Terraform
uses: hashicorp/setup-terraform@v2

- name: Terraform Init
run: |\n cd terraform/networking/private-endpoints
terraform init

- name: Terraform Plan
run: terraform plan -out=tfplan

- name: Terraform Apply
run: terraform apply -auto-approve tfplan

Validate Private Endpoint Connectivity

- name: Test Private Endpoint Connectivity
run: |\n # Deploy test VM in same VNet
VM_IP=$(az vm show -d \\\n --resource-group rg-app-prod \\\n --name vm-test \\\n --query privateIps -o tsv)

# Test DNS resolution
az vm run-command invoke \\\n --resource-group rg-app-prod \\\n --name vm-test \\\n --command-id RunShellScript \\\n --scripts \"nslookup stappprodata.blob.core.windows.net\"

# Should resolve to private IP (10.1.4.x), not public IP

Best Practices

1. Use Dedicated Subnet for Private Endpoints

Create separate subnet:

resource \"azurerm_subnet\" \"privatelink\" {\n  name             = \"snet-privatelink\"
address_prefixes = [\"10.1.4.0/24\"]
# Disable network policies (required for private endpoints)
}

Benefits:

  • Easier to apply NSG rules
  • Centralized IP address management
  • Supports hundreds of private endpoints in one subnet

2. Disable Public Access After Private Endpoint

Lock down PaaS services:

public_network_access_enabled = false

network_rules {
default_action = \"Deny\"
bypass = [\"AzureServices\"] # Allow trusted services only
}

3. Centralize Private DNS Zones

Hub-spoke architecture:

  • Create private DNS zones in hub VNet resource group
  • Link zones to all spoke VNets
  • Reduces duplication and management overhead
# In hub subscription
resource \"azurerm_private_dns_zone\" \"storage_blob\" {\n name = \"privatelink.blob.core.windows.net\"
resource_group_name = \"rg-network-hub\"
}

# Link to spoke VNets
resource \"azurerm_private_dns_zone_virtual_network_link\" \"spoke1\" {\n private_dns_zone_name = azurerm_private_dns_zone.storage_blob.name
virtual_network_id = azurerm_virtual_network.spoke1.id
registration_enabled = false
}

4. Use Azure Policy to Enforce Private Endpoints

# Deny creation of Storage accounts without private endpoints
# Built-in policy: \"Storage accounts should use private link\"
resource \"azurerm_subscription_policy_assignment\" \"require_private_link_storage\" {\n name = \"require-private-link-storage\"
subscription_id = data.azurerm_subscription.current.id
policy_definition_id = \"/providers/Microsoft.Authorization/policyDefinitions/6edd7eda-6dd8-40f7-810d-67160c639cd9\"

parameters = jsonencode({
effect = { value = \"Deny\" }
})
}

5. Monitor Private Endpoint Health

// Private endpoint connection status
AzureDiagnostics
| where ResourceType == \"PRIVATEENDPOINTS\"
| where TimeGenerated > ago(24h)
| summarize Count = count() by ResourceId, OperationName, resultType_s
| order by Count desc

Common Use Cases

1. Secure Storage Account Access

# Application VMs access storage only via private endpoint
resource \"azurerm_storage_account\" \"app\" {\n public_network_access_enabled = false

network_rules {
default_action = \"Deny\"
bypass = [\"AzureServices\"]
}
}

# VMs in same VNet automatically use private endpoint
# DNS resolution: stappprodata.blob.core.windows.net → 10.1.4.10 (private IP)

2. On-Premises Access to Azure PaaS

On-Premises Network

│ ExpressRoute / VPN

Azure Hub VNet
│ Peering

Azure Spoke VNet


Private Endpoint (10.1.4.10)


SQL Database (private access)

DNS Configuration:

  • On-prem DNS forwards *.database.windows.net to Azure DNS (168.63.129.16)
  • Azure DNS resolves via private DNS zone
  • Returns private IP (10.1.4.10)

3. Cross-Region Private Endpoint

# Storage account in East US
resource \"azurerm_storage_account\" \"east\" {\n location = \"East US\"
}

# Private endpoint in West US VNet
resource \"azurerm_private_endpoint\" \"west\" {\n location = \"West US\"
subnet_id = azurerm_subnet.west_privatelink.id

private_service_connection {
private_connection_resource_id = azurerm_storage_account.east.id
subresource_names = [\"blob\"]
}
}

# Traffic routes over Microsoft backbone (no internet)

Things to Avoid

Don't create private endpoints without private DNS zones (DNS won't resolve) ❌ Don't disable public access before testing private endpoint connectivity ❌ Don't mix service endpoints and private endpoints for same service (confusing routing) ❌ Don't forget to link private DNS zone to all VNets that need access ❌ Don't enable --disable-private-endpoint-network-policies false (breaks private endpoints) ❌ Don't use dynamic DNS registration for private DNS zones (manual A records preferred) ❌ Don't create private endpoints in subnet with strict NSG rules (may block connectivity) ❌ Don't forget to update on-premises DNS forwarders for hybrid scenarios

Do use dedicated subnet for private endpoints ✅ Do create private DNS zones and link to VNets ✅ Do test connectivity before disabling public access ✅ Do centralize DNS zones in hub VNet (hub-spoke) ✅ Do use Azure Policy to enforce private endpoint usage ✅ Do enable diagnostic logging for private endpoint connections ✅ Do document DNS resolution flow for troubleshooting ✅ Do use private endpoints for production workloads

Troubleshooting

DNS Not Resolving to Private IP

# From VM in VNet, test DNS resolution
nslookup stappprodata.blob.core.windows.net

# Expected: 10.1.4.10 (private IP)
# If public IP returned, check:
# 1. Private DNS zone linked to VNet?
# 2. A record exists in DNS zone?
# 3. VM using Azure DNS (168.63.129.16)?

Fix:

# Check DNS zone link
az network private-dns link vnet list \
--resource-group rg-app-prod \
--zone-name privatelink.blob.core.windows.net

# Verify A record
az network private-dns record-set a list \
--resource-group rg-app-prod \
--zone-name privatelink.blob.core.windows.net

Cannot Connect to Service via Private Endpoint

# Check private endpoint status
az network private-endpoint show \
--resource-group rg-app-prod \
--name pe-storage-blob-prod \
--query \"provisioningState\"

# Should be: \"Succeeded\"

# Check connection state
az network private-endpoint show \
--resource-group rg-app-prod \
--name pe-storage-blob-prod \
--query \"privateLinkServiceConnections[0].privateLinkServiceConnectionState\"

# Should be: {\"status\": \"Approved\"}

Public Access Still Working

# Verify public network access is disabled
az storage account show \
--resource-group rg-app-prod \
--name stappprodata \
--query \"publicNetworkAccess\"

# Should be: \"Disabled\"

# If still accessible, check network rules
az storage account show \
--resource-group rg-app-prod \
--name stappprodata \
--query \"networkRuleSet.defaultAction\"

# Should be: \"Deny\"

Cost Considerations

Private Endpoint Pricing (East US):

  • Inbound data: $0.01/GB
  • Outbound data: $0.01/GB
  • Endpoint: $7.50/month ($0.01/hour)

Example (1 endpoint, 100GB/month):

  • Endpoint: $7.50
  • Data: 100GB × $0.01 × 2 (in + out) = $2.00
  • Total: ~$9.50/month

Cost Optimization:

  • Reuse endpoints across multiple services (when possible)
  • Use private endpoints only for production/sensitive workloads
  • Disable unused private endpoints (delete if not needed)

Advanced Scenarios

Private Endpoint Approval Workflow

For cross-subscription scenarios:

resource \"azurerm_private_endpoint\" \"cross_sub\" {\n  private_service_connection {
is_manual_connection = true # Requires approval
request_message = \"Please approve for production workload\"
}
}

# Resource owner must approve in Portal or CLI:
# Azure Portal > Resource > Private endpoint connections > Approve

Expose your own service via Private Link:

resource \"azurerm_private_link_service\" \"app\" {\n  name                = \"pls-app-service\"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location

load_balancer_frontend_ip_configuration_ids = [
azurerm_lb.app.frontend_ip_configuration[0].id
]

nat_ip_configuration {
name = \"primary\"
primary = true
subnet_id = azurerm_subnet.pls.id
}
}