Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Ip Group Terraform Module

A reusable Azure IP Group β€” a named set of IP addresses and CIDR ranges that Azure Firewall and Firewall Policy rules reference by ID. Define the address set once, reference it from many rules, and change it in one place. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Version Module Type Resources


🧩 Overview

  • πŸ—‚οΈ Creates a single azurerm_ip_group β€” a named, reusable collection of IP addresses and CIDR ranges.
  • πŸ” Carries the address set inline via cidrs, which updates in place β€” editing the set never replaces the group and never re-creates the firewall rules that reference it.
  • πŸ”΄ Do not also use terraform-azurerm-ip-group-cidr for the same group. Azure offers two ways to populate an IP Group and the provider warns that using both "will cause a conflict and CIDRS will be removed" β€” data loss, not a plan error. Leaving cidrs empty does not make them safe to combine, because the field is not computed and an unset input is an enforced empty set. Pick one method per group.
  • 🧱 Centralizes address ranges so a change is made once and applies everywhere the group's id is referenced.
  • πŸ”— Emits the Resource id first, so Azure Firewall and Firewall Policy rule modules can consume it directly.
  • 🏷️ Applies tags and supports configurable timeouts β€” the universal module tail.
  • πŸ”’ Empty call is safe: it produces a valid, empty group with no exposure knobs to get wrong.

πŸ’‘ Why it matters: Firewall rule sets drift when the same address list is copied into rule after rule. An IP Group turns that list into one addressable object. Update the ranges here and every consuming rule sees the change at once β€” fewer edits, fewer mistakes, one place to audit.


❀️ Support this project

If this module saves you time, please consider supporting its continued development:


πŸ—ΊοΈ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  ipg["terraform-azurerm-ip-group"]
  ipgres["azurerm_ip_group"]
  fw["Azure Firewall rule (sibling)"]
  fwp["Firewall Policy rule collection (sibling)"]
  cidrmod["terraform-azurerm-ip-group-cidr: the standalone way to populate a group, and MUTUALLY EXCLUSIVE with this module's cidrs input. Using both removes CIDRS."]

  rg -->|"resource_group_name"| ipg
  ipg -->|"creates"| ipgres
  ipgres -->|"id referenced by"| fw
  ipgres -->|"id referenced by"| fwp
  cidrmod -->|"do NOT combine with cidrs on the same group"| ipg

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#002a4d,color:#ffffff;
  classDef sib fill:#f0f0f0,stroke:#c8c8c8,color:#333333;

  class ipg me;
  class ipgres keystone;
  class cidrmod keystone;
  class rg,fw,fwp sib;
Loading

The module sits downstream of a resource group and upstream of the firewall rules that reference the group. It owns the address set; the firewall and firewall-policy modules own the rules and consume this module's id.


🧬 What this module builds

flowchart LR
  subgraph in["Inputs"]
    n["name (force-new)"]
    rgn["resource_group_name (force-new)"]
    loc["location (force-new)"]
    c["cidrs (set, updates in place)"]
    t["tags"]
  end
  this["azurerm_ip_group.this"]
  subgraph out["Outputs"]
    oid["id (first)"]
    oname["name"]
    ocidrs["cidrs"]
    ofw["firewall_ids / firewall_policy_ids (computed)"]
  end

  in -->|"configure"| this
  this -->|"emit"| out

  classDef keystone fill:#004578,stroke:#002a4d,color:#ffffff;
  classDef io fill:#f0f0f0,stroke:#c8c8c8,color:#333333;

  class this keystone;
  class in,out io;
Loading

Resource inventory

Resource Role Cardinality
azurerm_ip_group.this The keystone IP Group; holds the centralized cidrs set single

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
azurerm provider ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, auth, and subscription

