Skip to main content

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

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

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

  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
}

⚠️ 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.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
}
}