When Microsoft released the v2 tier of Azure API Management, it came with a promise: simpler networking, faster deployment, and a federation model that finally lets teams manage their own APIs without stepping on each other. On paper, it looks clean. In practice, getting it to work with VNet injection, workspaces, and a dedicated workspace gateway running in Internal mode, took me far longer than I expected.
Not because the feature doesn’t work. It does. But because the documentation is scattered, the resource types are confusing, and the ARM schema for workspace-gateway association is essentially undiscovered unless you know exactly where to look.
This post documents everything I figured out the hard way, with real Bicep code from my own implementation.
┌─────────────────────────────────────────────────────────┐
│ VNet: vnet-sah-hip-o │
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ sn-...-apim-o │ │ sn-...-apim-gw-main-o │ │
│ │ (Microsoft.Web/ │ │ (Microsoft.Web/ │ │
│ │ serverFarms) │ │ hostingEnvironments) │ │
│ │ /27 │ │ /24 │ │
│ │ │ │ │ │
│ │ ┌────────────────┐ │ │ ┌──────────────────┐ │ │
│ │ │ apimv2-sah-... │ │ │ │ apimgw-sah-... │ │ │
│ │ │ PremiumV2 │ │ │ │ WorkspaceGateway │ │ │
│ │ │ External mode │◄─┼───┼─►│ Internal mode │ │ │
│ │ │ │ │ │ │ VIP: 10.0.9.4 │ │ │
│ │ │ ┌────────────┐ │ │ │ └──────────────────┘ │ │
│ │ │ │ workspace-A│ │ │ │ ▲ │ │
│ │ │ │ workspace-B│ │ │ │ │ configConn │ │
│ │ │ └────────────┘ │ │ │ │ │ │
│ │ └────────────────┘ │ └──────────┼─────────────┘ │
│ └──────────────────────┘ │ │
│ │ Internal only │
└────────────────────────────────────────┼────────────────┘
│
VNet clients / ExpressRoute
The challenge, and why it’s harder than it looks
Let me describe what I was trying to achieve. I run a Hybrid Integration Platform (HIP) built on Azure API Management PremiumV2. I needed:
- The APIM service itself injected into a VNet for private outbound routing to backends
- Workspaces so different integration teams can manage their own APIs in isolation
- A workspace gateway in Internal mode, meaning the data plane is completely private, only reachable from within the VNet, not from the public internet
This is the setup Microsoft calls true physical isolation. One gateway, dedicated to one or more workspaces, with its own VNet subnet and its own private IP.
Simple enough in theory. But when I started building it, three things went wrong almost immediately.
First, the SKU name for a workspace gateway is not what you think. The resource type is Microsoft.ApiManagement/gateways, a standalone resource, not a child of the APIM service. And the SKU name changes based on your APIM tier: WorkspaceGatewayPremium for PremiumV2, WorkspaceGatewayStandard for the others. Deploy with the wrong name and you get a cryptic InvalidParameters: SkuType error after a 17-minute deployment.
Second, I needed to associate my workspaces with the gateway. The Azure portal makes it look like a simple wizard. But when I went to automate it, I couldn’t find the ARM resource type for the association. I tried Microsoft.ApiManagement/gateways/workspaces, the obvious candidate, and it returned a BadRequest with an empty message. Not helpful.
Third, virtually nothing I found online covered PremiumV2 specifically. Most blog posts and GitHub examples use the classic Premium tier (v1), which has completely different networking requirements. Port 3443 for the management plane. No delegation on the subnet. Different NSG rules. Copying those examples and applying them to v2 is a guaranteed way to waste an afternoon. The worst part, is even with some investigation and support from Github Copilot I couldn’t get it to work.

