Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure ExpressRoute Circuit Peering Terraform Module

Establishes a routing domain on an ExpressRoute circuit — private or Microsoft peering, with the BGP session, VLAN, address prefixes and optional IPv6. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • ☁️ Creates one azurerm_express_route_circuit_peering (the keystone this) — a routing domain on an existing circuit.
  • 🔴 There is no name argument. The peering_type is the ARM name, which is why the provider warns that a second peering of the same type overwrites the first rather than erroring.
  • 🧬 Types the microsoft_peering_config block and the doubly-nested ipv6 block (which contains its own microsoft_peering) as deeply-typed objects, rendered with nested dynamic blocks.
  • 🔒 Takes shared_key as a sensitive input (the BGP MD5 key) and emits has_shared_key as presence-only.
  • ⚠️ Reports that AzurePublicPeering is deprecated, that advertised prefixes are validated out of band, and that exceeding a prefix limit terminates the BGP session.

💡 Why it matters: five of this resource's rules are cross-field and documented, so most of them can be enforced at plan time. The three that cannot — the overwrite hazard, out-of-band prefix validation, and whether the far-end router is configured — are each emitted as an output, because none of them produces an error.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  circ["terraform-azurerm-express-route-circuit"]
  mod["terraform-azurerm-express-route-circuit-peering"]
  rf["terraform-azurerm-route-filter  MicrosoftPeering only"]
  gwconn["terraform-azurerm-express-route-connection  needs a PRIVATE peering"]
  grconn["terraform-azurerm-express-route-circuit-connection  Global Reach, needs a PRIVATE peering"]
  auth["terraform-azurerm-express-route-circuit-authorization"]
  router["Your router or provider  BGP session, VLAN, ASN, shared key"]

  rg -->|"name"| circ
  circ -->|"name as express_route_circuit_name"| mod
  rf -->|"route_filter_id"| mod
  mod -->|"id as express_route_circuit_peering_id"| gwconn
  mod -->|"id as peering_id"| grconn
  auth -->|"authorization_key for a cross subscription peer"| grconn
  mod -->|"azure_asn and the azure ports, handed over"| router

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#00243c,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class mod this;
  class circ keystone;
  class rg,rf,gwconn,grconn,auth,router neutral;
Loading

A peering is the join everything else on a circuit hangs from. Both terraform-azurerm-express-route-connection (on-premises to Azure) and terraform-azurerm-express-route-circuit-connection (Global Reach) consume a private peering's id. A route filter attaches only to a Microsoft peering. And the far right of the diagram is the half Terraform cannot reach: your router still needs the VLAN, the prefixes, the ASN and the shared key.


🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs  there is NO name argument"]
    pt["peering_type  IS the name, so a second of one type overwrites"]
    cn["express_route_circuit_name  force-new"]
    rgn["resource_group_name  force-new"]
    vl["vlan_id  1 to 4094 per 802.1Q"]
    p4["primary and secondary peer address prefix  each a /30"]
    v4["ipv4_enabled  defaults true, and carries the prefix rule"]
    sk["shared_key  SENSITIVE, max 25 chars"]
    asn["peer_asn  64496 to 64511 rejected"]
    rf["route_filter_id  MicrosoftPeering only"]
    mpc["microsoft_peering_config  advertised prefixes REQUIRED here"]
    v6["ipv6  nests its own microsoft_peering, prefixes OPTIONAL there"]
  end

  res["azurerm_express_route_circuit_peering.this"]

  subgraph outputs["Outputs"]
    oid["id  ends in the peering type"]
    oaz["azure_asn and the two azure ports  hand these to your router"]
    opriv["is_private_peering  what downstream modules require"]
    odep["uses_the_deprecated_public_peering_type"]
    okey["has_shared_key  presence only, via nonsensitive"]
    oover["only_one_peering_of_each_type_per_circuit_and_a_second_overwrites_it"]
    oval["advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed"]
    olim["exceeding_a_prefix_limit_terminates_the_bgp_session"]
  end

  pt --> res
  cn --> res
  rgn --> res
  vl --> res
  p4 --> res
  v4 --> res
  sk --> res
  asn --> res
  rf --> res
  mpc --> res
  v6 --> res
  res --> oid
  res --> oaz
  res --> opriv
  res --> odep
  res --> okey
  res --> oover
  res --> oval
  res --> olim

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
  class res this;
  class pt,cn,rgn,vl,p4,v4,sk,asn,rf,mpc,v6,oid,oaz,opriv,odep,okey,oover,oval,olim neutral;
Loading
Resource Cardinality Purpose
azurerm_express_route_circuit_peering.this single One routing domain on a circuit: the BGP session's addressing, VLAN, ASN and optional IPv6.

Twelve arguments, of which only two are force-new — the circuit and the resource group. Everything about the peering's configuration updates in place.


✅ Provider / Versions

Item 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.
Module type Standalone — one resource, no children.

