Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Public IP Terraform Module

Create a standard, zone-redundant Azure Public IP with secure, resilient defaults, on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • 🌐 Creates one azurerm_public_ip for a load balancer, NAT gateway, gateway, or NIC.
  • 🛡️ Resilient by default: Standard SKU, Static allocation, zone-redundant (["1","2","3"]).
  • 🧭 Exposes DDoS mode, DNS label, IP version, idle timeout, prefix allocation, and IP tags — all typed.
  • 🔗 Emits id, ip_address, and fqdn for downstream wiring.

💡 Why it matters: the Basic SKU can no longer be created — the provider refuses a new Basic address at plan time, citing its deprecation on March 31, 2025 — and it was never zone-resilient. Defaulting to Standard + Static + zone-redundant gives every public IP the only baseline still creatable, and a production-grade one, without the caller having to remember the flags.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!

🗺️ Where this fits in the family

flowchart TD
  RG["terraform-azurerm-resource-group"]
  PIP["terraform-azurerm-public-ip"]
  APIP["azurerm_public_ip"]
  LB["terraform-azurerm-load-balancer"]
  NAT["terraform-azurerm-nat-gateway"]
  GW["VPN / Application Gateway"]

  RG -->|"resource_group_name + location"| PIP
  PIP --> APIP
  PIP -->|"id"| LB
  PIP -->|"id"| NAT
  PIP -->|"id"| GW

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class PIP this;
  class APIP key;
Loading

🧬 What this module builds

flowchart LR
  I1["name / location / resource_group_name"]
  I2["sku = Standard (default)"]
  I3["allocation_method = Static (default)"]
  I4["zones = 1,2,3 (default)"]
  I5["ddos / domain_name_label / ip_version (optional)"]

  PIP["azurerm_public_ip.this"]

  O1["id"]
  O2["name"]
  O3["ip_address"]
  O4["fqdn"]

  I1 --> PIP
  I2 --> PIP
  I3 --> PIP
  I4 --> PIP
  I5 --> PIP
  PIP --> O1
  PIP --> O2
  PIP --> O3
  PIP --> O4

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  class PIP this;
Loading

Resource inventory

Resource Cardinality Role
azurerm_public_ip.this 1 (keystone) The public IP address.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite

  • 🔴 A REPLACEMENT ALLOCATES A NEW ADDRESS, and the plan never says so. The force-new set is nearly the whole resource: name, resource_group_name, location, sku, sku_tier, ip_version, zones, edge_zone, ip_tags and public_ip_prefix_id. Terraform reports "must be replaced"; it cannot report that the address will change, because the new value is unknown until apply. Consumers referencing id re-wire themselves; anything holding the literal address — an external DNS record, a partner allow-list, an on-premises firewall rule — does not.
  • 🔴 domain_name_label_scope is force-new in ONE DIRECTION ONLY. The provider carries a ForceNewIfChange whose predicate is "the old value was set, OR the new value is empty". Setting a scope for the first time is an in-place update; changing it or clearing it replaces the whole public IP. The rule lives in CustomizeDiff, so it appears in neither the schema nor a ForceNew grep.
  • 🔴 THE BASIC SKU CANNOT BE CREATED. The provider's CustomizeDiff raises a hard error for any Basic address whose identity, SKU, allocation, zones, prefix or IP tags is changing — which is every create. Because allocation_method = "Dynamic" exists only on Basic, Dynamic allocation is unreachable for a new address too. Both values are still accepted by this module so an existing Basic address stays editable and destroyable; the sku_is_uncreatable_basic output reports the situation.
  • Where each check fires is not uniform. sku_tier = "Global" with a non-Standard sku fails at terraform plan (a CustomizeDiff); Standard-or-StandardV2 with Dynamic allocation, and a DDoS plan without ddos_protection_mode = "Enabled", fail at terraform apply (checks written into the create function). This module mirrors the latter two as validation {} blocks, so both now fail at terraform validate, with no credentials.
  • fqdn, reverse_fqdn and domain_name_label are EMPTY STRINGS, not null, when unset. The provider's read initialises each to "" and overwrites only when the API returns DNS settings, so != null takes the populated branch every time. Use the has_fqdn output.
  • zones must be [] for a Global tier and in regions without availability zones — override the default there. A single-element list is zonal, which pins the address into one zone and is a weaker posture than the zone-redundant default, not a stronger one.
  • The three DNS fields are sent as one API object: the provider rebuilds the whole block when any of them changes, and nils it when all three end up empty — so clearing the label also clears the reverse FQDN.