Schema notes that bite (verified against the live provider schema):

  • ⚠️ name is force-new β€” renaming the group replaces it, and any rule that referenced the old ID must be reconciled.
  • ⚠️ resource_group_name is force-new β€” moving the group between resource groups replaces it.
  • ⚠️ location is force-new β€” an IP Group cannot be moved between regions in place, and it must share the region of the firewall/policy that consumes it.
  • βœ… cidrs updates in place β€” adding or removing ranges is a non-destructive update that does not touch the consuming rules.
  • ℹ️ firewall_ids and firewall_policy_ids are computed (read-only) β€” Azure populates them from the rules that reference the group; they are outputs, never inputs.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Network Contributor on the target resource group, or a custom role granting Microsoft.Network/ipGroups/* scoped to the resource group.

Least-privilege at the smallest scope that works; grant at the resource-group scope, not the subscription, unless a broader scope is genuinely required.


Azure Prerequisites

  • An existing resource group in a supported US Azure region.
  • The Microsoft.Network resource provider registered on the target subscription.
  • The IP Group must be created in the same region as any Azure Firewall or Firewall Policy that will reference it.
  • The caller configures the provider "azurerm" { features {} } block, authentication, and the target subscription β€” the module declares none of these.

πŸ“ Module Structure

terraform-azurerm-ip-group/
β”œβ”€β”€ providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf   # deeply-typed inputs: name, resource_group_name, location, cidrs, tags, timeouts
β”œβ”€β”€ main.tf        # keystone azurerm_ip_group.this + dynamic timeouts
β”œβ”€β”€ outputs.tf     # id first, then name, cidrs, location, resource_group_name, firewall_* maps
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT, Copyright (c) 2026 Casey Wood
└── .gitignore     # the canonical library ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
  # auth + subscription come from the environment (ARM_* / az login / OIDC)
}

module "onprem_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "onprem-datacenters"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  cidrs = [
    "10.10.0.0/16",
    "10.20.0.0/16",
  ]

  tags = {
    environment = "prod"
    owner       = "network-team"
  }
}

ℹ️ The caller owns the provider. This module never declares a provider {} block or a features {} block β€” pin ?ref=v1.0.0, never a branch.


πŸ”Œ Cross-Module Contract

Consumes

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

Emits

Output Description Consumed by
id IP Group Resource ID (first) Azure Firewall / Firewall Policy rule modules
name IP Group name diagnostics, tagging
cidrs the centralized IP/CIDR set documentation, IPAM β€” πŸ”΄ not to be combined with terraform-azurerm-ip-group-cidr
location region the group is deployed in siblings needing region parity
resource_group_name the containing resource group downstream modules
firewall_ids Azure Firewall IDs referencing the group (computed) audit / reporting
firewall_policy_ids Firewall Policy IDs referencing the group (computed) audit / reporting

πŸ“š Example Library

1 Β· Minimal call (empty group)
module "placeholder" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "placeholder"
  resource_group_name = "rg-network-prod"
  location            = "eastus"
}

πŸ”’ The empty call is safe: cidrs defaults to an empty set, producing a valid, empty group with nothing exposed.

2 Β· On-prem datacenter ranges
module "onprem" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "onprem-datacenters"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  cidrs = [
    "10.10.0.0/16", # primary DC
    "10.20.0.0/16", # DR DC
  ]
}

πŸ’‘ Reference this group's id from every firewall rule that trusts the corporate network β€” update the ranges here when a datacenter subnet changes and every rule follows.

3 Β· Partner / SaaS allowlist
module "partner_allowlist" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "partner-saas-allowlist"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  cidrs = [
    "203.0.113.0/24",  # partner egress range
    "198.51.100.42/32", # single SaaS webhook source
  ]

  tags = { purpose = "partner-integration" }
}

⚠️ Keep the set minimal and least-privilege β€” every range here widens each firewall rule that references the group.

4 Β· Empty-then-populate pattern
# Phase 1: create the group and wire its id into firewall rules first.
module "app_egress" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "app-egress-targets"
  resource_group_name = "rg-network-prod"
  location            = "eastus"
  # cidrs omitted β€” starts empty
}

# Phase 2 (a later change): add ranges. Because cidrs updates in place,
# the group is not replaced and the referencing rules are untouched.

ℹ️ Create the addressable object first, wire it everywhere, then fill it. The group's id is stable across the populate step.

5 Β· Mixed IPv4 and IPv6 ranges
module "dual_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "dual-stack-trusted"
  resource_group_name = "rg-network-prod"
  location            = "eastus2"

  cidrs = [
    "10.0.0.0/8",
    "2001:db8:1234::/48",
  ]
}

πŸ’‘ A single group can hold both IPv4 and IPv6 CIDRs; reference one group instead of maintaining parallel v4/v6 rule lists.

6 Β· Governance tagging
module "tagged" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "regulated-data-sources"
  resource_group_name = "rg-network-prod"
  location            = "centralus"

  cidrs = ["172.16.0.0/12"]

  tags = {
    environment  = "prod"
    data_class   = "restricted"
    cost_center  = "cc-4471"
    managed_by   = "terraform"
  }
}

ℹ️ Tags flow only onto the IP Group. They aid inventory and cost attribution; they do not affect which addresses the group contains.

7 Β· Individual host addresses
module "jump_hosts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "admin-jump-hosts"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  cidrs = [
    "10.50.1.10/32",
    "10.50.1.11/32",
    "10.50.1.12/32",
  ]
}

πŸ”’ Use /32 (IPv4) or /128 (IPv6) for single hosts so the intent β€” exactly these addresses β€” is explicit and reviewable.

8 Β· Per-environment groups via for_each
variable "env_ranges" {
  type = map(set(string))
  default = {
    dev  = ["10.60.0.0/16"]
    test = ["10.61.0.0/16"]
    prod = ["10.62.0.0/16"]
  }
}

module "env_ip_group" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"
  for_each = var.env_ranges

  name                = "trusted-${each.key}"
  resource_group_name = "rg-network-${each.key}"
  location            = "eastus"
  cidrs               = each.value

  tags = { environment = each.key }
}

πŸ’‘ One definition, three groups. Because each module instance is keyed by environment name, adding or removing an environment never re-indexes the others.

9 Β· Multiple purpose-built groups via for_each
locals {
  ip_groups = {
    onprem  = { name = "onprem", cidrs = ["10.0.0.0/8"] }
    azure   = { name = "azure-services", cidrs = ["20.0.0.0/8"] }
    partner = { name = "partner", cidrs = ["203.0.113.0/24"] }
  }
}

module "ip_groups" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"
  for_each = local.ip_groups

  name                = each.value.name
  resource_group_name = "rg-network-prod"
  location            = "eastus"
  cidrs               = each.value.cidrs
}

ℹ️ Reference module.ip_groups["onprem"].id, module.ip_groups["partner"].id, and so on from your firewall rules.

10 Β· Custom operation timeouts
module "slow_region" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "bulk-ranges"
  resource_group_name = "rg-network-prod"
  location            = "westus2"

  cidrs = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]

  timeouts = {
    create = "30m"
    delete = "30m"
  }
}

ℹ️ Timeouts are rarely needed for an IP Group, but the universal tail is available for regions or subscriptions with slow control-plane behavior.

11 Β· Referenced from an Azure Firewall network rule
module "onprem" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "onprem-datacenters"
  resource_group_name = "rg-network-prod"
  location            = "eastus"
  cidrs               = ["10.10.0.0/16"]
}

resource "azurerm_firewall_network_rule_collection" "example" {
  name                = "allow-onprem"
  azure_firewall_name = "afw-hub"
  resource_group_name = "rg-network-prod"
  priority            = 100
  action              = "Allow"

  rule {
    name                  = "onprem-to-app"
    source_ip_groups      = [module.onprem.id]
    destination_addresses = ["10.100.0.0/16"]
    destination_ports     = ["443"]
    protocols             = ["TCP"]
  }
}

πŸ’‘ The rule references the group by id. Change the group's cidrs and this rule's effective source set changes with it β€” no edit to the rule required.

12 Β· Referenced from a Firewall Policy rule collection
module "partner_allowlist" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "partner-saas-allowlist"
  resource_group_name = "rg-network-prod"
  location            = "eastus"
  cidrs               = ["203.0.113.0/24"]
}

resource "azurerm_firewall_policy_rule_collection_group" "example" {
  name               = "partner-rcg"
  firewall_policy_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/firewallPolicies/afwp-hub"
  priority           = 200

  network_rule_collection {
    name     = "allow-partner"
    priority = 100
    action   = "Allow"

    rule {
      name                  = "partner-inbound"
      source_ip_groups      = [module.partner_allowlist.id]
      destination_addresses = ["10.100.5.0/24"]
      destination_ports     = ["443"]
      protocols             = ["TCP"]
    }
  }
}

πŸ”’ Firewall Policy is the modern, centrally-managed path. The same IP Group id is reusable across many policy rule collections.

13 Β· Consuming resource-group module outputs
module "network_rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-network-prod"
  location = "eastus"
}

module "onprem" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "onprem-datacenters"
  resource_group_name = module.network_rg.name
  location            = module.network_rg.location
  cidrs               = ["10.10.0.0/16"]
}

ℹ️ Sourcing location from the resource group keeps the group in the same region as the rest of the network stack β€” a requirement for the firewall that will consume it.

14 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

# 1) The resource group that owns the network stack.
module "network_rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-network-prod"
  location = "eastus"
}

# 2) The centralized address set.
module "onprem" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-ip-group.git?ref=v1.0.0"

  name                = "onprem-datacenters"
  resource_group_name = module.network_rg.name
  location            = module.network_rg.location

  cidrs = [
    "10.10.0.0/16",
    "10.20.0.0/16",
  ]

  tags = { environment = "prod", owner = "network-team" }
}

# 3) A Firewall Policy rule collection that references the group by id.
resource "azurerm_firewall_policy_rule_collection_group" "hub" {
  name               = "hub-rcg"
  firewall_policy_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/firewallPolicies/afwp-hub"
  priority           = 300

  network_rule_collection {
    name     = "allow-onprem-to-app"
    priority = 100
    action   = "Allow"

    rule {
      name                  = "onprem-https"
      source_ip_groups      = [module.onprem.id]
      destination_addresses = ["10.100.0.0/16"]
      destination_ports     = ["443"]
      protocols             = ["TCP"]
    }
  }
}

πŸ’‘ The resource group's outputs feed the IP Group; the IP Group's id feeds the firewall policy rule. When the corporate ranges change, edit module.onprem.cidrs once β€” the rule collection needs no change at all.


πŸ“₯ Inputs

Required

Name Type Description
name string Name of the IP Group (force-new).
resource_group_name string Existing resource group to contain the group (force-new).
location string Azure region (force-new).

Optional

Name Type Default Description
cidrs set(string) [] IP/CIDR ranges centralized by the group; updates in place. πŸ”΄ Mutually exclusive with terraform-azurerm-ip-group-cidr for the same group.
tags map(string) {} Tags applied to the IP Group.
timeouts object(...) null Optional create/read/update/delete timeouts.
Full input schemas
variable "name" {
  type = string
  # Immutable: changing forces replacement of the IP Group.
}

variable "resource_group_name" {
  type = string
  # Existing group; this module does not create it. Immutable (force-new).
}

variable "location" {
  type = string
  # US regions e.g. eastus / eastus2 / westus2 / centralus. Immutable (force-new).
}

variable "cidrs" {
  type    = set(string)
  default = []
  # Set of IP addresses ("10.0.0.1") or CIDR ranges ("10.0.0.0/24",
  # "2001:db8::/48"). Updates in place; does not replace the group or
  # re-create consuming firewall rules. Validated as IP/CIDR-shaped.
}

variable "tags" {
  type    = map(string)
  default = {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Notes
id IP Group Resource ID emitted first; reference from firewall rules
name IP Group name β€”
cidrs the centralized IP/CIDR set β€”
location region the group is deployed in β€”
resource_group_name the containing resource group β€”
firewall_ids Azure Firewall IDs referencing the group computed by Azure
firewall_policy_ids Firewall Policy IDs referencing the group computed by Azure

🧠 Architecture Notes

  • Single owned resource. Per this module suite's single-primary-resource rule, the module owns exactly one resource, azurerm_ip_group.this, and emits its id first.
  • cidrs is the whole point. The address set is carried inline on the keystone. It updates in place, so the group's id is stable while its contents change β€” this is what lets many firewall rules track one address list.
  • Force-new fields bite. name, resource_group_name, and location each force replacement. A replacement changes the Resource ID, which breaks every rule that referenced the old ID until they are reconciled β€” treat these three as effectively immutable once the group is referenced.
  • Do not double-manage the address set. Managing the same group's ranges both here (inline cidrs) and through a separate per-CIDR resource elsewhere conflicts and produces perpetual diffs. This module owns the set inline.
  • Computed reference outputs. firewall_ids / firewall_policy_ids reflect the rules Azure sees referencing the group; they are read-only and are useful for audit, not for wiring.
  • features {} is the caller's. The module declares no provider block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} } block β€” that is expected.

🧱 Design Principles

An IP Group is a reusability and consistency control, not an exposure control β€” it carries no public-access, TLS, or encryption knobs. The relevant secure-by-default posture:

Concern Secure default (empty call) Opt-out (caller must type it)
Address set cidrs = [] β€” a valid, empty group; nothing is trusted implicitly add explicit ranges
Least privilege narrow ranges recommended; /32 and /128 for single hosts supply broader CIDRs deliberately
Change surface one group referenced by many rules; edit ranges in one place copy addresses into individual rules instead (discouraged)
Tagging tags = {} supply governance tags

Rule of thumb: the empty call produces the safe, empty object; every widening of the trusted address set is an explicit, reviewable caller change.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 β€” never a branch.
  • This library is plan-only during authoring. A human runs terraform plan / apply from CI against real credentials.
  • The caller supplies provider "azurerm" { features {} } and authentication (Azure CLI, Managed Identity, or OIDC).

πŸ§ͺ Testing

The offline proof gate is what this module ships green against:

Gate What it proves What it does not do
terraform init -backend=false providers resolve against the ~> 4.0 pin no backend, no cloud
terraform validate the configuration is internally consistent and type-correct against the pinned schema does not call Azure
terraform fmt -check canonical formatting β€”

Only terraform plan (run by a human from CI, against real credentials) exercises the ARM API. validate and fmt never reach Azure.


πŸ’¬ Example Output

$ terraform output
id                    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/ipGroups/onprem-datacenters"
name                  = "onprem-datacenters"
cidrs                 = toset([
  "10.10.0.0/16",
  "10.20.0.0/16",
])
location              = "eastus"
resource_group_name   = "rg-network-prod"
firewall_ids          = []
firewall_policy_ids   = [
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/firewallPolicies/afwp-hub",
]

πŸ” Troubleshooting

Symptom Cause Fix
Plan shows the group being destroyed and re-created after a rename name is force-new Keep the name stable; if a rename is required, reconcile every rule that referenced the old id.
A firewall rule stops matching after moving the group location or resource_group_name changed, replacing the group and changing its id Recreate/reference the new id; keep the group in the firewall's region.
Perpetual diff on cidrs The same group's ranges are managed both here and elsewhere Manage the address set in exactly one place β€” this module's inline cidrs.
provider "azurerm" ... features initialization error Caller root module has no features {} block Add provider "azurerm" { features {} } to the caller; the module never carries it.
Error: Invalid value for variable on cidrs An entry is not a valid IP/CIDR Use a bare address (10.0.0.1) or CIDR (10.0.0.0/24, 2001:db8::/48).
Group appears with no referencing rules firewall_ids/firewall_policy_ids are empty Expected until a rule references the group's id; these outputs are computed by Azure.

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."