Schema notes that bite:

  • 🔴 No name argument. ARM names a peering after its type, so the Resource ID ends in /peerings/AzurePrivatePeering and similar.
  • 🔴 A second peering of the same type overwrites the first. The provider states it: "only one Peering of each Type can be created. Attempting to create multiple peerings of the same type will overwrite the original peering." There is no name to collide, so there is no error.
  • 🔴 AzurePublicPeering is deprecated. Microsoft's routing documentation refers to "the private, Microsoft, and public (deprecated) peering paths" and states that new circuits can include two peerings: private and Microsoft. Accepted here, and reported.
  • 🔴 Five cross-field rules are documented, and all five are enforced: IPv4 prefixes required when IPv4 is configured; microsoft_peering_config only for MicrosoftPeering; route_filter_id only for MicrosoftPeering; ipv6.route_filter_id likewise; and ipv6 only for MicrosoftPeering or AzurePrivatePeering.
  • 🔴 The IPv4 and IPv6 forms of the same block disagree on requiredness. microsoft_peering_config.advertised_public_prefixes is required; ipv6.microsoft_peering.advertised_public_prefixes is optional. Conversely ipv6's two address prefixes are required where the top-level IPv4 ones are optional.
  • 🔴 Advertised prefixes are validated against a routing registry out of band, and a failure leaves the peering in Validation needed. The provider exposes no attribute for that state.
  • ⚠️ Exceeding a prefix limit terminates the BGP session rather than dropping the excess. Limits: 4000 IPv4 private (10,000 Premium), 100 IPv6 private, 200 either family on Microsoft peering.
  • ⚠️ shared_key maxes out at 25 characters, which the provider publishes.
  • ⚠️ The prefixes are /30s — published, and enforced here.
  • ⚠️ No tags attribute. With no name and no tags, a peering carries no metadata of its own.
  • ⚠️ All four timeouts exist, unlike several ExpressRoute siblings which have three.
  • ⚠️ lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the peering Contributor or Network Contributor the ExpressRoute circuit
Read it Reader the peering
Attach a route filter Contributor on both the peering and the route filter
Consume the peering downstream Contributor a gateway, connection or Global Reach pairing
Validate advertised public prefixes not a role — a support ticket with a Letter of Authorization Microsoft support

🔴 The last row is not an Azure permission at all, and it is where a Microsoft peering most often stalls. Microsoft checks the advertised prefixes and ASN against ARIN, APNIC, AFRINIC, LACNIC, RIPENCC, RADB or ALTDB; if the prefixes are assigned to you by another entity and that is not recorded, the automatic check does not complete and the peering sits in Validation needed until a support case with documentation clears it.

🔒 shared_key is the only sensitive value here. It is redacted in plan output and written to state in plaintext, so an encrypted, access-controlled backend is the control that matters. The ASN, ports and prefixes are all public routing information.


Azure Prerequisites

  • An existing ExpressRoute circuit in a provisioned state, and its name plus resource group.
  • No existing peering of the same type on that circuit, or this will overwrite it.
  • A VLAN ID allocated to you by your connectivity provider, distinct from any other peering's on the same circuit.
  • Two /30 subnets for the primary and secondary links — public and registered to you if this is a Microsoft peering.
  • For MicrosoftPeering: public prefixes registered to you in a routing registry, usually a route filter, and tolerance for out-of-band prefix validation.
  • The far end configured — your router or your provider's, with the matching VLAN, prefixes, ASN and shared key. Terraform does not touch it.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; this module declares none of these.

📁 Module Structure

terraform-azurerm-express-route-circuit-peering/
├── providers.tf     # required_version + pinned azurerm; no provider block
├── variables.tf     # 13 inputs, including the microsoft_peering_config and doubly-nested ipv6 objects
├── main.tf          # the keystone `this` + nested dynamic blocks + the derived locals
├── outputs.tf       # id first, then the computed BGP values, then the facts that produce no error
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "private_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  primary_peer_address_prefix   = "10.0.0.0/30"
  secondary_peer_address_prefix = "10.0.0.4/30"

  peer_asn   = 65001
  shared_key = var.bgp_shared_key
}

⚠️ There is no name. This peering will be called AzurePrivatePeering in Azure, and a second resource with the same type on this circuit would overwrite it.

🔒 var.bgp_shared_key should be declared sensitive = true and provisioned out of band.


🔌 Cross-Module Contract

Consumes

Input Type Source
express_route_circuit_name string terraform-azurerm-express-route-circuit output name — force-new
resource_group_name string terraform-azurerm-resource-group output name — force-new
peering_type string caller — required, and it is also the peering's name
vlan_id number your connectivity provider — required, 1–4094
primary_peer_address_prefix / secondary_peer_address_prefix string caller — /30 each, required when IPv4 is on
ipv4_enabled bool caller — defaults true; carries the prefix rule
shared_key string out of band — sensitive, max 25 characters
peer_asn number your ASN — optional; Azure computes one if omitted
route_filter_id string terraform-azurerm-route-filter output id — MicrosoftPeering only
microsoft_peering_config object(...) caller — required for MicrosoftPeering on IPv4
ipv6 object(...) caller — doubly nested; not available on the public type
timeouts object(...) caller — all four operations

Emits

