IaC Azure

Terraform Module: Azure Public IP — Standard SKU, zone-redundant, DDoS-aware static addressing

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

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 configlive/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 configlive/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

TerraformAzurePublic IPModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading