Martin Rylko
  • Services
  • Blog
  • About
  • Contact
  • Get in Touch
Martin Rylko

Senior Cloud Architect & DevOps Engineer. Specializing in Microsoft Azure, IaC, Cloud Security and AI.

Navigation

  • Services
  • Blog
  • About
  • Contact

Collaboration

Looking for an experienced architect for your Azure project? Get in touch.

rylko@cloudmasters.cz

© 2026 Martin Rylko. All rights reserved.

Built in the cloud. Deployed via Azure Static Web Apps.

Home/Blog/Azure Private Endpoints Everywhere: Refactoring a Serverless Pipeline From APIM to PE-Only
All articlesČíst česky

Azure Private Endpoints Everywhere: Refactoring a Serverless Pipeline From APIM to PE-Only

6/25/2026 5 min
#Azure#Networking#Private Endpoints#Serverless#Security

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:

  1. APIM was the only layer between the internet and the backends – if APIM dies, the whole pipeline dies
  2. Function App, Storage, Service Bus had public endpoints guarded only by IP allowlists (easy to bypass)
  3. 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:

  1. vnetRouteAllEnabled: true – if false, only RFC1918 traffic flows through the VNet, everything else (including DNS lookups) goes to the internet. This stuck us on the first production deploy
  2. publicNetworkAccess: 'Disabled' – closes off SCM/Kudu and the rest of the surface
  3. ipSecurityRestrictions with default deny – a safety net in case publicNetworkAccess ever 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

ComponentBefore (APIM-fronted)After (PE-only)Delta
APIM Premium 2 units~EUR 2 800EUR 0 (removed)-EUR 2 800
Function App Consumption~EUR 5EUR 0-EUR 5
Function App Premium EP1 zone-redundantEUR 0~EUR 360+EUR 360
Service Bus Standard~EUR 10EUR 0-EUR 10
Service Bus Premium 1 MUEUR 0~EUR 600+EUR 600
Private Endpoints (5×)EUR 0~EUR 35+EUR 35
AMPLSEUR 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

  1. DNS resolution from inside the Function App – vnetRouteAllEnabled: false means DNS lookups flow through Azure DNS, which does not know about private zones. Fix: set vnetRouteAllEnabled: true
  2. Shared PE subnet exhausted – we picked /28 out of laziness, ran out of IPs at the 15th PE. Migration to /26 (with recreation of some PEs) took a day
  3. 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.

Tags:#Azure#Networking#Private Endpoints#Serverless#Security
LinkedInX / Twitter

About the author

Martin Rylko

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.

Email LinkedInFull profile

Frequently Asked Questions

Why replace APIM with private endpoints for internal communication?▾
APIM is great for external API exposure – authentication, rate limiting, transformations. For purely internal integration between Function Apps, Service Bus, and Storage, it is a more expensive and complex layer that adds latency and another failure point. The private endpoint pattern eliminates APIM as a middle layer, gives each service its own private IP in a shared subnet, and traffic flows directly inside the VNet with no public surface.
What is a shared PE subnet and why is it worth using?▾
A shared PE subnet is a dedicated subnet (typically /27 or /26) that hosts all private endpoints for a given spoke. Instead of per-service subnets (storage, KV, SB each with their own), everything lives in one subnet. Pros: simpler NSGs, simpler route tables, simpler capacity planning. Cons: a single subnet with many NICs requires careful tracking of free IP addresses.
How do you handle DNS for private endpoints in a hub-spoke topology?▾
Centrally in the hub. Create Private DNS Zones (privatelink.blob.core.windows.net, privatelink.servicebus.windows.net, etc.) in the hub subscription and link every spoke VNet to them via VNet links. The spoke owns no zones – it only consumes the hub. That gives uniform private endpoint resolution across the entire landing zone and avoids duplicate zones, which lead to split-brain DNS.
Function App Premium is more expensive than Consumption – when is it worth switching?▾
Whenever you need VNet integration with private DNS resolution. The Consumption plan has no VNet integration – full stop. The Premium plan starts at roughly EUR 120/month for the first EP1 instance and gives you always-on behaviour, dedicated compute, and VNet integration. For production integration in a PE-only architecture there is no choice – Premium is the minimum, Consumption is unusable.

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.

Read

AKS 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.

Read

Azure 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