Output Description Consumed by
id The peering's Resource ID. terraform-azurerm-express-route-connection, terraform-azurerm-express-route-circuit-connection
peering_type / express_route_circuit_name / resource_group_name / vlan_id Identity and addressing. review
primary_peer_address_prefix / secondary_peer_address_prefix / ipv4_enabled The IPv4 link configuration. review
peer_asn Yours, or Azure's computed value. your router
azure_asn / primary_azure_port / secondary_azure_port Computed. Hand these to the far end. your router
gateway_manager_etag Computed, internal. completeness
route_filter_id The applied filter, or null. Microsoft-peering review
has_shared_key Derived, not sensitive. Presence only. security review
is_private_peering Derived. What downstream modules require. design review
is_microsoft_peering Derived. design review
uses_the_deprecated_public_peering_type Derived. A true is a finding. architecture review
ipv6_enabled Derived, resolving the provider's true default. review
advertised_ipv4_prefix_count / advertised_ipv6_prefix_count Derived. prefix-limit review
only_one_peering_of_each_type_per_circuit_and_a_second_overwrites_it Always true. change review
advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed Always true. runbook
exceeding_a_prefix_limit_terminates_the_bgp_session Always true. capacity review
the_type_is_the_name_and_this_resource_has_no_name_argument Always true. design review
terraform_only_waits_for_the_control_plane_write Always true. runbook
this_resource_carries_no_tags Always true. tagging review

📚 Example Library

1 · Private peering — the common case
module "private_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = "erc-prod"
  resource_group_name        = "rg-expressroute-prod"

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  primary_peer_address_prefix   = "10.0.0.0/30"
  secondary_peer_address_prefix = "10.0.0.4/30"
}

ℹ️ This is the peering that reaches virtual networks, and the one every downstream module in this library wants. Private peering accepts private or public addressing, and needs no prefix registration.

2 · Microsoft peering with a route filter
module "route_filter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-filter.git?ref=v1.0.0"

  name                = "rf-m365"
  resource_group_name = module.rg.name
  location            = "eastus2"

  # A filter with no rules advertises NOTHING, which withholds every Microsoft
  # prefix from this peering. Azure supports exactly one rule, so all the
  # communities you want go in its single `communities` list.
  rules = {
    microsoft_365 = {
      communities = [
        "12076:5010", # Exchange Online
        "12076:5020", # SharePoint Online / OneDrive
        "12076:5030", # Skype for Business / Teams
      ]
    }
  }
}

module "microsoft_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "MicrosoftPeering"
  vlan_id      = 200

  primary_peer_address_prefix   = "203.0.113.0/30"
  secondary_peer_address_prefix = "203.0.113.4/30"

  peer_asn        = 65001
  route_filter_id = module.route_filter.id

  microsoft_peering_config = {
    advertised_public_prefixes = ["203.0.113.0/24"]
    routing_registry_name      = "ARIN"
    customer_asn               = 65001
  }
}

⚠️ A Microsoft peering without a route filter often carries nothing useful. The filter is what selects which Microsoft service communities you receive, so its absence is a common reason a technically-correct peering appears to do nothing.

🔴 These prefixes must be public addresses registered to you, and Microsoft validates that out of band — see example 9.

3 · A BGP shared key
variable "bgp_shared_key" {
  description = "BGP MD5 key, shared with the connectivity provider. Max 25 characters."
  type        = string
  sensitive   = true
}

module "private_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  primary_peer_address_prefix   = "10.0.0.0/30"
  secondary_peer_address_prefix = "10.0.0.4/30"

  shared_key = var.bgp_shared_key
}

output "bgp_is_authenticated" {
  value = module.private_peering.has_shared_key
}

⚠️ The provider publishes a 25-character maximum, which is short enough that a password generator will often exceed it. The module checks the length and does not echo the value.

🔒 sensitive = true redacts plan output; it does not encrypt state. Use an encrypted, access-controlled backend. has_shared_key exists so a review can confirm authentication is on without reading the key.

4 · IPv6, including its nested Microsoft-peering block
module "dual_stack_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "MicrosoftPeering"
  vlan_id      = 200

  primary_peer_address_prefix   = "203.0.113.0/30"
  secondary_peer_address_prefix = "203.0.113.4/30"

  microsoft_peering_config = {
    advertised_public_prefixes = ["203.0.113.0/24"] # REQUIRED here
  }

  ipv6 = {
    primary_peer_address_prefix   = "2002:db00::/126" # REQUIRED inside ipv6
    secondary_peer_address_prefix = "2003:db00::/126" # REQUIRED inside ipv6

    microsoft_peering = {
      advertised_public_prefixes = ["2002:db00::/126"] # OPTIONAL here
    }
  }
}

🔴 Note the requiredness flips between the two forms. microsoft_peering_config.advertised_public_prefixes is required; the same field inside ipv6.microsoft_peering is optional. And ipv6's two address prefixes are required, where their top-level IPv4 equivalents are optional. That asymmetry is the provider's, not this module's.

ℹ️ Supplying the block enables IPv6. The provider defaults ipv6.enabled to true, so omitting the flag does not mean the feature is off — ipv6_enabled resolves that for a reader.

