IPAM in Bicep: your spoke VNet gets its address space from a pool

Updated

Every new spoke in a landing zone starts with the same question: which address space is still free? The answer usually lives in a spreadsheet. With Azure IPAM and the latest version of the AVM module for virtual networks, it doesn’t have to. You ask for a number of addresses, and Azure picks a free range from a central pool.

The address plan in Excel

You probably know the situation. There is an address plan, once set up neatly. A /16 for Azure, divided into blocks for the hub, spokes and reserve. It lives in Excel or on a wiki page.

Then a new spoke comes along. Someone looks for a free block, puts it in the Bicep parameters and updates the spreadsheet. Or forgets that last step. Six months later, two spokes turn out to share the same range. Peering fails, or a route to on-premises goes the wrong way.

The problem is not that people are careless. The problem is that the source of truth lives outside Azure. The code doesn’t know which ranges are taken, and the spreadsheet doesn’t know what has actually been deployed.

What Azure IPAM does

IPAM (IP address management) is part of Azure Virtual Network Manager. You create a Network Manager, and in it IP address pools. A pool is a container with one or more CIDR blocks. Azure keeps track of which parts of those blocks are in use.

Pools form a hierarchy:

  • Root pool: the first pool, with your entire address space. For example 10.100.0.0/16.
  • Child pool: part of the root pool or of another child pool. For example a pool for spokes and a pool per region. You can go up to seven levels deep.

You can allocate address space from a pool in two ways:

  • Static CIDR: you reserve a fixed range yourself. This is meant for space outside Azure, such as on-premises, or for Azure components that IPAM doesn’t support, such as a Virtual WAN hub.
  • Allocation to a resource: a VNet asks for a number of addresses, for example 1024. IPAM picks a free, non-overlapping range of that size itself. In this article I call this the dynamic way.

That second way is where the gain is. Your code no longer says 10.100.68.0/22, but “give me 1024 addresses from the spoke pool”. That makes IPAM the source of truth. In the portal you see, per pool, how much space is taken, and by whom.

IPAM is generally available in all regions with Virtual Network Manager, apart from a few (including Qatar Central and South Africa West). Microsoft Learn has the current list.

What was missing in AVM, and what PR #7271 adds

