Create a standard, zone-redundant Azure Public IP with secure, resilient defaults, on
hashicorp/azurerm ~> 4.0.
- 🌐 Creates one
azurerm_public_ipfor 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, andfqdnfor 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.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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;
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;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_public_ip.this |
1 (keystone) | The public IP address. |
| 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_tagsandpublic_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 referencingidre-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_scopeis force-new in ONE DIRECTION ONLY. The provider carries aForceNewIfChangewhose 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 inCustomizeDiff, so it appears in neither the schema nor aForceNewgrep. - 🔴 THE BASIC SKU CANNOT BE CREATED. The provider's
CustomizeDiffraises a hard error for any Basic address whose identity, SKU, allocation, zones, prefix or IP tags is changing — which is every create. Becauseallocation_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; thesku_is_uncreatable_basicoutput reports the situation. - Where each check fires is not uniform.
sku_tier = "Global"with a non-Standardskufails atterraform plan(aCustomizeDiff); Standard-or-StandardV2 with Dynamic allocation, and a DDoS plan withoutddos_protection_mode = "Enabled", fail atterraform apply(checks written into the create function). This module mirrors the latter two asvalidation {}blocks, so both now fail atterraform validate, with no credentials. fqdn,reverse_fqdnanddomain_name_labelare EMPTY STRINGS, not null, when unset. The provider's read initialises each to""and overwrites only when the API returns DNS settings, so!= nulltakes the populated branch every time. Use thehas_fqdnoutput.zonesmust be[]for aGlobaltier 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.
- Network Contributor on the target resource group (or a custom role with
Microsoft.Network/publicIPAddresses/*).
- The
Microsoft.Networkresource provider registered on the subscription. - An existing resource group.
- For DDoS
Enabledmode, an existing DDoS protection plan (itsid). - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
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
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.
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 |
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 = []
}
⚠️ Setzones = []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 planfails, not apply: "creation of newBasicSKU public IP addresses is no longer permitted following its deprecation on March 31, 2025." The provider's check coversallocation_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
tagsoridle_timeout_in_minutesare edited. Avalidation {}block refusing"Basic"here would take that away — and, because a failed validation blocksterraform destroytoo, would leave an existing Basic address unmanageable through the module. Thesku_is_uncreatable_basicoutput 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.
| 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)| 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. |
- 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
idfrom 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_scopeis in-place; changing or clearing it replaces the resource. The rule is aCustomizeDiffpredicate on the old value, which is why it does not read as force-new anywhere in the schema. - SKU/zone coupling.
zonesmust be empty for a Global tier and in non-AZ regions — the defaults assume Standard/Regional/AZ, so overridezoneswhere 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_addressis known after apply.fqdn,reverse_fqdnanddomain_name_labelcome back as""rather than null when unset, so a null guard on them silently takes the wrong branch. - DDoS
Enabledis 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. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| Concern | Secure/resilient default | Opt-out |
|---|---|---|
| SKU | Standard (zone-capable) |
Basic — accepted 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 |
cd terraform-azurerm-public-ip
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
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.
$ 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"| 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. |
- 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."