5 · An IPv6-only peering
module "ipv6_only_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  # Turning IPv4 off is what makes the top-level prefixes unnecessary.
  ipv4_enabled = false

  ipv6 = {
    primary_peer_address_prefix   = "2002:db00::/126"
    secondary_peer_address_prefix = "2003:db00::/126"
  }
}

⚠️ ipv4_enabled defaults to true, so leaving it alone while omitting the prefixes is a validation error rather than an IPv6-only peering. The check sits on ipv4_enabled and its message says which two values are missing.

6 · Both peerings on one circuit
module "private_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  primary_peer_address_prefix   = "10.0.0.0/30"
  secondary_peer_address_prefix = "10.0.0.4/30"
}

module "microsoft_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  # A DIFFERENT type, and a DIFFERENT VLAN.
  peering_type = "MicrosoftPeering"
  vlan_id      = 200

  primary_peer_address_prefix   = "203.0.113.0/30"
  secondary_peer_address_prefix = "203.0.113.4/30"

  microsoft_peering_config = {
    advertised_public_prefixes = ["203.0.113.0/24"]
  }
}

✅ This is safe because the types differ. Microsoft documents two routing domains per circuit, and each is a separate pair of BGP sessions needing its own VLAN.

🔴 Two resources of the SAME type would not be safe — see example 7.

7 · The overwrite hazard
# DO NOT DO THIS. Both declare the same peering, because the type is the name.
module "microsoft_peering_a" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = "erc-prod"
  resource_group_name        = "rg-expressroute-prod"
  peering_type               = "MicrosoftPeering"
  vlan_id                    = 200

  primary_peer_address_prefix   = "203.0.113.0/30"
  secondary_peer_address_prefix = "203.0.113.4/30"

  microsoft_peering_config = { advertised_public_prefixes = ["203.0.113.0/24"] }
}

module "microsoft_peering_b" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = "erc-prod"
  resource_group_name        = "rg-expressroute-prod"
  peering_type               = "MicrosoftPeering" # <-- same type, same circuit
  vlan_id                    = 300

  primary_peer_address_prefix   = "198.51.100.0/30"
  secondary_peer_address_prefix = "198.51.100.4/30"

  microsoft_peering_config = { advertised_public_prefixes = ["198.51.100.0/24"] }
}

output "read_this_first" {
  value = module.microsoft_peering_a.only_one_peering_of_each_type_per_circuit_and_a_second_overwrites_it
}

🔴 There is no error, and no drift that ever settles. The provider states that a second peering of the same type overwrites the original — because ARM names a peering after its type, so these are two writes to one object. Both applies report success and the last one wins, every time.

⚠️ The same trap spans states. Two separate configurations each declaring a MicrosoftPeering on one circuit will fight forever. Exactly one Terraform resource per circuit per type.

8 · The deprecated public peering type
module "legacy_public_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = "erc-legacy"
  resource_group_name        = "rg-expressroute-legacy"

  # Accepted, and reported. Do not choose this for new work.
  peering_type = "AzurePublicPeering"
  vlan_id      = 300

  primary_peer_address_prefix   = "198.51.100.0/30"
  secondary_peer_address_prefix = "198.51.100.4/30"
}

output "architecture_finding" {
  value = module.legacy_public_peering.uses_the_deprecated_public_peering_type
}

🔴 Microsoft marks Azure public peering deprecated — its routing documentation refers to "the private, Microsoft, and public (deprecated) peering paths", and states that new circuits can include two peerings: private and Microsoft.

ℹ️ The module accepts it anyway, deliberately. An existing circuit may genuinely carry one, and refusing the value would make that estate undeclarable — worse than declaring it with a flag attached. Microsoft peering is the replacement, and it is not a like-for-like swap: it needs registered public prefixes and usually a route filter. Note ipv6 is not available on the public type at all.

9 · Prefix validation happens outside Terraform
output "why_a_clean_apply_is_not_enough" {
  value = module.microsoft_peering.advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed
}

Check the state Terraform cannot see:

az network express-route peering show \
  --circuit-name "erc-prod" -g "rg-expressroute-prod" --name "MicrosoftPeering" \
  --query "microsoftPeeringConfig.advertisedPublicPrefixesState"

🔴 Validation needed means the peering exists and carries nothing. Microsoft checks the advertised prefixes and ASN against ARIN, APNIC, AFRINIC, LACNIC, RIPENCC, RADB or ALTDB. If the prefixes are assigned to you by another entity and that is not recorded, the automatic check does not complete and you must open a support case with documentation such as a Letter of Authorization.

⚠️ The provider exposes no attribute for that state, so Terraform reports an identical clean apply either way and re-planning reveals nothing.

10 · What the validations reject
# Rejected — right value, wrong case. An UNRECOGNISED value is allowed through,
# because the provider says "acceptable values include".
peering_type = "microsoftpeering"

# Rejected — a VLAN tag is a 12-bit field under 802.1Q, so 4095 is reserved.
vlan_id = 4095

# Rejected — the provider publishes /30 for a BGP link.
primary_peer_address_prefix = "10.0.0.0/29"

# Rejected — IANA reserves 64496-64511 for documentation, and Microsoft
# documents them as not allowed in the AS path.
peer_asn = 64500

