Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_public_ip_prefix: reserve a contiguous Standard SKU IPv4/IPv6 block, control prefix length, zones and IP tags. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "public_ip_prefix" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-public-ip-prefix?ref=v1.0.0"
name = "..." # Name of the Public IP Prefix (2-80 chars, alphanumeric …
resource_group_name = "..." # Resource group to deploy the prefix into.
location = "..." # Azure region for the prefix (e.g. `centralindia`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Public IP Prefix is a reservation of a contiguous, fixed range of public IP addresses carved out of Microsoft’s pool. Instead of allocating Standard public IPs one at a time — each of which lands on a random, unpredictable address — you reserve a block (for example a /28 giving 16 addresses) up front. Every Standard SKU public IP you subsequently create from that prefix is guaranteed to fall inside the range. That is the whole point: downstream firewalls, partners and allow-lists can be told “anything from 20.51.x.0/28 is us” once, and never need updating as you scale out NAT Gateways, Load Balancers and VMs behind it.
The prefix length is immutable after creation, the SKU is always Standard, and the zonal/IP-tag properties are fixed at create time — change any of them and Terraform must destroy and recreate the prefix, which releases the addresses. Those sharp edges, plus the off-by-one prefix-length maths (/31 = 1 IP, /28 = 16 IPs) and the IPv4-vs-IPv6 differences, are exactly why a thin reusable module pays off: it encodes the valid ranges as validation blocks, pins the SKU, wires zones consistently, and exports the allocated CIDR so consumers can build allow-lists from a single source of truth.
When to use it
- You need a stable, advertise-once CIDR for egress so external parties can allow-list your traffic — typically fronting a NAT Gateway or an outbound Standard Load Balancer rule.
- You are building a Standard Load Balancer with multiple frontends (or a scale-out fleet) and want all of their public IPs to come from one predictable block.
- You run zone-redundant workloads and want a prefix whose child IPs can be pinned to specific availability zones — or explicitly made zone-redundant — in a controlled way.
- You want to pre-provision capacity so address allocation never fails mid-deploy under quota pressure, and so the addresses are reserved (and billed) deterministically.
- You do not need this for a single one-off public IP, for Basic SKU resources, or where the consuming resource cannot accept a
public_ip_prefix_id(e.g. legacy Basic Load Balancers).
Module structure
terraform-module-azure-public-ip-prefix/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_public_ip_prefix" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
# SKU is always Standard for a Public IP Prefix; Standard always = Regional or Global tier.
sku = "Standard"
sku_tier = var.sku_tier
ip_version = var.ip_version
prefix_length = var.prefix_length
# Zones are immutable. Empty list = no zone (non-zonal); ["1","2","3"] = zone-redundant.
zones = var.zones
# Optional IP tags (e.g. RoutingPreference = "Internet", FirstPartyUsage = "/NonProd").
dynamic "ip_tag" {
for_each = var.ip_tags
content {
ip_tag_type = ip_tag.value.ip_tag_type
tag = ip_tag.value.tag
}
}
tags = var.tags
lifecycle {
# prefix_length / ip_version / zones can't change in place; guard against silent recreation.
precondition {
condition = !(var.ip_version == "IPv6" && length(var.zones) > 0)
error_message = "IPv6 Public IP Prefixes do not support availability zones; leave var.zones empty for IPv6."
}
}
}
variables.tf
variable "name" {
type = string
description = "Name of the Public IP Prefix resource."
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 2-80 chars, start alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
}
}
variable "resource_group_name" {
type = string
description = "Name of the resource group to deploy the prefix into."
}
variable "location" {
type = string
description = "Azure region for the Public IP Prefix (e.g. 'centralindia')."
}
variable "sku_tier" {
type = string
default = "Regional"
description = "SKU tier of the prefix: 'Regional' or 'Global'."
validation {
condition = contains(["Regional", "Global"], var.sku_tier)
error_message = "sku_tier must be either 'Regional' or 'Global'."
}
}
variable "ip_version" {
type = string
default = "IPv4"
description = "IP version of the addresses in the prefix: 'IPv4' or 'IPv6'."
validation {
condition = contains(["IPv4", "IPv6"], var.ip_version)
error_message = "ip_version must be either 'IPv4' or 'IPv6'."
}
}
variable "prefix_length" {
type = number
default = 28
description = "Prefix length / block size. IPv4: 24-31 (a /28 = 16 addresses). IPv6: 124-127."
validation {
condition = (
(var.ip_version == "IPv4" && var.prefix_length >= 24 && var.prefix_length <= 31) ||
(var.ip_version == "IPv6" && var.prefix_length >= 124 && var.prefix_length <= 127)
)
error_message = "prefix_length must be 24-31 for IPv4 or 124-127 for IPv6 (and consistent with ip_version)."
}
}
variable "zones" {
type = list(string)
default = []
description = "Availability zones for the prefix's addresses. [] = non-zonal; [\"1\",\"2\",\"3\"] = zone-redundant. IPv4 only."
validation {
condition = alltrue([for z in var.zones : contains(["1", "2", "3"], z)])
error_message = "zones may only contain the values \"1\", \"2\" and \"3\"."
}
}
variable "ip_tags" {
type = list(object({
ip_tag_type = string
tag = string
}))
default = []
description = "Optional IP tags applied to the prefix (e.g. RoutingPreference = Internet)."
}
variable "tags" {
type = map(string)
default = {}
description = "Resource tags to apply to the prefix."
}
outputs.tf
output "id" {
value = azurerm_public_ip_prefix.this.id
description = "Resource ID of the Public IP Prefix — pass this as public_ip_prefix_id to child public IPs / NAT Gateways."
}
output "name" {
value = azurerm_public_ip_prefix.this.name
description = "Name of the Public IP Prefix."
}
output "ip_prefix" {
value = azurerm_public_ip_prefix.this.ip_prefix
description = "The allocated public CIDR block (e.g. '20.51.0.0/28') — the single source of truth for downstream allow-lists."
}
output "prefix_length" {
value = azurerm_public_ip_prefix.this.prefix_length
description = "The prefix length of the allocated block."
}
output "zones" {
value = azurerm_public_ip_prefix.this.zones
description = "Availability zones the prefix's addresses can be placed in."
}
How to use it
module "public_ip_prefix" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-public-ip-prefix?ref=v1.0.0"
name = "pipp-egress-prod-cin"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
ip_version = "IPv4"
prefix_length = 28 # 16 addresses reserved
sku_tier = "Regional"
zones = ["1", "2", "3"]
ip_tags = [
{
ip_tag_type = "RoutingPreference"
tag = "Internet"
}
]
tags = {
environment = "prod"
workload = "egress"
owner = "platform-network"
}
}
# Downstream: a NAT Gateway draws its outbound IP straight from the reserved block,
# so all egress is guaranteed to come from module.public_ip_prefix.ip_prefix.
resource "azurerm_nat_gateway_public_ip_prefix_association" "egress" {
nat_gateway_id = azurerm_nat_gateway.egress.id
public_ip_prefix_id = module.public_ip_prefix.id
}
# Publish the CIDR so a partner allow-list / firewall rule references one source of truth.
output "egress_cidr" {
value = module.public_ip_prefix.ip_prefix
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/public_ip_prefix/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-public-ip-prefix?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/public_ip_prefix && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the Public IP Prefix (2-80 chars, alphanumeric start). |
resource_group_name |
string |
— | Yes | Resource group to deploy the prefix into. |
location |
string |
— | Yes | Azure region for the prefix (e.g. centralindia). |
sku_tier |
string |
"Regional" |
No | SKU tier: Regional or Global. |
ip_version |
string |
"IPv4" |
No | IPv4 or IPv6. |
prefix_length |
number |
28 |
No | Block size. IPv4: 24-31 (/28 = 16 IPs). IPv6: 124-127. |
zones |
list(string) |
[] |
No | Availability zones; [] non-zonal, ["1","2","3"] zone-redundant. IPv4 only. |
ip_tags |
list(object({ ip_tag_type, tag })) |
[] |
No | Optional IP tags (e.g. RoutingPreference = Internet). |
tags |
map(string) |
{} |
No | Resource tags applied to the prefix. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the prefix — pass as public_ip_prefix_id to child public IPs / NAT Gateways. |
name |
Name of the Public IP Prefix. |
ip_prefix |
The allocated public CIDR block (e.g. 20.51.0.0/28) — the source of truth for allow-lists. |
prefix_length |
The prefix length of the allocated block. |
zones |
Availability zones the prefix’s addresses can be placed in. |
Enterprise scenario
A fintech runs a customer-facing API across three AKS clusters in Central India and must give every banking partner a single, never-changing egress CIDR to allow-list at their perimeter firewalls. The platform team reserves one zone-redundant /28 prefix with this module, attaches it to a shared NAT Gateway used by all three clusters’ node subnets, and publishes ip_prefix into the partner onboarding runbook. As traffic grows from one cluster to three, the partners’ allow-lists never change because every outbound packet still originates from the reserved 16-address block — turning what used to be a multi-week firewall-change ticket into a one-time entry.
Best practices
- Size the prefix before you create it —
prefix_lengthis immutable. Recreating to resize releases the addresses and breaks every allow-list, so deliberately leave headroom (a/28over a/30) rather than resizing later. - Lock zones at creation and keep them consistent. Zone configuration cannot be changed in place; pick
["1","2","3"]for zone-redundant egress up front, and remember IPv6 prefixes are non-zonal — the module’s precondition enforces this. - Treat
ip_prefixas the single source of truth. Feed the output CIDR into partner allow-lists, NSG rules and firewall objects via reference, never by copy-paste, so the reservation and the rules can never drift. - Right-size for cost. A prefix bills for all reserved addresses whether or not child IPs exist, and Standard public IPs/prefixes carry an hourly charge — reserve the smallest block that meets your scale plan instead of defaulting to a large range.
- Pin SKU and standardise naming. Always Standard SKU (Basic prefixes are retired); use a clear convention like
pipp-<workload>-<env>-<region>so the prefix, its child IPs and the consuming NAT Gateway are unambiguous in the portal and in CMDB. - Apply
RoutingPreference/IP tags intentionally. IP tags are set only at create time, so decide on Microsoft-network vs. internet routing preference before deploy to avoid an address-releasing recreate later.