Private Endpoints
What is a Private Endpoint?
A Private Endpoint is a network interface that connects you privately and securely to Azure PaaS services using a private IP address from your VNet. It brings Azure services (like Storage, SQL Database, Key Vault) into your private network, eliminating exposure to the public internet.
Key Benefits:
- No Public Internet Exposure: Traffic stays on Microsoft backbone network
- Private IP Addressing: Service accessible via private IP in your VNet
- Protection Against Data Exfiltration: Lock down service to specific VNet
- Global Reach: Access services across regions via VNet peering
- Simplified Network Architecture: No need for service endpoints, NAT gateways, or public IPs
How it works:
┌──────────────────────────┐
│ Your VNet │
│ (10.1.0.0/16) │
│ │
│ ┌────────────────────┐ │
│ │ Private Endpoint │ │──────┐
│ │ IP: 10.1.2.10 │ │ │
│ └────────────────────┘ │ │ Private Link
│ ▲ │ │
│ │ │ ▼
│ ┌─────┴─────┐ │ ┌────────────────────┐
│ │ VM │ │ │ Azure PaaS Service │
│ └───────────┘ │ │ (Storage, SQL, etc)│
│ │ └────────────────────┘
└──────────────────────────┘
No Public IP needed!
Private Endpoint vs Service Endpoint
| Feature | Private Endpoint | Service Endpoint |
|---|---|---|
| IP Address | Private IP in your VNet | Public IP (service) |
| Routing | Traffic stays in VNet | Routes via Azure backbone |
| Scope | Specific resource instance | Entire service (all Storage accounts) |
| Data Exfiltration Protection | ✅ Strong (locked to VNet) | ⚠️ Limited (service level) |
| On-Premises Access | ✅ Via VPN/ExpressRoute | ❌ Not supported |
| DNS | Requires private DNS zone | Not needed |
| Cost | $$$ (~$7.50/month per endpoint + data) | $ Free |
| Use Case | High security, compliance | Cost-effective, basic security |
Best Practice: Use Private Endpoints for production workloads with compliance requirements
How to Create a Private Endpoint
Prerequisites
- Azure PaaS Resource: Storage account, SQL Database, Key Vault, etc.
- VNet with Subnet: Dedicated subnet recommended
- Disable Public Access: On target resource (after testing)
Create Private Endpoint for Storage Account
Using Azure Portal
- Navigate to Storage Account > Networking > Private endpoint connections
- Click + Private endpoint
- Basics:
- Name:
pe-storage-prod - Region: Same as VNet
- Name:
- Resource:
- Resource type:
Microsoft.Storage/storageAccounts - Resource: Select your storage account
- Target sub-resource:
blob(or file, table, queue)
- Resource type:
- Virtual Network:
- VNet: Select your VNet
- Subnet:
snet-privatelink(dedicated subnet) - Private IP configuration: Dynamic (recommended)
- DNS:
- Integrate with private DNS zone: Yes
- Private DNS Zone:
privatelink.blob.core.windows.net(auto-created)
- 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
| Service | Private DNS Zone Name |
|---|---|
| Storage - Blob | privatelink.blob.core.windows.net |
| Storage - File | privatelink.file.core.windows.net |
| Storage - Queue | privatelink.queue.core.windows.net |
| Storage - Table | privatelink.table.core.windows.net |
| SQL Database | privatelink.database.windows.net |
| Key Vault | privatelink.vaultcore.azure.net |
| Cosmos DB | privatelink.documents.azure.com |
| Event Hubs | privatelink.servicebus.windows.net |
| Service Bus | privatelink.servicebus.windows.net |
| App Service | privatelink.azurewebsites.net |
| Container Registry | privatelink.azurecr.io |
| Cognitive Services | privatelink.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
}
⚠️ Test first: Ensure all apps can reach service via private endpoint before blocking public access
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.netto 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
Private Link Service (Custom Service)
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
}
}