# Rejected — a route filter is only available for MicrosoftPeering.
peering_type    = "AzurePrivatePeering"
route_filter_id = "/subscriptions/.../routeFilters/rf1"

# Rejected — ipv6 is not available on the deprecated public type.
peering_type = "AzurePublicPeering"
ipv6         = { primary_peer_address_prefix = "2002:db00::/126", secondary_peer_address_prefix = "2003:db00::/126" }

# Rejected — the registry names are upper case. An unknown one is allowed.
microsoft_peering_config = { advertised_public_prefixes = ["203.0.113.0/24"], routing_registry_name = "arin" }

ℹ️ Both near-miss checks allow the unknown and reject the near miss. peering_type because the provider's list is not exhaustive; routing_registry_name because the provider's own examples say RIPE while Microsoft's registry list says RIPENCC — so the documented set is neither closed nor internally consistent, and both spellings are accepted.

11 · Terraform stops at the control plane
output "hand_these_to_the_far_end" {
  value = {
    azure_asn            = module.private_peering.azure_asn
    primary_azure_port   = module.private_peering.primary_azure_port
    secondary_azure_port = module.private_peering.secondary_azure_port
    vlan_id              = module.private_peering.vlan_id
    primary_prefix       = module.private_peering.primary_peer_address_prefix
    secondary_prefix     = module.private_peering.secondary_peer_address_prefix
  }
}

output "the_session_may_still_be_down" {
  value = module.private_peering.terraform_only_waits_for_the_control_plane_write
}

⚠️ A successful apply means Azure accepted the configuration, not that BGP is up. The other half of a peering is on equipment Terraform cannot reach, and it needs the matching VLAN, prefixes, ASN and shared key.

💡 That is why azure_asn and the two port attributes exist — they are computed precisely because the far end needs them.

12 · 🏗️ End-to-end composition — a circuit with both peerings, wired downstream
provider "azurerm" {
  features {}
}

variable "bgp_shared_key" {
  description = "BGP MD5 key. Max 25 characters. Provision out of band; never commit it."
  type        = string
  sensitive   = true
}

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

  name     = "rg-expressroute-prod"
  location = "eastus2"

  tags = { environment = "prod" }
}

# 1 · The circuit. Tag here -- a peering has no tags.
module "circuit" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit.git?ref=v1.0.0"

  name                = "erc-prod"
  resource_group_name = module.rg.name
  location            = "eastus2"

  tags = { environment = "prod", cost_owner = "network-platform" }
}

# 2 · This module: the private peering. Everything downstream needs THIS one.
module "private_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "AzurePrivatePeering"
  vlan_id      = 100

  primary_peer_address_prefix   = "10.0.0.0/30"
  secondary_peer_address_prefix = "10.0.0.4/30"

  peer_asn   = 65001
  shared_key = var.bgp_shared_key
}

# 3 · A Microsoft peering alongside it -- different type, different VLAN, so no
#     overwrite. A route filter is what makes it useful.
module "route_filter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-filter.git?ref=v1.0.0"

  name                = "rf-m365"
  resource_group_name = module.rg.name
  location            = "eastus2"

  # A filter with no rules advertises NOTHING, which withholds every Microsoft
  # prefix from this peering. Azure supports exactly one rule, so all the
  # communities you want go in its single `communities` list.
  rules = {
    microsoft_365 = {
      communities = [
        "12076:5010", # Exchange Online
        "12076:5020", # SharePoint Online / OneDrive
        "12076:5030", # Skype for Business / Teams
      ]
    }
  }
}

module "microsoft_peering" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-circuit-peering.git?ref=v1.0.0"

  express_route_circuit_name = module.circuit.name
  resource_group_name        = module.rg.name

  peering_type = "MicrosoftPeering"
  vlan_id      = 200

  primary_peer_address_prefix   = "203.0.113.0/30"
  secondary_peer_address_prefix = "203.0.113.4/30"

  peer_asn        = 65001
  route_filter_id = module.route_filter.id

  microsoft_peering_config = {
    advertised_public_prefixes = ["203.0.113.0/24"]
    routing_registry_name      = "ARIN"
  }
}

# 4 · On-premises to Azure, through a Virtual WAN hub. Consumes the PRIVATE peering.
module "express_route_gateway" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-gateway.git?ref=v1.0.0"

  name                = "ergw-platform"
  resource_group_name = module.rg.name
  location            = "eastus2"
  virtual_hub_id      = var.platform_virtual_hub_id
  scale_units         = 1
}

module "gateway_connection" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-express-route-connection.git?ref=v1.0.0"

  name                             = "erc-platform"
  express_route_gateway_id          = module.express_route_gateway.id
  express_route_circuit_peering_id = module.private_peering.id
}

output "confirm_the_peering_is_private" {
  description = "Both downstream consumers require a private peering."
  value       = module.private_peering.is_private_peering
}

output "hand_these_to_the_provider" {
  value = {
    azure_asn            = module.private_peering.azure_asn
    primary_azure_port   = module.private_peering.primary_azure_port
    secondary_azure_port = module.private_peering.secondary_azure_port
  }
}

output "still_to_verify_outside_terraform" {
  value = {
    bgp_may_be_down       = module.private_peering.terraform_only_waits_for_the_control_plane_write
    prefixes_need_a_check = module.microsoft_peering.advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed
  }
}

