Most teams wildly underestimate this number and then wonder why their subnet is full before they’ve deployed half their workload.
Private endpoints look simple until you start planning at scale. You connect a PaaS service to your VNet, it gets a private IP, traffic stays off the public internet. Easy, right.
The catch: it’s not one private endpoint per service. Some services require multiple endpoints, one per sub-resource. Get this wrong early and you’ll be redesigning your subnet allocation halfway through a deployment.
Why some services need more than one private endpoint
A private endpoint doesn’t connect to a service, it connects to a sub-resource of that service. Sub-resources are the individual access planes of a PaaS resource.
Take Azure Storage. A single storage account exposes five distinct sub-resources: blob, file, queue, table, and dfs (Data Lake). If you want all five to stay private, you need five endpoints, each with its own private IP in your subnet.
Why not one endpoint for the whole service?
Each sub-resource has its own DNS namespace and service endpoint. A single private IP can’t resolve to multiple FQDNs like account.blob.core.windows.net and account.file.core.windows.net at the same time. Azure’s private DNS zones are scoped per sub-resource type — that’s why the split exists.
The same logic applies across the board:
| Service | Sub-resources | Max PEs |
|---|---|---|
| Storage Account | blob, file, queue, table, dfs | 5 |
| Cosmos DB | Sql, MongoDB, Cassandra, Gremlin, Table, Analytical, SqlDedicated, MongoDBDedicated | 8 |
| ML Workspace | amlworkspace, registry, dataFactory, storageBlob, storageFile, keyVault | 6 |
| Synapse Workspace | Sql, SqlOnDemand, Dev, Web, SqlOnDemandDev | 5 |
| Automation Account | Webhook, DSCAndHybridWorker | 2 |
| Purview Account | account, portal | 2 |
| Databricks Workspace | databricks_ui_api, browser_authentication | 2 |
| Key Vault, Service Bus, Event Hub, SQL Server, App Service, ACR | Single sub-resource each | 1 |
Now multiply across environments (dev, test, prod) and regions, and the number grows fast.
How to calculate what you need
There’s no built-in Azure tool that gives you this number upfront. You inventory your PaaS resources, map each one to its maximum PE count, and do the maths. For greenfield environments, you work from your target architecture.
Querying your subscription via Azure Resource Graph is the fastest approach. No iterating resource groups one by one.
Step 1. Authenticate against your tenant
If your organisation has conditional access or MFA enforced (and it should), you need to pass the tenant ID explicitly. Otherwise the token won’t be scoped correctly and downstream calls will fail.
Step 2. Query all PE-eligible resources
Use Azure Resource Graph to pull a count of each PE-eligible resource type in your subscription. Resource Graph is fast and doesn’t require iterating resource groups one by one.
Step 3. Multiply by the sub-resource count
For each resource type, multiply the instance count by the maximum number of PEs that type supports. Assume maximum unless you know for certain you won’t use a sub-resource — it’s safer to overestimate now than resize subnets later.
Step 4. Add Azure’s reserved IPs and a growth buffer
Azure reserves 5 IPs in every subnet (first 4 + last). Add 30% headroom on top. Then pick the smallest CIDR block that fits.
The Script
The PowerShell script below handles all of the above. It authenticates, queries Resource Graph across 50+ PE-eligible resource types, and outputs a full breakdown with a CIDR recommendation at the end.
# Authenticate with MFA/conditional access support
.\Get-PrivateEndpointEstimate.ps1 `
-TenantId "your-tenant-id" `
-SubscriptionId "your-subscription-id"# ============================================================
# Get-PrivateEndpointEstimate.ps1
# Estimates the maximum number of private endpoints needed
# in an Azure subscription, assuming all sub-resources are used.
# ============================================================
param(
[Parameter(Mandatory = $false)]
[string]$SubscriptionId,
[Parameter(Mandatory = $false)]
[string]$TenantId
)
# --- Azure Login ---
# When TenantId is provided (e.g. MFA/conditional access required), always
# authenticate explicitly against that tenant. Do not reuse an existing context
# as it may not have a valid token for that tenant.
if ($TenantId) {
Write-Host "Authenticating against tenant $TenantId..." -ForegroundColor Yellow
Connect-AzAccount -TenantId $TenantId -ErrorAction Stop | Out-Null
} else {
$ctx = Get-AzContext -ErrorAction SilentlyContinue
if (-not $ctx) {
Write-Host "Not logged in. Starting interactive login..." -ForegroundColor Yellow
Connect-AzAccount -ErrorAction Stop | Out-Null
}
}
# Set subscription context
if ($SubscriptionId) {
Write-Host "Setting subscription to: $SubscriptionId" -ForegroundColor Cyan
Set-AzContext -SubscriptionId $SubscriptionId -ErrorAction Stop | Out-Null
}
$ctx = Get-AzContext
$SubscriptionId = $ctx.Subscription.Id
$TenantId = $ctx.Tenant.Id # resolve in case it wasn't passed in
Write-Host "Subscription : $($ctx.Subscription.Name)" -ForegroundColor Cyan
Write-Host "Subscription ID : $SubscriptionId" -ForegroundColor Cyan
Write-Host "Tenant ID : $TenantId`n" -ForegroundColor Cyan
# --- Ensure Az.ResourceGraph is available ---
if (-not (Get-Module -ListAvailable -Name Az.ResourceGraph)) {
Write-Host "Az.ResourceGraph module not found. Installing..." -ForegroundColor Yellow
Install-Module Az.ResourceGraph -Force -Scope CurrentUser | Out-Null
}
Import-Module Az.ResourceGraph -ErrorAction Stop
# --- Max PE count per resource type ---
$peMap = [ordered]@{
"microsoft.storage/storageaccounts" = @{ Count = 5; SubResources = "blob, file, queue, table, dfs" }
"microsoft.sql/servers" = @{ Count = 1; SubResources = "sqlServer" }
"microsoft.sql/managedinstances" = @{ Count = 1; SubResources = "managedInstance" }
"microsoft.dbforpostgresql/servers" = @{ Count = 1; SubResources = "postgresqlServer" }
"microsoft.dbforpostgresql/flexibleservers" = @{ Count = 1; SubResources = "postgresqlServer" }
"microsoft.dbformysql/servers" = @{ Count = 1; SubResources = "mysqlServer" }
"microsoft.dbformysql/flexibleservers" = @{ Count = 1; SubResources = "mysqlServer" }
"microsoft.documentdb/databaseaccounts" = @{ Count = 8; SubResources = "Sql, MongoDB, Cassandra, Gremlin, Table, Analytical, SqlDedicated, MongoDBDedicated" }
"microsoft.keyvault/vaults" = @{ Count = 1; SubResources = "vault" }
"microsoft.keyvault/managedhsms" = @{ Count = 1; SubResources = "managedHsm" }
"microsoft.servicebus/namespaces" = @{ Count = 1; SubResources = "namespace" }
"microsoft.eventhub/namespaces" = @{ Count = 1; SubResources = "namespace" }
"microsoft.eventhub/clusters" = @{ Count = 1; SubResources = "namespace" }
"microsoft.relay/namespaces" = @{ Count = 1; SubResources = "namespace" }
"microsoft.notificationhubs/namespaces" = @{ Count = 1; SubResources = "namespace" }
"microsoft.web/sites" = @{ Count = 1; SubResources = "sites" }
"microsoft.web/hostingenvironments" = @{ Count = 1; SubResources = "hostingEnvironments" }
"microsoft.containerregistry/registries" = @{ Count = 1; SubResources = "registry" }
"microsoft.containerservice/managedclusters" = @{ Count = 1; SubResources = "management" }
"microsoft.cognitiveservices/accounts" = @{ Count = 1; SubResources = "account" }
"microsoft.openai/accounts" = @{ Count = 1; SubResources = "account" }
"microsoft.search/searchservices" = @{ Count = 1; SubResources = "searchService" }
"microsoft.insights/components" = @{ Count = 1; SubResources = "azuremonitor" }
"microsoft.monitor/accounts" = @{ Count = 1; SubResources = "prometheusMetrics" }
"microsoft.operationalinsights/workspaces" = @{ Count = 1; SubResources = "azuremonitor" }
"microsoft.machinelearningservices/workspaces" = @{ Count = 6; SubResources = "amlworkspace, registry, dataFactory, storageBlob, storageFile, keyVault" }
"microsoft.datafactory/factories" = @{ Count = 1; SubResources = "dataFactory" }
"microsoft.synapse/workspaces" = @{ Count = 5; SubResources = "Sql, SqlOnDemand, Dev, Web, SqlOnDemandDev" }
"microsoft.databricks/workspaces" = @{ Count = 2; SubResources = "databricks_ui_api, browser_authentication" }
"microsoft.kusto/clusters" = @{ Count = 1; SubResources = "cluster" }
"microsoft.appconfiguration/configurationstores" = @{ Count = 1; SubResources = "configurationStores" }
"microsoft.apimanagement/service" = @{ Count = 1; SubResources = "Gateway" }
"microsoft.logic/integrationaccounts" = @{ Count = 1; SubResources = "integrationAccount" }
"microsoft.logic/workflows" = @{ Count = 1; SubResources = "workflow" }
"microsoft.signalrservice/signalr" = @{ Count = 1; SubResources = "signalr" }
"microsoft.signalrservice/webpubsub" = @{ Count = 1; SubResources = "webpubsub" }
"microsoft.botservice/botservices" = @{ Count = 1; SubResources = "Bot" }
"microsoft.cache/redis" = @{ Count = 1; SubResources = "redisCache" }
"microsoft.cache/redisenterprise" = @{ Count = 1; SubResources = "redisEnterprise" }
"microsoft.batch/batchaccounts" = @{ Count = 1; SubResources = "batchAccount" }
"microsoft.automation/automationaccounts" = @{ Count = 2; SubResources = "Webhook, DSCAndHybridWorker" }
"microsoft.devices/iothubs" = @{ Count = 1; SubResources = "iotHub" }
"microsoft.devices/provisioningservices" = @{ Count = 1; SubResources = "iotDps" }
"microsoft.hdinsight/clusters" = @{ Count = 1; SubResources = "cluster" }
"microsoft.media/mediaservices" = @{ Count = 1; SubResources = "keydelivery" }
"microsoft.purview/accounts" = @{ Count = 2; SubResources = "account, portal" }
"microsoft.healthcareapis/workspaces" = @{ Count = 1; SubResources = "healthcareworkspace" }
"microsoft.digitaltwins/digitaltwinsinstances" = @{ Count = 1; SubResources = "API" }
"microsoft.eventgrid/topics" = @{ Count = 1; SubResources = "topic" }
"microsoft.eventgrid/domains" = @{ Count = 1; SubResources = "domain" }
"microsoft.network/privatelinkservices" = @{ Count = 1; SubResources = "privateLinkService" }
"microsoft.network/applicationgateways" = @{ Count = 0; SubResources = "N/A - not PE-enabled" }
}
# --- Query all resources in subscription ---
Write-Host "Querying resources via Azure Resource Graph..." -ForegroundColor Cyan
$typeList = ($peMap.Keys | ForEach-Object { "'$_'" }) -join ", "
$query = @"
Resources
| where subscriptionId =~ '$SubscriptionId'
| where type in~ ($typeList)
| summarize ResourceCount = count() by type
"@
# Pass both Subscription and ManagementGroup is not needed - Subscription is enough.
# The tenant token is already scoped correctly from Connect-AzAccount -TenantId.
$graphResults = Search-AzGraph -Query $query -Subscription $SubscriptionId -ErrorAction Stop
# Build lookup from graph results
$resourceCounts = @{}
foreach ($row in $graphResults) {
$resourceCounts[$row.type.ToLower()] = $row.ResourceCount
}
# --- Build full result table ---
$results = @()
$totalPE = 0
foreach ($type in $peMap.Keys) {
$info = $peMap[$type]
$resCount = if ($resourceCounts.ContainsKey($type)) { $resourceCounts[$type] } else { 0 }
$pePerRes = $info.Count
$totalForType = $resCount * $pePerRes
$totalPE += $totalForType
$results += [PSCustomObject]@{
"Resource Type" = $type
"Sub-Resources" = $info.SubResources
"Max PE / Resource" = $pePerRes
"Resources in Sub" = $resCount
"Total PEs for Type" = $totalForType
}
}
# --- Subnet sizing ---
$reservedIPs = 5
$headroom = [math]::Ceiling($totalPE * 0.30)
$totalNeeded = $totalPE + $reservedIPs + $headroom
function Get-CidrRecommendation([int]$ipCount) {
@(
@{ CIDR = "/27"; Usable = 27 },
@{ CIDR = "/26"; Usable = 59 },
@{ CIDR = "/25"; Usable = 123 },
@{ CIDR = "/24"; Usable = 251 },
@{ CIDR = "/23"; Usable = 507 },
@{ CIDR = "/22"; Usable = 1019 }
) | Where-Object { $_.Usable -ge $ipCount } | Select-Object -First 1 -ExpandProperty CIDR
}
$cidr = Get-CidrRecommendation -ipCount $totalNeeded
# --- Output ---
Write-Host "`n========================================================" -ForegroundColor Yellow
Write-Host " PRIVATE ENDPOINT ESTIMATE REPORT" -ForegroundColor Yellow
Write-Host " Subscription : $($ctx.Subscription.Name)" -ForegroundColor Yellow
Write-Host " Subscription ID : $SubscriptionId" -ForegroundColor Yellow
Write-Host "========================================================`n" -ForegroundColor Yellow
$found = $results | Where-Object { $_."Resources in Sub" -gt 0 } | Sort-Object "Total PEs for Type" -Descending
$notFound = $results | Where-Object { $_."Resources in Sub" -eq 0 } | Sort-Object "Resource Type"
Write-Host "PRESENT IN SUBSCRIPTION" -ForegroundColor Green
Write-Host "------------------------" -ForegroundColor Green
if ($found) {
$found | Format-Table "Resource Type", "Sub-Resources", "Max PE / Resource", "Resources in Sub", "Total PEs for Type" -AutoSize
} else {
Write-Host " None found.`n"
}
Write-Host "NOT PRESENT IN SUBSCRIPTION (for reference)" -ForegroundColor DarkGray
Write-Host "--------------------------------------------" -ForegroundColor DarkGray
$notFound | Format-Table "Resource Type", "Sub-Resources", "Max PE / Resource" -AutoSize
Write-Host "--------------------------------------------------------" -ForegroundColor Gray
Write-Host " SUMMARY" -ForegroundColor White
Write-Host "--------------------------------------------------------" -ForegroundColor Gray
Write-Host (" {0,-45} {1}" -f "Total PE-eligible resource types found:", ($found | Measure-Object).Count)
Write-Host (" {0,-45} {1}" -f "Total resource instances found:", ($found | Measure-Object -Property "Resources in Sub" -Sum).Sum)
Write-Host (" {0,-45} {1}" -f "Total private endpoints (maximum):", $totalPE)
Write-Host (" {0,-45} {1}" -f "Azure reserved IPs per subnet:", $reservedIPs)
Write-Host (" {0,-45} {1}" -f "30% growth headroom (IPs):", $headroom)
Write-Host (" {0,-45} {1}" -f "Total IPs needed:", $totalNeeded)
Write-Host ""
Write-Host (" {0,-45} {1}" -f "Recommended minimum subnet CIDR:", $cidr) -ForegroundColor Green
Write-Host "--------------------------------------------------------`n" -ForegroundColor GrayThe output splits into two tables: resources present in your subscription (sorted by PE count, worst offenders first) and resource types not yet present but supported. At the bottom you get the summary and a recommended subnet CIDR.
Important: The script assumes all sub-resources are in use for every resource instance. If you know you’re only using blob on a storage account, or only the Sql API on Cosmos DB, trim the $peMap hashtable accordingly. Overestimating is safe; underestimating causes operational pain.
Sizing the subnet
Total IPs needed = (PE count) + 5 reserved + 30% headroom
Then match to a CIDR block:
| CIDR | Usable IPs | Suitable for |
|---|---|---|
| /27 | 27 | Very small, single-workload |
| /26 | 59 | Small workload, few PaaS services |
| /25 | 123 | Medium workload |
| /24 | 251 | Standard landing zone per environment |
| /23 | 507 | Large environment, many services |
| /22 | 1,019 | Enterprise multi-workload hub |
| /21 | 2,043 | Very large or multi-region centralised PE subnet |
| /20 | 4,091 | Enterprise-wide shared PE subnet |
Most enterprise landing zones sit between a /25 and /24 per environment. Hub-and-spoke with centralised private endpoints, you’ll want a /23 or /22.
One last thing: keep your private endpoint subnet separate from your workload subnets. Mixing PE traffic with application or compute traffic makes NSG management a headache and complicates your DNS private zone scoping.
References
- What is a private endpoint? — Microsoft Docs. Covers the fundamentals of private link and sub-resource targeting.
- Private endpoint DNS configuration — Microsoft Docs. Explains why each sub-resource requires its own private DNS zone and therefore its own endpoint.
- Using private endpoints for Azure Storage — Microsoft Docs. Full list of storage sub-resources and their DNS zone mappings.
- Azure VNet address space and subnet sizing — Microsoft Docs. Covers the 5 reserved IPs rule and valid CIDR ranges.
- Private Link and DNS integration at scale — Cloud Adoption Framework. Recommended architecture for centralised private endpoint management in enterprise landing zones.
