Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_public_ip: Standard-SKU static IPs with availability-zone control, IP tags, DNS labels and reverse FQDN, ready for load balancers, NAT gateways and firewalls. 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" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-public-ip?ref=v1.0.0"
name = "..." # Name of the Public IP resource (1-80 chars).
resource_group_name = "..." # Resource group in which to create the Public IP.
location = "..." # Azure region (e.g. `eastus`).
}
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 (azurerm_public_ip) is a billable, standalone resource that represents a single inbound/outbound IPv4 or IPv6 address from Microsoft’s address space. It is the front door for anything that needs to be reachable from the internet without going through a private endpoint: Standard Load Balancer frontends, Azure Firewall, NAT Gateway, Application Gateway, Bastion, VPN/ExpressRoute gateways and individual VM NICs. Crucially, the Public IP is decoupled from whatever consumes it — the address lives independently of the load balancer or NIC it is attached to, which is exactly why it deserves its own lifecycle.
That decoupling is the whole reason to wrap it in a module. In production you rarely want a “default” dynamic Basic IP; you want a Standard SKU, statically allocated, zone-redundant address with a predictable name, an optional DNS label, and the right ddos_protection_mode. Getting those four decisions right every time — and never accidentally shipping a Basic SKU that’s being retired, or a dynamic IP that changes on deallocation and breaks DNS/firewall allow-lists — is tedious and error-prone when copy-pasted. This module bakes the safe defaults in (Standard + Static + zone-redundant), exposes the knobs that legitimately vary per workload, and emits the ip_address, id, and fqdn outputs that downstream resources and DNS records depend on.
When to use it
- You need a stable, known-ahead-of-time IP for a firewall allow-list, a partner’s NSG rule, a TLS cert’s SAN, or a DNS
Arecord that must not change. - You’re fronting a Standard Load Balancer, Azure Firewall, NAT Gateway, or VPN Gateway, all of which mandate a Standard-SKU public IP.
- You want zone redundancy (the IP survives a single availability-zone outage) or, conversely, a zonal IP pinned to the same zone as a zonal resource.
- You need a Microsoft-provided DNS label (
<label>.<region>.cloudapp.azure.com) or a reverse FQDN (PTR) for mail/compliance reasons. - You want consistent tagging, DDoS posture, and naming across dozens of public IPs without re-deciding SKU/allocation each time.
If the address only ever needs to be private, use a private IP / private endpoint instead — this module is specifically for internet-facing addressing.
Module structure
terraform-module-azure-public-ip/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_public_ip resource, fully wired
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, ip_address, fqdn, ...
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_public_ip" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
# Standard is the only SKU that supports zones, static-by-design
# allocation, and is required by SLB / Firewall / NAT Gateway.
sku = var.sku
sku_tier = var.sku_tier
allocation_method = var.allocation_method
ip_version = var.ip_version
# Availability-zone behaviour:
# ["1","2","3"] => zone-redundant
# ["2"] => zonal, pinned to a single zone
# [] => no-zone (only valid for Basic / regions w/o zones)
zones = var.zones
# DNS: produces <domain_name_label>.<region>.cloudapp.azure.com
domain_name_label = var.domain_name_label
reverse_fqdn = var.reverse_fqdn
# Idle timeout for outbound flows (4-30 minutes).
idle_timeout_in_minutes = var.idle_timeout_in_minutes
# DDoS posture on the IP itself (Standard SKU only).
ddos_protection_mode = var.ddos_protection_mode
ddos_protection_plan_id = (
var.ddos_protection_mode == "Enabled" ? var.ddos_protection_plan_id : null
)
# Service-routing / IP tags, e.g. { "RoutingPreference" = "Internet" }
# or FirstPartyUsage tags handed out by Microsoft.
ip_tags = var.ip_tags
# Bring-your-own-IP / custom prefix association (optional).
public_ip_prefix_id = var.public_ip_prefix_id
tags = var.tags
}
variables.tf
variable "name" {
description = "Name of the Public IP resource."
type = string
validation {
condition = length(var.name) >= 1 && length(var.name) <= 80
error_message = "name must be between 1 and 80 characters."
}
}
variable "resource_group_name" {
description = "Resource group in which to create the Public IP."
type = string
}
variable "location" {
description = "Azure region (e.g. eastus, westeurope)."
type = string
}
variable "sku" {
description = "SKU of the Public IP. Standard is strongly recommended; Basic is being retired."
type = string
default = "Standard"
validation {
condition = contains(["Basic", "Standard"], var.sku)
error_message = "sku must be either 'Basic' or 'Standard'."
}
}
variable "sku_tier" {
description = "SKU tier: Regional or Global (Global enables cross-region load balancing)."
type = string
default = "Regional"
validation {
condition = contains(["Regional", "Global"], var.sku_tier)
error_message = "sku_tier must be either 'Regional' or 'Global'."
}
}
variable "allocation_method" {
description = "Static or Dynamic. Standard SKU only supports Static."
type = string
default = "Static"
validation {
condition = contains(["Static", "Dynamic"], var.allocation_method)
error_message = "allocation_method must be either 'Static' or 'Dynamic'."
}
}
variable "ip_version" {
description = "IPv4 or IPv6."
type = string
default = "IPv4"
validation {
condition = contains(["IPv4", "IPv6"], var.ip_version)
error_message = "ip_version must be either 'IPv4' or 'IPv6'."
}
}
variable "zones" {
description = "Availability zones. [\"1\",\"2\",\"3\"] = zone-redundant; [\"2\"] = zonal; [] = no-zone."
type = list(string)
default = ["1", "2", "3"]
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 "domain_name_label" {
description = "Optional DNS label; yields <label>.<region>.cloudapp.azure.com. Null disables it."
type = string
default = null
validation {
condition = (
var.domain_name_label == null ||
can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.domain_name_label))
)
error_message = "domain_name_label must be 3-63 chars, lowercase letters/numbers/hyphens, not starting or ending with a hyphen."
}
}
variable "reverse_fqdn" {
description = "Optional reverse FQDN (PTR record target). Must end with a trailing dot."
type = string
default = null
}
variable "idle_timeout_in_minutes" {
description = "TCP idle timeout for outbound connections, 4-30 minutes."
type = number
default = 4
validation {
condition = var.idle_timeout_in_minutes >= 4 && var.idle_timeout_in_minutes <= 30
error_message = "idle_timeout_in_minutes must be between 4 and 30."
}
}
variable "ddos_protection_mode" {
description = "DDoS protection mode: VirtualNetworkInherited, Enabled, or Disabled."
type = string
default = "VirtualNetworkInherited"
validation {
condition = contains(["VirtualNetworkInherited", "Enabled", "Disabled"], var.ddos_protection_mode)
error_message = "ddos_protection_mode must be VirtualNetworkInherited, Enabled, or Disabled."
}
}
variable "ddos_protection_plan_id" {
description = "Resource ID of a DDoS protection plan; only used when ddos_protection_mode = Enabled."
type = string
default = null
}
variable "ip_tags" {
description = "Map of IP tags, e.g. { RoutingPreference = \"Internet\" }."
type = map(string)
default = {}
}
variable "public_ip_prefix_id" {
description = "Optional ID of a public IP prefix to allocate this address from."
type = string
default = null
}
variable "tags" {
description = "Tags to apply to the Public IP."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The resource ID of the Public IP."
value = azurerm_public_ip.this.id
}
output "name" {
description = "The name of the Public IP."
value = azurerm_public_ip.this.name
}
output "ip_address" {
description = "The allocated IP address. Known at apply time for Standard/Static IPs."
value = azurerm_public_ip.this.ip_address
}
output "fqdn" {
description = "The fully-qualified domain name when domain_name_label is set, else null."
value = azurerm_public_ip.this.fqdn
}
output "sku" {
description = "The SKU of the Public IP (Basic or Standard)."
value = azurerm_public_ip.this.sku
}
output "zones" {
description = "The availability zones the Public IP is associated with."
value = azurerm_public_ip.this.zones
}
How to use it
module "public_ip" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-public-ip?ref=v1.0.0"
name = "pip-prod-eus-slb-01"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
sku = "Standard"
allocation_method = "Static"
zones = ["1", "2", "3"] # zone-redundant frontend
domain_name_label = "kloudvin-prod-eus"
ddos_protection_mode = "VirtualNetworkInherited"
ip_tags = { RoutingPreference = "Internet" }
tags = {
environment = "prod"
workload = "edge-lb"
managed_by = "terraform"
}
}
# Downstream: attach the IP to a Standard Load Balancer frontend.
resource "azurerm_lb" "edge" {
name = "lb-prod-eus-edge-01"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
sku = "Standard"
frontend_ip_configuration {
name = "public-frontend"
public_ip_address_id = module.public_ip.id
}
}
# Downstream: publish the allocated address as an A record.
resource "azurerm_dns_a_record" "edge" {
name = "edge"
zone_name = azurerm_dns_zone.public.name
resource_group_name = azurerm_resource_group.dns.name
ttl = 300
records = [module.public_ip.ip_address]
}
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/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?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 && 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 |
n/a | Yes | Name of the Public IP resource (1-80 chars). |
resource_group_name |
string |
n/a | Yes | Resource group in which to create the Public IP. |
location |
string |
n/a | Yes | Azure region (e.g. eastus). |
sku |
string |
"Standard" |
No | Basic or Standard. Standard recommended; Basic is retiring. |
sku_tier |
string |
"Regional" |
No | Regional or Global (Global = cross-region LB). |
allocation_method |
string |
"Static" |
No | Static or Dynamic. Standard SKU only supports Static. |
ip_version |
string |
"IPv4" |
No | IPv4 or IPv6. |
zones |
list(string) |
["1","2","3"] |
No | Availability zones; all three = zone-redundant, single = zonal, [] = no-zone. |
domain_name_label |
string |
null |
No | DNS label producing <label>.<region>.cloudapp.azure.com. |
reverse_fqdn |
string |
null |
No | Reverse FQDN / PTR target (trailing dot). |
idle_timeout_in_minutes |
number |
4 |
No | Outbound TCP idle timeout, 4-30 minutes. |
ddos_protection_mode |
string |
"VirtualNetworkInherited" |
No | VirtualNetworkInherited, Enabled, or Disabled. |
ddos_protection_plan_id |
string |
null |
No | DDoS plan ID; used only when mode = Enabled. |
ip_tags |
map(string) |
{} |
No | IP tags, e.g. { RoutingPreference = "Internet" }. |
public_ip_prefix_id |
string |
null |
No | Optional public IP prefix to allocate from. |
tags |
map(string) |
{} |
No | Tags to apply to the Public IP. |
Outputs
| Name | Description |
|---|---|
id |
The resource ID of the Public IP. |
name |
The name of the Public IP. |
ip_address |
The allocated IP address (known at apply time for Standard/Static). |
fqdn |
The FQDN when domain_name_label is set, otherwise null. |
sku |
The SKU of the Public IP (Basic or Standard). |
zones |
The availability zones the Public IP is associated with. |
Enterprise scenario
A retail bank fronts its public banking portal with an Azure Firewall and a zone-redundant Standard Load Balancer in eastus, and the firewall’s egress IP must be on a partner payment processor’s allow-list. By provisioning the egress address through this module with allocation_method = "Static", sku = "Standard", and zones = ["1","2","3"], the IP is guaranteed never to change across firewall redeployments or zone failures — so the processor’s allow-list and the bank’s own outbound DNS A records (driven off module.public_ip.ip_address) stay valid through every release. The same module instance, with domain_name_label set, also gives the platform team a stable cloudapp.azure.com hostname for synthetic monitoring without touching the corporate DNS zone.
Best practices
- Default to Standard + Static, never Basic + Dynamic. Basic public IPs are on a retirement path and dynamic allocation changes the address on deallocation, silently breaking DNS records and firewall allow-lists. The module defaults enforce this, but resist overriding them.
- Use zone-redundant (
["1","2","3"]) for any resilient frontend, and only pin to a single zone (["2"]) when the consuming resource is itself zonal — mismatched zones between an IP and its load balancer/NIC will be rejected at apply time. - Treat the IP’s lifecycle as separate from its consumer. Keeping the Public IP in its own module/state slice means you can recreate a load balancer or firewall without releasing the address — protecting that allow-listed, DNS-bound IP from being reassigned to another tenant.
- Set
ddos_protection_modedeliberately. Inherit from the VNet (VirtualNetworkInherited) so the plan is managed centrally, orEnabledwith an explicitddos_protection_plan_idfor high-value targets; avoidDisabledon internet-facing production addresses. - Name for region, environment, and role (e.g.
pip-prod-eus-slb-01) and tag withenvironment/workload/managed_by— public IPs accumulate fast, and each idle Standard IP is billed hourly, so consistent naming and tagging are what make cost and orphan cleanup tractable. - Reclaim orphaned IPs promptly. A Standard IP not attached to anything still costs money; surface unattached addresses (no
ip_configuration) in cost reviews and destroy them rather than leaving them parked.