Where var.platform_virtual_hub_id is a Virtual WAN hub ID.

💡 Steps 2 and 3 coexist safely because their types differ. That is the whole discipline with this resource: one module instance per circuit per peering type.

⚠️ Two things remain unverified after a clean apply — whether BGP came up on the far end, and whether the Microsoft peering's prefixes passed registry validation. Neither is visible to Terraform, which is why both are outputs.

ℹ️ Global Reach is deliberately absent here. It also consumes module.private_peering.id, but it belongs to terraform-azurerm-express-route-circuit-connection and joins two circuits rather than reaching Azure.


📥 Inputs

Input Type Default Notes
express_route_circuit_name string — Required. Force-new.
resource_group_name string — Required. Force-new.
peering_type string — Required. Also the peering's name.
vlan_id number — Required. 1–4094 (802.1Q).
primary_peer_address_prefix string null /30. Required when IPv4 is on.
secondary_peer_address_prefix string null /30. Required when IPv4 is on.
ipv4_enabled bool true Carries the prefix cross-field rule.
shared_key string null Sensitive. Max 25 characters.
peer_asn number null Azure computes one if omitted. 64496–64511 rejected.
route_filter_id string null MicrosoftPeering only.
microsoft_peering_config object(...) null Required for MicrosoftPeering on IPv4.
ipv6 object(...) null Doubly nested. Not on the public type.
timeouts object(...) null All four operations.
Full schemas
variable "express_route_circuit_name" { type = string }
variable "resource_group_name" { type = string }

# AzurePrivatePeering | AzurePublicPeering (deprecated) | MicrosoftPeering
variable "peering_type" { type = string }

variable "vlan_id" { type = number }

variable "primary_peer_address_prefix" {
  type    = string
  default = null
}

variable "secondary_peer_address_prefix" {
  type    = string
  default = null
}

variable "ipv4_enabled" {
  type    = bool
  default = true
}

variable "shared_key" {
  type      = string
  default   = null
  sensitive = true
}

variable "peer_asn" {
  type    = number
  default = null
}

variable "route_filter_id" {
  type    = string
  default = null
}

variable "microsoft_peering_config" {
  type = object({
    advertised_public_prefixes = list(string) # REQUIRED here
    advertised_communities     = optional(list(string))
    customer_asn               = optional(number)
    routing_registry_name      = optional(string)
  })
  default = null
}

variable "ipv6" {
  type = object({
    primary_peer_address_prefix   = string # REQUIRED inside ipv6
    secondary_peer_address_prefix = string # REQUIRED inside ipv6
    enabled                       = optional(bool)
    route_filter_id               = optional(string)
    microsoft_peering = optional(object({
      advertised_public_prefixes = optional(list(string)) # OPTIONAL here
      advertised_communities     = optional(list(string))
      customer_asn               = optional(number)
      routing_registry_name      = optional(string)
    }))
  })
  default = null
}

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

ℹ️ There is no tags variable and no name variable, because the resource has neither.


🧾 Outputs

Output Description Kind
id Passthrough
peering_type The effective routing domain, which is also the peering's name in Azure Passthrough
express_route_circuit_name The circuit this peering belongs to Passthrough
resource_group_name The resource group holding the circuit Passthrough
vlan_id The VLAN this peering is established on Passthrough
primary_peer_address_prefix The /30 for the primary BGP link, or null for an IPv6-only peering Passthrough
secondary_peer_address_prefix The /30 for the secondary BGP link Passthrough
ipv4_enabled Whether IPv4 peering is enabled Passthrough
peer_asn Passthrough
azure_asn Passthrough
primary_azure_port Computed: Microsoft's primary port for this peering Passthrough
secondary_azure_port Computed: Microsoft's secondary port for this peering Passthrough
gateway_manager_etag Computed: an internal ETag Azure maintains for the peering Passthrough
route_filter_id Passthrough
has_shared_key Passthrough
is_private_peering Passthrough
is_microsoft_peering Passthrough
uses_the_deprecated_public_peering_type Passthrough
ipv6_enabled Passthrough
advertised_ipv4_prefix_count Passthrough
advertised_ipv6_prefix_count How many IPv6 public prefixes are declared in ipv6.microsoft_peering Passthrough
only_one_peering_of_each_type_per_circuit_and_a_second_overwrites_it Constant
advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed Constant
exceeding_a_prefix_limit_terminates_the_bgp_session Constant
the_type_is_the_name_and_this_resource_has_no_name_argument Constant
terraform_only_waits_for_the_control_plane_write Constant
this_resource_carries_no_tags Constant
editing_peering_type_does_not_repoint_this_resource Constant true, and a trap rather than a convenience Constant

🔒 Only shared_key is sensitive, and it is an input rather than an output. has_shared_key reports its presence without revealing it. Every other value here is public routing information — an ASN, a port, a prefix.


🧠 Architecture Notes