The AVM module avm/res/network/virtual-network has supported IPAM since May 2025 (PR #5176). But that worked through a detour. You put the pool’s resource ID as the first item in addressPrefixes, a list that is really meant for CIDR blocks. You gave the number of addresses in a separate parameter, ipamPoolNumberOfIpAddresses. The module recognised the pool by the text /Microsoft.Network/networkManagers/ in the first item.

That works, but it has limits. You can only specify one pool. A resource ID in a list of CIDRs is confusing to read. And there was an issue (#6695) that an existing VNet couldn’t be linked to a pool.

PR #7271 fixes this. The PR was merged on 20 August 2026 and is part of version 0.10.2 of the module. This is what changes:

  • There is a dedicated parameter ipamPoolPrefixAllocations. Its type comes straight from the Azure API (Microsoft.Network/virtualNetworks@2025-05-01), so you get the same structure as in a plain Bicep resource: a pool.id and a numberOfIpAddresses.
  • addressPrefixes is no longer required. You use one or the other, not both. According to the module, Azure doesn’t allow mixing fixed prefixes and pool allocations in the same address space.
  • The old way keeps working. The change is backwards compatible.
  • The module test now also links an existing VNet to a pool.

Subnets could already take their space from a pool, through subnets[].ipamPoolPrefixAllocations. Now the whole VNet can do that cleanly too.

Hands-on example: a spoke from the pool

Below is a spoke VNet using the AVM module. It gets 1024 addresses from an existing spoke pool, plus two subnets that also take their space from that pool. I leave the Network Manager and the pool itself out of scope; the spoke only receives the pool’s resource ID as a parameter.

I checked the file with bicep build and bicep lint, including type checking against the module from the registry. I have not deployed this example to a real subscription; do that in a test environment first.

spoke.bicep, the VNet through AVM:

// Spoke VNet whose address space comes from an IPAM pool instead of a hardcoded CIDR.
param location string = resourceGroup().location
param name string
param ipamPoolId string

@description('Size of the VNet in IP addresses, as a string (e.g. 1024 = /22).')
param numberOfIpAddresses string = '1024'

module virtualNetwork 'br/public:avm/res/network/virtual-network:0.10.2' = {
  params: {
    name: name
    location: location
    // Since 0.10.2: allocate from a pool. Do not set addressPrefixes as well.
    ipamPoolPrefixAllocations: [
      {
        pool: {
          id: ipamPoolId
        }
        numberOfIpAddresses: numberOfIpAddresses
      }
    ]
    // Subnets also take their size from the pool, within the VNet allocation.
    subnets: [
      {
        name: 'snet-app'
        ipamPoolPrefixAllocations: [
          {
            pool: {
              id: ipamPoolId
            }
            numberOfIpAddresses: '256'
          }
        ]
      }
      {
        name: 'snet-data'
        ipamPoolPrefixAllocations: [
          {
            pool: {
              id: ipamPoolId
            }
            numberOfIpAddresses: '128'
          }
        ]
      }
    ]
  }
}

output virtualNetworkId string = virtualNetwork.outputs.resourceId

Note two things. numberOfIpAddresses is a string, not a number. And there is no CIDR anywhere in the spoke. IPAM decides which /22 the spoke gets.

In a real landing zone, the Network Manager and the spokes usually live in different subscriptions. The spoke subscriptions then need to be within the Network Manager’s scope (networkManagerScopes), either as individual subscriptions or through a management group.

Things to watch

Permissions. Whoever creates and manages pools needs the Network Contributor role on the Network Manager. Whoever has a VNet allocated from a pool, such as the identity of your spoke pipeline, only needs the IPAM Pool User role on the pool. That role can allocate space, but not change the pool. If that identity can’t see the pool or the VNets, add Network Manager Read on the Network Manager.

Scope. A VNet can only draw from a pool if its subscription is within the Network Manager’s scope. If you get an error on the first spoke in a new subscription, look there first.

API versions. The module uses Microsoft.Network/virtualNetworks@2025-05-01. For Microsoft.Network/networkManagers/ipamPools, 2025-09-01 is the latest version. The oldest non-preview version in the reference is 2024-05-01. In the example I use 2025-05-01, like the module and its test. One or two pools per VNet: Microsoft mentions at most one IPv4 and one IPv6 pool.

Redeploys. The AVM test deploys the module twice in a row, once to create it and once to check that nothing changes. So keep numberOfIpAddresses stable. I haven’t tested what happens when you change the size of an existing VNet. Try that in a test environment first.

You only see the allocated range after deployment. The module (0.10.2) has no output with the VNet’s address space. So you can’t fill in rules that need a CIDR, such as firewall rules or routes, in advance. You see the allocation in the portal on the pool, under Allocations.

Subnets. You don’t know in advance which range the VNet gets. Fixed subnet prefixes are awkward then. So let subnets draw from the pool too, as in the example and in the module test. Make sure the subnets fit within the size of the VNet together.

Existing VNets. The module test shows how to link an existing VNet. The VNet already exists with a regular /24 within the pool range. The module then redeploys it with only ipamPoolPrefixAllocations and 256 addresses, the same size. I would keep the existing range inside the pool and keep the size the same. Test this on a non-production VNet first, because you are touching the address space of a running network.

IPAM as the source of truth

The real gain is not in one spoke. It is in IPAM becoming the place where your address space is managed, for everything.

Not everything in a landing zone is managed with Bicep. Often a network team manages the hub with Azure Firewall in Terraform. There are two clean solutions for that:

  • Reserve the range with a static CIDR in the root pool. The Terraform team uses that range, and IPAM never gives it to a spoke.
  • Let Terraform draw from the pool itself. The IPAM resources and a VNet’s ipamPoolPrefixAllocations are ordinary Azure APIs. With the AzAPI provider you can use them from Terraform as well; Microsoft Learn has AzAPI examples for the pools and static CIDRs.

The same goes for on-premises ranges, VPN customers or a Virtual WAN hub. Add them as static CIDRs in the root pool. Then IPAM can never accidentally give them to a spoke, and your whole address plan lives in one place.

The spreadsheet can go. Or better: it becomes a report you pull from IPAM, instead of a list you maintain by hand.

Looking for something lighter? Also have a look at Azure IPAM, an open-source project by Microsoft (MIT licence). It is a small app you run in your own tenant, on App Service or Azure Functions with Cosmos DB. You get a web interface and a REST API to plan and reserve address space, and it discovers the existing networks in your tenant. The difference with Virtual Network Manager: it is not part of Azure Resource Manager. So you call its API from your pipeline, instead of using ipamPoolPrefixAllocations in Bicep. And you run the app yourself; support goes through GitHub, not Microsoft Support.

Conclusion

Manual CIDR management doesn’t scale. Every new spoke is manual work, and every forgotten line in the spreadsheet is an overlap that hurts later. Azure IPAM makes address space centrally manageable, and since version 0.10.2 of avm/res/network/virtual-network you request that space cleanly with ipamPoolPrefixAllocations.

My advice: start with the next new spoke. Set up a root pool and a spoke pool, reserve your hub and on-premises ranges as static CIDRs, and let that one spoke take its address space from the pool. If that works, migrate the existing spokes one by one, in a test environment first.

Wondering how this fits your landing zone? Get in touch and we’ll look at it together.

Sources