Azure Private Endpoints Everywhere: Refactoring a Serverless Pipeline From APIM to PE-Only
Azure Private Endpoints Everywhere: Refactoring a Serverless Pipeline From APIM to PE-Only
At Nespresso we had a classic serverless email pipeline that worked – the same way the Concorde worked. Expensive, loud, but it got a message from point A to point B. When ticket NESNT-341 landed on my desk – "rewrite to private endpoint-only architecture before promoting to production" – it was the perfect opportunity to demonstrate what modern serverless integration without a public surface looks like.
This article is the technical write-up of the whole refactor, including the three places we got stuck that only resolved after two hours of Microsoft docs.
State Before: APIM as a Gateway for Everything
┌─────────────┐
│ Internet │
└──────┬──────┘
↓
┌─────────────┐
│ APIM │ ← single entry point
└──────┬──────┘
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
┌────────┐ ┌──────────┐ ┌──────────┐
│ Func A │ │ Storage │ │ Service │
│(public)│ │ (public) │ │ Bus │
└────────┘ └──────────┘ └──────────┘Problems:
- APIM was the only layer between the internet and the backends – if APIM dies, the whole pipeline dies
- Function App, Storage, Service Bus had public endpoints guarded only by IP allowlists (easy to bypass)
- APIM Premium SKU costs ~EUR 2 800/month for a multi-region deployment – overkill for purely internal traffic
Target state: zero public surface, everything over private endpoints, APIM kept only for genuinely external HTTP APIs.
Target Architecture
┌──────────────────────────────────┐
│ Hub VNet (priv DNS zones) │
└────────────────┬──────────────────┘
│ VNet peering
┌────────────────┴──────────────────┐
│ Spoke VNet │
│ ┌──────────────────────────────┐ │
│ │ Shared PE subnet (/27) │ │
│ │ ─ pe-storage │ │
│ │ ─ pe-keyvault │ │
│ │ ─ pe-servicebus │ │
│ │ ─ pe-log-analytics │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Function App subnet (/26) │ │
│ │ ─ Function Premium plan │ │
│ │ + VNet integration │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘All communication traffic (Function → Storage, Function → SB, Function → KV, Function → LA) flows through private endpoints inside the VNet. No hop through APIM, no hop through the internet.
Step 1: Shared PE Subnet (and Why It Matters)
The most common mistake in PE rollouts is per-service subnets. That explodes fast – 20 services means 20 subnets, 20 NSGs, 20 entries in the route table. A shared PE subnet consolidates this:
resource sharedPeSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-01-01' = {
name: 'snet-shared-pe'
parent: spokeVnet
properties: {
addressPrefix: '10.42.10.0/27' // 32 IPs, 27 usable after Azure reservations
privateEndpointNetworkPolicies: 'Disabled' // REQUIRED for PE
privateLinkServiceNetworkPolicies: 'Enabled'
networkSecurityGroup: { id: nsgShared.id }
}
}Critical property: privateEndpointNetworkPolicies: 'Disabled'. Without this you cannot deploy a PE into the subnet. Microsoft introduced this block historically because of NSG limitations – by 2024 NSGs on PE subnets do work, but the flag still has to be Disabled. If you forget, you get SubnetMissingRequiredDelegation. Trained at Nespresso the hard way.
Step 2: Function App Premium with VNet Integration
The Consumption plan has no VNet integration – which makes it unusable for PE-only. You have to be on Premium:
resource asp 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'asp-ccemail-prod'
location: location
sku: {
name: 'EP1'
tier: 'ElasticPremium'
}
properties: {
maximumElasticWorkerCount: 5
zoneRedundant: true // recommended for production
}
}
resource funcApp 'Microsoft.Web/sites@2024-04-01' = {
name: 'func-ccemail-prod'
location: location
kind: 'functionapp,linux'
properties: {
serverFarmId: asp.id
virtualNetworkSubnetId: functionSubnet.id // dedicated /26 subnet
vnetRouteAllEnabled: true // REQUIRED – otherwise DNS lookups go to Azure DNS
publicNetworkAccess: 'Disabled'
siteConfig: {
linuxFxVersion: 'DOTNET-ISOLATED|9.0'
vnetPrivatePortsCount: 2
ipSecurityRestrictions: [
{
action: 'Deny'
priority: 2147483647
name: 'Deny all'
description: 'Deny all access; private endpoint only'
}
]
}
}
}Three important parameters:
vnetRouteAllEnabled: true– iffalse, only RFC1918 traffic flows through the VNet, everything else (including DNS lookups) goes to the internet. This stuck us on the first production deploypublicNetworkAccess: 'Disabled'– closes off SCM/Kudu and the rest of the surfaceipSecurityRestrictionswith default deny – a safety net in casepublicNetworkAccessever gets reverted
Step 3: Private DNS Zones in the Hub (and Linking the Spokes)
This is the area where most projects fail and debugging takes days. The simple rule: DNS zones belong in the hub, the spoke just consumes them.
// In the hub subscription
resource sbPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
name: 'privatelink.servicebus.windows.net'
location: 'global'
}
// VNet links – one per spoke
resource sbDnsLinkSpoke 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
name: 'link-${spokeName}'
parent: sbPrivateDnsZone
location: 'global'
properties: {
virtualNetwork: { id: spokeVnetId }
registrationEnabled: false // PEs do not register dynamically
}
}Never create private DNS zones inside the spoke. The moment you have two zones for the same service (privatelink.blob.core.windows.net in the hub and a duplicate in the spoke), you get split-brain – some DNS lookups return the private IP, others the public one. Trained at Christie's during the DO-141 audit.
Step 4: Service Bus With Private Endpoint
resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = {
name: 'sb-ccemail-prod'
location: location
sku: { name: 'Premium', capacity: 1 }
properties: {
publicNetworkAccess: 'Disabled'
disableLocalAuth: true // Entra ID auth only, no connection strings
}
}
resource sbPe 'Microsoft.Network/privateEndpoints@2024-01-01' = {
name: 'pe-sb-ccemail'
location: location
properties: {
subnet: { id: sharedPeSubnet.id }
privateLinkServiceConnections: [
{
name: 'sb-connection'
properties: {
privateLinkServiceId: sb.id
groupIds: ['namespace']
}
}
]
}
}
// Auto-register A records in the hub DNS zone
resource sbPeDnsGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-01-01' = {
name: 'dnsgroup'
parent: sbPe
properties: {
privateDnsZoneConfigs: [
{
name: 'sb-config'
properties: {
privateDnsZoneId: sbPrivateDnsZoneId // resource ID from the hub subscription
}
}
]
}
}Premium SKU on Service Bus is mandatory – Standard/Basic have no VNet/PE support. The price jumps from ~EUR 10 to ~EUR 600/month per messaging unit. For us at Nespresso this was the single biggest cost driver of the whole refactor.
Step 5: Function App Connection Strings → Managed Identity
With disableLocalAuth: true on Service Bus, connection strings no longer work. You must go through managed identity:
// Program.cs (Function App)
var builder = FunctionsApplication.CreateBuilder(args);
builder.Services.AddAzureClients(clients =>
{
var sbNamespace = Environment.GetEnvironmentVariable("SERVICEBUS_NAMESPACE");
clients.AddServiceBusClientWithNamespace(sbNamespace)
.WithCredential(new ManagedIdentityCredential());
});
builder.Build().Run();And the role assignment in Bicep:
resource sbDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(sb.id, funcApp.id, 'Azure Service Bus Data Owner')
scope: sb
properties: {
principalId: funcApp.identity.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'090c5cfd-751d-490a-894a-3ce6f1109419' // Azure Service Bus Data Owner
)
}
}Step 6: Log Analytics via Azure Monitor Private Link Scope (AMPLS)
This is the trickiest piece and deserves its own article. For a PE-only Log Analytics setup you need an Azure Monitor Private Link Scope, attach the workspace to it, and create a separate PE for AMPLS:
resource ampls 'Microsoft.Insights/privateLinkScopes@2021-07-01-preview' = {
name: 'ampls-prod'
location: 'global'
properties: {
accessModeSettings: {
ingestionAccessMode: 'PrivateOnly'
queryAccessMode: 'PrivateOnly'
}
}
}
resource amplsScopedResource 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = {
name: 'la-scope'
parent: ampls
properties: {
linkedResourceId: logAnalyticsWorkspaceId
}
}
resource amplsPe 'Microsoft.Network/privateEndpoints@2024-01-01' = {
name: 'pe-ampls'
location: location
properties: {
subnet: { id: sharedPeSubnet.id }
privateLinkServiceConnections: [
{
name: 'ampls-connection'
properties: {
privateLinkServiceId: ampls.id
groupIds: ['azuremonitor']
}
}
]
}
}ingestionAccessMode: 'PrivateOnly' means the workspace no longer accepts telemetry from the public internet – only from the VNet via AMPLS. This grounded us on the first deploy at Nespresso because a legacy on-prem agent was sending heartbeats over the internet. Keep ingest mode 'Open' until every source is on a private channel.
Cost: Before vs After
| Component | Before (APIM-fronted) | After (PE-only) | Delta |
|---|---|---|---|
| APIM Premium 2 units | ~EUR 2 800 | EUR 0 (removed) | -EUR 2 800 |
| Function App Consumption | ~EUR 5 | EUR 0 | -EUR 5 |
| Function App Premium EP1 zone-redundant | EUR 0 | ~EUR 360 | +EUR 360 |
| Service Bus Standard | ~EUR 10 | EUR 0 | -EUR 10 |
| Service Bus Premium 1 MU | EUR 0 | ~EUR 600 | +EUR 600 |
| Private Endpoints (5×) | EUR 0 | ~EUR 35 | +EUR 35 |
| AMPLS | EUR 0 | ~EUR 5 | +EUR 5 |
| Total | ~EUR 2 815 | ~EUR 1 000 | -EUR 1 815/month |
Plus the value gained: zero public surface, NIS2 Art. 21 compliance, and alignment with the internal security policy of "no public endpoints in prod".
Three Places We Got Stuck
- DNS resolution from inside the Function App –
vnetRouteAllEnabled: falsemeans DNS lookups flow through Azure DNS, which does not know about private zones. Fix: setvnetRouteAllEnabled: true - Shared PE subnet exhausted – we picked
/28out of laziness, ran out of IPs at the 15th PE. Migration to/26(with recreation of some PEs) took a day - AMPLS PrivateOnly mode broke legacy agents – an on-prem agent sending heartbeats over the internet stopped logging. Fix: keep ingest mode
'Open'for the duration of migration, flip after the agent rollout completes
Conclusion
A PE-only architecture in Azure is mature in 2026, but not trivial. A three-week refactor at Nespresso saved ~EUR 1 800/month, removed a critical SPOF in APIM, and brought the environment closer to NIS2 compliance. Cost: 60 engineering hours and three sharply-worded remarks during deployment.
If you are working on a similar migration and want to skip our three morning surprises, check out our cloud architecture services or reach out for an architecture review of your serverless stack.
About the author