The type is the name, and that single fact explains this resource's worst behaviour. There is no name argument: ARM names a peering after its peering_type, so the Resource ID ends in /peerings/AzurePrivatePeering. The provider therefore warns that only one peering of each type can exist on a circuit and that a second overwrites the original — not because of a rule, but because there is no name to collide, so the second write simply lands on the same object. Two Terraform configurations each declaring a MicrosoftPeering on one circuit will each report success and each undo the other, forever, with no drift that ever settles. Exactly one module instance per circuit per type.

Five of its rules are cross-field and documented, which is unusual and worth exploiting. IPv4 prefixes are required when IPv4 is configured; microsoft_peering_config applies only to MicrosoftPeering; route_filter_id and ipv6.route_filter_id likewise; and ipv6 may be specified only for MicrosoftPeering or AzurePrivatePeering. All five are enforced here, each placed on the variable being constrained and referencing peering_type one-directionally — which is required, because a validation must reference its own variable and two variables validating each other is a cycle. The consequence to know: when peering_type fails its own check, every validation on the variables that reference it is skipped, so a short error list is not proof the rest are fine.

The IPv4 and IPv6 forms of the same block disagree about what is required. microsoft_peering_config.advertised_public_prefixes is required; the identical field inside ipv6.microsoft_peering is optional. Conversely ipv6's two address prefixes are required, where their top-level IPv4 equivalents are merely documented as required. That is the provider's schema, not this module's typing, and it is named rather than smoothed because a caller moving between the two will otherwise be surprised.

The ipv6 block is the only doubly-nested structure in this stretch of the library. It contains its own microsoft_peering block, rendered here with a dynamic block inside a dynamic block. Both levels are typed as object(...) with optional(...) throughout, so a caller supplying a partial IPv6 configuration gets a type error rather than a silently dropped field.

AzurePublicPeering is deprecated, and the module ships it anyway. Microsoft's routing documentation refers to "the private, Microsoft, and public (deprecated) peering paths" and states that new circuits can include two peerings — private and Microsoft. That is the enum-scoped case of this suite's retirement rule: the resource is entirely current, one of its accepted values is not. Refusing the value would make an existing circuit that carries a public peering undeclarable, which is worse than declaring it with uses_the_deprecated_public_peering_type attached. Microsoft peering is the replacement and is not a like-for-like swap.

Two failure modes produce no error and no drift, so both are outputs. Advertised public prefixes are validated by Microsoft against a routing registry, and a failure leaves the peering with an AdvertisedPublicPrefixesState of Validation needed, carrying no traffic until a support case with a Letter of Authorization clears it — and the provider exposes no attribute for that state, so Terraform reports an identical clean apply either way. Separately, exceeding a BGP prefix limit terminates the session rather than dropping the excess, and Microsoft notes that advertising Microsoft-peering routes into private peering can cross the private limit as Microsoft's own prefix set grows monthly, so a configuration can break without anyone changing it.

Terraform's work ends at the control plane. A successful apply means Azure accepted the peering; the BGP session needs the far end configured with the matching VLAN, prefixes, ASN and shared key, on equipment this module cannot reach. azure_asn, primary_azure_port and secondary_azure_port are computed precisely so they can be handed over, which is why they are emitted rather than treated as internal detail.

Only two arguments are force-new — the circuit and the resource group. Everything about the peering's configuration, including the VLAN and the addressing, updates in place. That is generous, and it also means a substantial re-addressing shows up in a plan as a modification rather than a replacement, so read the field diff rather than the action verb.

With no name and no tags, a peering carries no metadata of its own at all. Both absences are in the provider's schema; the circuit is the place to tag.


🧱 Design Principles

Concern This module's position Why
The overwrite hazard Named in an output, with the mechanism explained. It produces no error and no settling drift; only a human can prevent it.
Deprecated AzurePublicPeering Accepted and reported. An existing circuit may carry one; refusing it would make that estate undeclarable.
The five documented cross-field rules All enforced, each placed on the constrained variable. The provider documents them, so a validation is honest.
The IPv4/IPv6 requiredness asymmetry Named, not smoothed. It is the provider's schema; hiding it would surprise a caller later.
shared_key Sensitive input, presence emitted separately. It is a shared secret; sensitive redacts plan output but not state.
vlan_id bounds Enforced from 802.1Q, not invented. A VLAN tag is a 12-bit field; the protocol limit is the honest bound.
peer_asn Positive, and the IANA documentation block rejected. Microsoft publishes 64496–64511 as not allowed in the path.
routing_registry_name Near-miss check, both spellings accepted. The provider says RIPE, Microsoft says RIPENCC, and the list ends in "etc.".
Out-of-band prefix validation Named in an output. The provider exposes no attribute for the state, so nothing else can report it.
Prefix limits Named in an output, with the real numbers. Exceeding one drops the session, and the count that matters is on your router.
tags and name Both absent, and both absences stated. Schema omissions; tag the circuit.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. Plan-only; a human applies from CI.

After the apply, three checks Terraform cannot make:

# 1 · Is the BGP session actually up?
az network express-route peering show \
  --circuit-name "erc-prod" -g "rg-expressroute-prod" --name "AzurePrivatePeering" \
  --query "{state:state, primary:primaryAzurePort, secondary:secondaryAzurePort}"