🔑 Required Azure RBAC Roles / Permissions

  • Network Contributor on the target resource group (or a custom role with Microsoft.Network/publicIPAddresses/*).

Azure Prerequisites

  • The Microsoft.Network resource provider registered on the subscription.
  • An existing resource group.
  • For DDoS Enabled mode, an existing DDoS protection plan (its id).
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

📁 Module Structure

terraform-azurerm-public-ip/
├── providers.tf     # required_version + azurerm ~> 4.0; no provider block
├── variables.tf     # name, rg, location, sku, allocation_method, zones, ... tags, timeouts
├── main.tf          # azurerm_public_ip.this
├── outputs.tf       # id, name, ip_address, fqdn
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical Terraform ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-lb-prod-eastus2"
  resource_group_name = "rg-network-prod-eastus2"
  location            = "eastus2"
}

ℹ️ The empty call is Standard + Static + zone-redundant. Pin the module by tag (?ref=v1.0.0), never a branch.

🔌 Cross-Module Contract

Consumes

Input Type From
resource_group_name string terraform-azurerm-resource-group (name)
location string caller / resource group (location)

Emits

Output Description Consumed by
id Public IP Resource ID load balancer, NAT gateway, gateway, NIC
name Public IP name diagnostics, tagging
ip_address Allocated address DNS, allow-lists
fqdn DNS FQDN (if a label is set) clients

📚 Example Library

1 · Minimal (Standard, Static, zone-redundant)
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-min-eastus2"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
}

🔒 Standard SKU, Static allocation, zones 1/2/3 — a resilient production baseline out of the box.

2 · With a DNS label
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-web-eastus2"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  domain_name_label   = "contoso-web-prod"
}

ℹ️ Produces contoso-web-prod.eastus2.cloudapp.azure.com.

3 · Pinned to a single zone
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-zonal-eastus2"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  zones               = ["1"]
}
4 · Non-AZ region (no zones)
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-noaz"
  resource_group_name = "rg-network-westcentralus"
  location            = "westcentralus"
  zones               = []
}

⚠️ Set zones = [] in regions without availability zones, or the create fails.

5 · Basic SKU — what NOT to write, and why it is still accepted

This configuration cannot be applied. It is shown because the module accepts it and the failure comes from the provider rather than from here.

module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-basic"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  sku                 = "Basic"
  allocation_method   = "Dynamic"
  zones               = []
}

🔴 terraform plan fails, not apply: "creation of new Basic SKU public IP addresses is no longer permitted following its deprecation on March 31, 2025." The provider's check covers allocation_method = "Dynamic" as well, since Dynamic exists only on Basic.

ℹ️ So why does this module accept the values? Because the provider's check is on changes, not on the value: an address that already exists as Basic still plans cleanly when only its tags or idle_timeout_in_minutes are edited. A validation {} block refusing "Basic" here would take that away — and, because a failed validation blocks terraform destroy too, would leave an existing Basic address unmanageable through the module. The sku_is_uncreatable_basic output reports the state instead.

⚠️ Migrating off Basic is not an upgrade. There is no in-place path: you create a Standard address and move every consumer to it, which means the address value changes.

6 · Global tier (cross-region LB)
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-global"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  sku_tier            = "Global"
  zones               = []
}
7 · IPv6
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-v6"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  ip_version          = "IPv6"
}
8 · DDoS protection enabled
module "pip" {
  source                  = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                    = "pip-ddos"
  resource_group_name     = "rg-network-eastus2"
  location                = "eastus2"
  ddos_protection_mode    = "Enabled"
  ddos_protection_plan_id = var.ddos_plan_id
}
9 · Custom idle timeout
module "pip" {
  source                  = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                    = "pip-idle"
  resource_group_name     = "rg-network-eastus2"
  location                = "eastus2"
  idle_timeout_in_minutes = 15
}
10 · Allocated from a public IP prefix
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-from-prefix"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  public_ip_prefix_id = var.public_ip_prefix_id
  zones               = ["1"]
}
11 · With IP tags
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-tagged"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
  ip_tags             = { RoutingPreference = "Internet" }
}
12 · for_each — a pool of IPs
module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  for_each            = toset(["a", "b", "c"])
  name                = "pip-egress-${each.key}"
  resource_group_name = "rg-network-eastus2"
  location            = "eastus2"
}
13 · 🏗️ End-to-end composition

A resource group and a zone-redundant public IP feeding a NAT gateway.

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-network-prod-eastus2"
  location = "eastus2"
}

module "pip" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-public-ip.git?ref=v1.0.0"
  name                = "pip-nat-prod-eastus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "nat" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-nat-gateway.git?ref=v1.0.0"
  name                = "nat-prod-eastus2"
  resource_group_name = module.rg.name
  location            = module.rg.location
  # The associations are a KEYED MAP, not a list -- so adding or removing one never re-indexes the rest.
  public_ip_associations = {
    egress = { public_ip_address_id = module.pip.id }
  }
}

💡 The public IP flows into the NAT gateway by module.pip.id, giving subnets a stable, explicit egress address.

📥 Inputs

Name Type Required Default Description
name string Public IP name. Immutable.
resource_group_name string Containing resource group. Immutable.
location string Azure region. Immutable.
sku string "Standard" Standard / StandardV2 / Basic. Basic cannot be created.
sku_tier string "Regional" Regional / Global.
allocation_method string "Static" Static / Dynamic. Dynamic requires Basic, so it is unreachable for a new address.
domain_name_label_scope string null NoReuse / ResourceGroupReuse / SubscriptionReuse / TenantReuse. Force-new in one direction.
reverse_fqdn string null PTR target. Must already resolve forward to this address.
edge_zone string null Extended location. Force-new.
zones list(string) ["1","2","3"] Availability zones.
ip_version string "IPv4" IPv4 / IPv6.
idle_timeout_in_minutes number null 4–30.
domain_name_label string null DNS label.
ddos_protection_mode string null DDoS mode.
ddos_protection_plan_id string null DDoS plan ID.
public_ip_prefix_id string null Prefix to allocate from.
ip_tags map(string) null IP tags.
tags map(string) {} Resource tags.
timeouts object null Optional timeouts.
Full variable schemas
variable "sku"               { type = string, default = "Standard" }   # Standard | StandardV2 | Basic
variable "sku_tier"          { type = string, default = "Regional" }   # Regional | Global
variable "allocation_method" { type = string, default = "Static" }     # Static | Dynamic (Basic only)
variable "zones"             { type = list(string), default = ["1","2","3"] }
variable "ip_version"        { type = string, default = "IPv4" }        # IPv4 | IPv6
# ddos_protection_mode ∈ { VirtualNetworkInherited, Enabled, Disabled } (validated)

🧾 Outputs

Output Description Notes
id Public IP Resource ID Emitted first. Reference this, never the address value.
name Public IP name Force-new.
resource_group_name Containing resource group Force-new — there is no move operation.
location Azure region, in the canonical form Azure uses. Read from the resource, not var.location.
ip_address Allocated address Computed. Does not survive a replacement.
fqdn DNS FQDN "" when unset, not null.
has_fqdn Whether the address really has a DNS name Use this instead of a null check on fqdn.
domain_name_label / reverse_fqdn The DNS label and PTR target Also "" rather than null.
sku / sku_tier SKU and tier in effect Both force-new.
sku_is_uncreatable_basic The SKU is Basic The provider will not create one. Reported, not refused.
allocation_method / ip_version Static-or-Dynamic, IPv4-or-IPv6 ip_version is force-new.
zones / zone_count / is_zone_redundant The zone posture Force-new. One zone is zonal, not redundant.
replacement_allocates_a_new_address Constant true The fact a plan cannot tell you.
changing_domain_name_label_scope_replaces_the_ip A scope is set From here, editing or clearing it is destructive.
allocated_from_prefix Drawn from a public IP prefix Force-new.
ddos_protection_mode The mode in effect Resolved from the resource — the provider defaults null to VirtualNetworkInherited.
ddos_protection_is_billed_per_ip Mode is Enabled A separate paid SKU, billed per IP.
idle_timeout_in_minutes The timeout in effect Resolved — the provider substitutes 4 for null.
tags Resource tags One of the few in-place fields.

🧠 Architecture Notes

  • Resilient defaults. Standard SKU + Static allocation + zones ["1","2","3"] is the empty-call baseline. It is also the only baseline still creatable: the provider refuses a new Basic address, and Dynamic allocation exists only on Basic.
  • Almost everything here is force-new, and replacement changes the address. That is the lifecycle fact to design around. Reference id from consumers; treat the literal address as something that can change under you whenever the resource is replaced, and keep external systems that need the value fed from this module's output rather than from a copy.
  • One asymmetric force-new. Adding a domain_name_label_scope is in-place; changing or clearing it replaces the resource. The rule is a CustomizeDiff predicate on the old value, which is why it does not read as force-new anywhere in the schema.
  • SKU/zone coupling. zones must be empty for a Global tier and in non-AZ regions — the defaults assume Standard/Regional/AZ, so override zones where that does not hold.
  • Zone-redundant is not zonal. ["1","2","3"] survives the loss of a zone; ["1"] pins the address into one. The single-element form looks like a resilience setting and is the opposite.
  • Computed address, and empty strings where null is expected. ip_address is known after apply. fqdn, reverse_fqdn and domain_name_label come back as "" rather than null when unset, so a null guard on them silently takes the wrong branch.
  • DDoS Enabled is a purchase. It is DDoS IP Protection, billed per protected IP and independent of any plan on the virtual network. The default, VirtualNetworkInherited, adds no charge and puts the real control on the VNet.
  • No secrets. The module emits identifiers only.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure/resilient default Opt-out
SKU Standard (zone-capable) Basicaccepted but not creatable; the provider refuses a new one at plan time. Kept accepted so existing addresses stay manageable.
Allocation Static Dynamic — Basic-only, so likewise unreachable for a new address.
Zones ["1","2","3"] (zone-redundant) [] or a single zone
DDoS Inherited from the VNet Enabled with a plan

🚀 Runbook

cd terraform-azurerm-public-ip
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the sku/allocation/ip-version/ddos validations) and canonically formatted, with no cloud calls. What it does not exercise: region zone availability, SKU/allocation compatibility at apply, and DDoS plan existence — those surface only under terraform plan/apply against real credentials from CI.

💬 Example Output

$ terraform output
fqdn       = null
id         = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod-eastus2/providers/Microsoft.Network/publicIPAddresses/pip-nat-prod-eastus2"
ip_address = "20.62.128.10"
name       = "pip-nat-prod-eastus2"

🔍 Troubleshooting

Symptom Cause Fix
Create fails: zones not supported Region lacks AZs, or Basic/Global with zones set Set zones = [].
Create fails: Standard needs Static sku = "Standard" with allocation_method = "Dynamic" Use Static (the default). This module now catches it at terraform validate rather than leaving it to apply.
terraform plan fails: creation of new Basic SKU public IP addresses is no longer permitted A new Basic address, or allocation_method = "Dynamic" which implies Basic There is no fix for a new address — use Standard + Static. An existing Basic address is unaffected while its identity, SKU, zones, prefix and IP tags stay put.
Plan wants to replace the IP after a one-word edit domain_name_label_scope was CHANGED or CLEARED — force-new in that direction only Decide the scope at creation. Note the replacement allocates a new address.
DDoS plan rejected at apply ddos_protection_plan_id set without ddos_protection_mode = "Enabled" Set the mode. This module now catches it at terraform validate.
A conditional on fqdn takes the wrong branch fqdn is "" when no label is set, not null Use the has_fqdn output, or compare against "".
An external allow-list stopped matching The address changed because the resource was replaced Feed the allow-list from ip_address rather than a copied literal; see replacement_allocates_a_new_address.
ip_address empty in plan It is computed Expected; it resolves on apply.
AuthorizationFailed Identity lacks Network Contributor Grant Network Contributor on the resource group.
DDoS enable rejected No plan supplied Set ddos_protection_plan_id with mode Enabled.
Replacement on SKU change sku/zones are effectively immutable Expect replacement; plan accordingly.

🔗 Related Docs

  • Provider resource: azurerm_public_ip
  • Sibling modules: terraform-azurerm-nat-gateway, terraform-azurerm-load-balancer, terraform-azurerm-resource-group
  • This module's cross-module contract: SCOPE.md

💙 "Infrastructure as Code should be standardized, consistent, and secure."