{
"code": "ResourceDeploymentFailure",
"target": "/subscriptions/[subscription id]/resourceGroups/rg-sah-hybrid-integration-platform-core-o/providers/Microsoft.Resources/deployments/apim-core",
"message": "The resource write operation failed to complete successfully, because it reached terminal provisioning state 'Failed'.",
"details": [
{
"code": "DeploymentFailed",
"target": "/subscriptions/[subscription id]/resourceGroups/rg-sah-hybrid-integration-platform-core-o/providers/Microsoft.Resources/deployments/apim-core",
"message": "At least one resource deployment operation failed. Please list deployment operations for details. Please see https://aka.ms/arm-deployment-operations for usage details.",
"details": [
{
"code": "BadRequest",
"message": ""
},
{
"code": "BadRequest",
"message": ""
}
]
}
]
}
The solution: step by step
Let me walk you through exactly how I built this with Bicep, end to end.
Prerequisites
Before you start, make sure you have:
- An Azure DevOps pipeline or equivalent for Bicep deployments
- An Azure subscription with quota for APIM PremiumV2 in your target region (workspace gateways are not available in all regions, check the regional availability list)
- A VNet with two subnets ready to be created:
- One for the APIM service itself (
Microsoft.Web/serverFarmsdelegation, minimum/27) - One per workspace gateway (
Microsoft.Web/hostingEnvironmentsdelegation, minimum/24recommended)
- One for the APIM service itself (
Step 1: Deploy the APIM v2 service with VNet injection
The APIM service itself is straightforward. The key thing to understand about v2 is that VNet integration is always External at the service level, even if you want private inbound access, that is handled by the workspace gateway, not by the APIM service. The service-level subnet only controls outbound routing to your backends.
To give an impression of what I wanted to achieve:
// All v2 tiers use 'External' for the service-level VNet integration.
// Private inbound access is handled by workspace gateways in their own subnet.
var virtualNetworkType = ApiManagementConfiguration.?networking != null ? 'External' : 'None'
resource apiManagement 'Microsoft.ApiManagement/service@2025-03-01-preview' = {
name: ApiManagementConfiguration.name
location: Location
sku: {
name: ApiManagementConfiguration.sku // 'PremiumV2'
capacity: 1
}
properties: {
publisherName: ApiManagementConfiguration.publisherName
publisherEmail: ApiManagementConfiguration.publisherEmail
virtualNetworkType: virtualNetworkType
virtualNetworkConfiguration: virtualNetworkType != 'None'
? { subnetResourceId: subnetResourceId }
: null
}
}
The subnet for the APIM service needs the Microsoft.Web/serverFarms delegation, not Microsoft.ApiManagement/service as you might expect from v1. This is one of the things that tripped me up initially.

Step 2: Create workspaces
Workspaces are child resources of the APIM service. They require StandardV2 or PremiumV2.
resource apiManagementWorkspaces 'Microsoft.ApiManagement/service/workspaces@2025-03-01-preview' = [
for workspace in ApiManagementConfiguration.workspaces: {
name: workspace.name
parent: apiManagement
properties: {
displayName: workspace.displayName
}
}
]
A workspace is essentially a logical container, it holds its own APIs, products, subscriptions, and named values. Different teams get RBAC access scoped to their workspace only. The platform team retains oversight across all workspaces.
Important constraint: workspaces do not support managed identities. If you rely on Key Vault references in named values or authentication-managed-identity policies, you will need to work around that limitation.

Step 3: Deploy the workspace gateway
This is where it gets interesting. The workspace gateway is not a child of the APIM service. It is a completely standalone Azure resource of type Microsoft.ApiManagement/gateways. It has its own resource group scope, its own subnet, its own hostname.
var workspaceGatewaySkuName = ApiManagementConfiguration.sku == 'PremiumV2'
? 'WorkspaceGatewayPremium'
: 'WorkspaceGatewayStandard'
resource workspaceGateways 'Microsoft.ApiManagement/gateways@2025-09-01-preview' = [
for gateway in ApiManagementConfiguration.gateways: {
name: 'apimgw-${IntegrationContext.organizationName}-${IntegrationContext.workload.shortName}-${IntegrationContext.workload.integrationName}-${gateway.name}-${IntegrationContext.environmentLetter}'
location: Location
sku: {
name: workspaceGatewaySkuName
capacity: 1
}
properties: {
virtualNetworkType: gateway.virtualNetworkType // 'Internal' for private-only access
backend: {
subnet: {
id: gateway.subnetResourceId
}
}
}
dependsOn: [apiManagement, apiManagementWorkspaces]
}
]
Two things to pay close attention to here:
The SKU name is tier-dependent. For PremiumV2 use WorkspaceGatewayPremium. For BasicV2 or StandardV2 use WorkspaceGatewayStandard. Pass the wrong string and you get the InvalidParameters: SkuType error I mentioned earlier.
virtualNetworkType: 'Internal' means fully private. The gateway gets a private IP from your subnet (in my case, 10.0.9.4) and no public endpoint is created. API consumers must be inside the VNet, or connected via VPN/ExpressRoute, to reach the gateway. Once you deploy with Internal, you cannot change this without redeploying the gateway from scratch.

The gateway subnet needs a /24 (256 IPs) and the Microsoft.Web/hostingEnvironments delegation.
One thing that confused me during deployment: the gateway initially showed Internal VIP: 100.96.3.4. That address is in the RFC 6598 range (100.64.0.0/10) — Azure infrastructure space that appears on every gateway regardless of mode. It does not mean the gateway is in External mode. Confirmation that Internal mode is actually working is seeing a second VIP from your own subnet — in my case 10.0.9.4. Both addresses appear in the gateway overview once provisioning completes.
Step 4: Get the NSG rules right
Both subnets need NSG rules, and the requirements are different from the classic v1 tier. This is the section where I most wish someone had written it down earlier.
APIM service subnet (the Microsoft.Web/serverFarms one):
For v2, you do not need port 3443. That is a v1 management plane requirement that you will see in almost every blog post, ignore it for v2. The rules I use:
networkSecurityGroup: {
rules: [
{
name: 'allow-vnet-inbound-tcp-port-443'
description: 'Allow inbound HTTPS so VNet clients can reach the API Management gateway.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1000
direction: 'Inbound'
}
{
name: 'allow-vnet-outbound-tcp-port-443'
description: 'Allow APIM to call backends and Logic Apps trigger endpoints over HTTPS.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1000
direction: 'Outbound'
}
{
name: 'allow-storage-outbound-tcp-port-443'
description: 'Allow APIM to reach Azure Storage (rate limiting, sessions, configuration).'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'Storage'
access: 'Allow'
priority: 1100
direction: 'Outbound'
}
{
name: 'allow-entra-outbound-tcp-port-443'
description: 'Allow APIM to reach Entra ID for OAuth2 token validation.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'AzureActiveDirectory'
access: 'Allow'
priority: 1200
direction: 'Outbound'
}
{
name: 'allow-apimanagement-outbound-tcp-port-443'
description: 'Allow APIM v2 to reach the management plane during activation and runtime.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'ApiManagement'
access: 'Allow'
priority: 1300
direction: 'Outbound'
}
]
}
Workspace gateway subnet (the Microsoft.Web/hostingEnvironments one):
This subnet has two rules that I initially missed and that are not mentioned in Microsoft’s v2 networking documentation, I found them by studying an open-source reference implementation:
networkSecurityGroup: {
rules: [
{
name: 'allow-gatewaymanager-inbound-tcp-port-443'
description: 'Required: Azure Gateway Manager lifecycle management.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'GatewayManager'
destinationAddressPrefix: '*'
access: 'Allow'
priority: 1000
direction: 'Inbound'
}
{
name: 'allow-apimanagement-inbound-tcp-port-443'
description: 'Required: APIM control plane pushes configuration to the gateway.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'ApiManagement'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1100
direction: 'Inbound'
}
{
name: 'allow-vnet-inbound-tcp-port-443'
description: 'Allow VNet clients to reach the workspace gateway over HTTPS.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1200
direction: 'Inbound'
}
{
name: 'allow-azureloadbalancer-inbound-tcp-port-65200-65535'
description: 'Required: Azure infrastructure load balancer health probe.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '65200-65535'
sourceAddressPrefix: 'AzureLoadBalancer'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1300
direction: 'Inbound'
}
{
name: 'allow-vnet-outbound-tcp-port-443'
description: 'Allow the workspace gateway to call backends over HTTPS.'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: 'VirtualNetwork'
access: 'Allow'
priority: 1000
direction: 'Outbound'
}
]
}
The GatewayManager rule allows Azure’s infrastructure to manage the gateway lifecycle. The ApiManagement rule allows the APIM service control plane to push configuration to the gateway. Without these two, the gateway cannot receive its workspace configuration after deployment.
Step 5: Associate workspaces with the gateway
This is the part that took me the longest to figure out. Searching the Microsoft docs for “associate workspace with gateway programmatically” gives you either the portal wizard or a vague mention that it is supported via REST API. No schema, no example, no resource type name.
The resource type you need is Microsoft.ApiManagement/gateways/configConnections. It is a child of the standalone workspace gateway resource, not of the APIM service. I found it documented on the ARM template reference page, and confirmed through a community Bicep implementation.
// Flatten the list of (gateway, workspace) pairs into individual configConnection resources.
// One configConnection per association.
var gatewayWorkspacePairs = flatten(map(
range(0, length(ApiManagementConfiguration.gateways)),
i => map(
ApiManagementConfiguration.gateways[i].workspaceNames,
workspaceName => {
gatewayFullName: 'apimgw-${IntegrationContext.organizationName}-...-${ApiManagementConfiguration.gateways[i].name}-${IntegrationContext.environmentLetter}'
workspaceName: workspaceName
}
)
))
resource gatewayConfigConnections 'Microsoft.ApiManagement/gateways/configConnections@2025-09-01-preview' = [
for pair in gatewayWorkspacePairs: {
name: '${pair.gatewayFullName}/${pair.workspaceName}'
properties: {
sourceId: resourceId(
'Microsoft.ApiManagement/service/workspaces',
ApiManagementConfiguration.name,
pair.workspaceName
)
hostnames: []
}
dependsOn: [workspaceGateways, apiManagementWorkspaces]
}
]
sourceId is the ARM resource ID of the workspace inside the APIM service. hostnames can be left as an empty array when using Internal mode, the gateway’s private hostname (apimgw-{name}-{hash}.gateway.{region}.azure-api.net resolving to your private IP) is auto-assigned by Azure.
After deployment, the workspace overview page in the portal shows the gateway as associated.


Putting it all together: the configuration model
In my implementation I use a single configuration object that flows through the entire Bicep module chain. The relevant section looks like this:
apiManagement: {
sku: 'PremiumV2'
workspaces: [
{ name: 'team-a', displayName: 'Team A' }
{ name: 'team-b', displayName: 'Team B' }
]
gateways: [
{
name: 'main'
addressSpace: '10.0.9.0/24'
virtualNetworkType: 'Internal'
workspaceNames: ['team-a', 'team-b']
}
]
networking: {
addressSpace: '10.0.8.96/27' // APIM service subnet
}
}
From this, the Bicep layer computes subnet names, resource IDs, and gateway names, callers only supply address spaces and logical names.
I will also share my full api.management.bicep that rolls out the resources:
import * as generic from '../types/generic.types.bicep'
import * as module from '../types/module.types.bicep'
@description('Optional deployment datetime stamp.')
param DateTimeString string = utcNow()
@description('Input context for the integration.')
param IntegrationContext generic.integrationType
@description('Api Management configuration object with all information.')
param ApiManagementConfiguration module.apiManagementConfigurationType
@description('Optional location.')
param Location string = resourceGroup().location
// All v2 tiers (BasicV2, StandardV2, PremiumV2) use 'External' for VNet integration.
// The subnet (Microsoft.Web/hostingEnvironments delegation) provides outbound routing to
// private backends. Private inbound access on PremiumV2 is provided by workspace gateways,
// which are VNet-injected into their own dedicated subnet — not by setting 'Internal' here.
var virtualNetworkType = ApiManagementConfiguration.?networking != null ? 'External' : 'None'
var workspaceGatewaySkuName = ApiManagementConfiguration.sku == 'PremiumV2' ? 'WorkspaceGatewayPremium' : 'WorkspaceGatewayStandard'
// Flatten gateway × workspace into individual (gatewayFullName, workspaceName) pairs
// so we can deploy one configConnection resource per association.
var gatewayWorkspacePairs = flatten(map(
range(0, length(ApiManagementConfiguration.gateways)),
i => map(
ApiManagementConfiguration.gateways[i].workspaceNames,
workspaceName => {
gatewayFullName: 'apimgw-${IntegrationContext.organizationName}-${IntegrationContext.workload.shortName}-${IntegrationContext.workload.integrationName}-${ApiManagementConfiguration.gateways[i].name}-${IntegrationContext.environmentLetter}'
workspaceName: workspaceName
}
)
))
var subnetResourceId = ApiManagementConfiguration.?networking != null
? resourceId(
ApiManagementConfiguration.networking!.virtualNetwork.resourceGroup.name,
'Microsoft.Network/virtualNetworks/subnets',
ApiManagementConfiguration.networking!.virtualNetwork.name,
ApiManagementConfiguration.networking!.virtualNetwork.subnet.name
)
: ''
resource apiManagement 'Microsoft.ApiManagement/service@2025-03-01-preview' = {
name: ApiManagementConfiguration.name
location: Location
tags: {
Environment: IntegrationContext.environmentLetter
WorkloadName: IntegrationContext.workload.shortName
DeploymentStamp: DateTimeString
}
identity: {
type: 'SystemAssigned'
}
sku: {
name: ApiManagementConfiguration.sku
capacity: 1
}
properties: {
publisherName: ApiManagementConfiguration.publisherName
publisherEmail: ApiManagementConfiguration.publisherEmail
virtualNetworkType: virtualNetworkType
virtualNetworkConfiguration: virtualNetworkType != 'None'
? { subnetResourceId: subnetResourceId }
: null
}
}
// Workspaces: StandardV2 and PremiumV2 only.
// The platform.core.bicep layer passes an empty array for BasicV2.
resource apiManagementWorkspaces 'Microsoft.ApiManagement/service/workspaces@2025-03-01-preview' = [
for workspace in ApiManagementConfiguration.workspaces: {
name: workspace.name
parent: apiManagement
properties: {
displayName: workspace.displayName
}
}
]
// Workspace gateways: PremiumV2 only.
// Each gateway is VNet-injected into its own dedicated subnet (delegation: Microsoft.ApiManagement/service)
// and can serve up to 5 workspaces. The platform.core.bicep layer passes an empty array for
// BasicV2 and StandardV2.
// dependsOn ensures SKU validation runs against a healthy, fully-provisioned service.
resource workspaceGateways 'Microsoft.ApiManagement/gateways@2025-09-01-preview' = [
for gateway in ApiManagementConfiguration.gateways: {
name: 'apimgw-${IntegrationContext.organizationName}-${IntegrationContext.workload.shortName}-${IntegrationContext.workload.integrationName}-${gateway.name}-${IntegrationContext.environmentLetter}'
location: Location
tags: {
Environment: IntegrationContext.environmentLetter
WorkloadName: IntegrationContext.workload.shortName
DeploymentStamp: DateTimeString
}
sku: {
name: workspaceGatewaySkuName
capacity: 1
}
properties: {
virtualNetworkType: gateway.virtualNetworkType
backend: {
subnet: {
// subnetResourceId is built with resourceId() in platform.core.bicep; the linter cannot trace it through the config type.
#disable-next-line use-resource-id-functions
id: gateway.subnetResourceId
}
}
}
dependsOn: [apiManagement, apiManagementWorkspaces]
}
]
// One configConnection per (gateway, workspace) pair — this is the ARM resource that associates
// an APIM service workspace with a standalone workspace gateway for dedicated data-plane routing.
resource gatewayConfigConnections 'Microsoft.ApiManagement/gateways/configConnections@2025-09-01-preview' = [
for pair in gatewayWorkspacePairs: {
name: '${pair.gatewayFullName}/${pair.workspaceName}'
properties: {
sourceId: resourceId('Microsoft.ApiManagement/service/workspaces', ApiManagementConfiguration.name, pair.workspaceName)
hostnames: []
}
dependsOn: [workspaceGateways, apiManagementWorkspaces]
}
]
resource activityLogHealthAlert 'Microsoft.Insights/activityLogAlerts@2026-01-01' = {
name: 'al-${IntegrationContext.organizationName}-${IntegrationContext.workload.shortName}-${IntegrationContext.workload.integrationName}-apim-health-${IntegrationContext.environmentLetter}'
location: 'global'
properties: {
scopes: [
apiManagement.id
]
condition: {
allOf: [
{
field: 'category'
equals: 'ResourceHealth'
}
{
anyOf: [
{
field: 'resourceType'
equals: 'Microsoft.ApiManagement/service'
}
]
}
]
}
actions: {
actionGroups: [
{
actionGroupId: ApiManagementConfiguration.actionGroupPlatformId
}
]
}
enabled: bool(ApiManagementConfiguration.alerts.enabled)
description: 'Resource Health waarschuwing voor ${apiManagement.name}'
}
}
output apiManagement module.defaultWithPrincipalOutputType = {
resourceName: apiManagement.name
resourceId: apiManagement.id
systemAssignedPrincipalId: apiManagement.identity.principalId
}
Conclusion and takeaways
Deploying APIM PremiumV2 with workspace isolation is absolutely achievable through Bicep, but it requires piecing together information from multiple sources, and there are a few traps along the way.
The things I would tell myself at the start of this journey:
Microsoft.ApiManagement/gatewaysis not a child of the APIM service. It is a standalone resource deployed independently to the same resource group.- The SKU name is tier-dependent. Use
WorkspaceGatewayPremiumfor PremiumV2,WorkspaceGatewayStandardotherwise. - Do not copy v1 NSG rules for v2. Port 3443 is not needed. The workspace gateway subnet needs
GatewayManagerandApiManagementinbound on 443 — that detail is not in the official v2 documentation. - The association resource is
Microsoft.ApiManagement/gateways/configConnections. Notgateways/workspaces(which returns BadRequest), not a pipeline script — a proper ARM resource withsourceIdpointing to the APIM service workspace. - Internal VNet mode means private-only. Once deployed, you cannot change the
virtualNetworkType. Plan before you deploy.
Note: having multiple workspaces in a single workspace gateway requires some additional effort from an API perspective. By default all workspaces’ base addresses will point to the gateway URL and therefore it is advisable to have teams using an infix/suffix to distinguish URLs.

All the Bicep code shown in this post is part of my own code for a Hybrid Integration Platform, feel free to use it as a reference for your own implementation.
End note: The workspace gateway feature and the configConnections resource type are still on preview API versions (2025-09-01-preview at the time of writing). This means the schema could change and certain behaviors may still evolve. I will keep this post updated as the feature matures toward general availability.


Leave a Reply