# 2 · Did the Microsoft peering's prefixes pass registry validation?
az network express-route peering show \
  --circuit-name "erc-prod" -g "rg-expressroute-prod" --name "MicrosoftPeering" \
  --query "microsoftPeeringConfig.advertisedPublicPrefixesState"

# 3 · Is there an unexpected peering of a type you did not declare?
az network express-route peering list \
  --circuit-name "erc-prod" -g "rg-expressroute-prod" --query "[].name"

The third is the closest thing to an overwrite check that exists.


🧪 Testing

What the offline gate proves: that the configuration parses against the pinned provider, that formatting is canonical, and — via terraform console with variables files — that every validation {} fires on the input it targets. Twenty-six proof cases covered all thirty-two checks, including each of the five documented cross-field rules, and confirmed the failing variable in each case rather than only the error count.

Both near-miss checks were proved in both directions. peering_type = "microsoftpeering" is rejected while "SomeFuturePeering" is allowed; routing_registry_name = "arin" is rejected while "SOMEREGISTRY" is allowed. Four positive cases pass cleanly: a full Microsoft peering with a doubly-nested IPv6 block, a bare private peering, a bare public peering, and a Microsoft peering with an unrecognised registry.

The derived locals were evaluated against all three peering types, not just the common one: is_private_peering, is_microsoft_peering, uses_the_deprecated_public_peering_type, has_shared_key, ipv6_enabled and both prefix counts returned the expected values for a Microsoft peering with IPv6 and a key, a bare private peering, and a public peering. That also confirmed the nonsensitive() unwrap on has_shared_key works rather than erroring.

What only an apply exercises: whether the circuit exists in that resource group, whether the VLAN and prefixes are acceptable to Azure, and whether a peering of this type already exists.

What no Terraform run exercises at all: whether the BGP session comes up, whether the advertised prefixes pass registry validation, whether the VLAN collides with a sibling peering's, and whether a prefix limit is close to being crossed.


💬 Example Output

Outputs:

advertised_ipv4_prefix_count = 1
advertised_ipv6_prefix_count = 0
advertised_prefixes_are_validated_out_of_band_and_can_sit_in_validation_needed = true
azure_asn = 12076
exceeding_a_prefix_limit_terminates_the_bgp_session = true
express_route_circuit_name = "erc-prod"
gateway_manager_etag = ""
has_shared_key = true
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-expressroute-prod/providers/Microsoft.Network/expressRouteCircuits/erc-prod/peerings/MicrosoftPeering"
ipv4_enabled = true
ipv6_enabled = false
is_microsoft_peering = true
is_private_peering = false
only_one_peering_of_each_type_per_circuit_and_a_second_overwrites_it = true
peer_asn = 65001
peering_type = "MicrosoftPeering"
primary_azure_port = "pri-port-01"
primary_peer_address_prefix = "203.0.113.0/30"
resource_group_name = "rg-expressroute-prod"
route_filter_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-expressroute-prod/providers/Microsoft.Network/routeFilters/rf-m365"
secondary_azure_port = "sec-port-01"
secondary_peer_address_prefix = "203.0.113.4/30"
terraform_only_waits_for_the_control_plane_write = true
the_type_is_the_name_and_this_resource_has_no_name_argument = true
this_resource_carries_no_tags = true
uses_the_deprecated_public_peering_type = false
vlan_id = 200

🔍 Troubleshooting

Symptom Cause Fix
peering_type matches a documented value except for its capitalisation microsoftpeering and similar. Use PascalCase. An unrecognised value is allowed.
vlan_id must be between 1 and 4094 0, 4095 or higher. A VLAN tag is a 12-bit field; 0 and 4095 are reserved.
must be a /30 subnet A /29 or /31 prefix. The provider publishes /30 for a BGP link.
both primary_peer_address_prefix and secondary_peer_address_prefix are required IPv4 is enabled (the default) with no prefixes. Supply both, or set ipv4_enabled = false and configure ipv6.
peer_asn falls in 64496-64511 An ASN copied from documentation. Use the ASN your organisation holds.
route_filter_id ... only available when peering_type is "MicrosoftPeering" A filter on a private or public peering. Remove it, or change the peering type.
an ipv6 block was supplied, but ... only when peering_type is "MicrosoftPeering" or "AzurePrivatePeering" IPv6 on the deprecated public type. The public type does not support IPv6.
shared_key must be 25 characters or fewer A generated password. Shorten it; the provider publishes that maximum.
Only one validation error when several inputs are wrong Terraform skips every check on a variable when a variable it references has failed. Fix peering_type first, then re-run.
A second peering silently replaced the first The type is the name, so both wrote to one object. One module instance per circuit per type; az network express-route peering list to see what exists.
Peering exists but no routes arrive For Microsoft peering: no route filter, or prefixes in Validation needed. Attach a filter; check advertisedPublicPrefixesState.
BGP session never comes up The far end is not configured. Hand over azure_asn, both ports, the VLAN and the prefixes.
The session drops after months of working A prefix limit was crossed; exceeding one terminates the session. Summarise routes, or move to a Premium circuit.
No tags or name argument accepted The resource has neither. Tag the circuit; the type is the name.

🔗 Related Docs


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