Martin Rylko
Senior Cloud Architect & DevOps Engineer
14+ years in IT – from on-premises datacenters and Hyper-V clustering to cloud infrastructure on Microsoft Azure. I specialize in Landing Zones, IaC automation, Kubernetes and security compliance.
Frequently Asked Questions
Why replace APIM with private endpoints for internal communication?▾
What is a shared PE subnet and why is it worth using?▾
How do you handle DNS for private endpoints in a hub-spoke topology?▾
Function App Premium is more expensive than Consumption – when is it worth switching?▾
You might also like
Azure Functions Flex Consumption: When to Replace the Premium Plan in 2026
Flex Consumption is the third path between the Consumption and Premium plans for Azure Functions. A practical breakdown of the pricing model, VNet integration, and when to switch off the Premium plan.
ReadAKS Cilium NetworkPolicy: Migrating From Calico Without Production Downtime
Practical playbook for migrating an AKS cluster from the Calico Network Policy engine to Azure CNI Powered by Cilium. Zero-downtime procedure, eBPF benefits, and typical rollout traps.
ReadAzure Data Collection Rules: Ingestion-Time PII Masking in Log Analytics
How to mask personal data in Azure Monitor before it is written to Log Analytics. DCR transformations, KQL limitations, and a real lab from a retail environment.
Read