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.
- ☁️ Creates one
azurerm_express_route_circuit_peering(the keystonethis) — a routing domain on an existing circuit. - 🔴 There is no
nameargument. Thepeering_typeis 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_configblock and the doubly-nestedipv6block (which contains its ownmicrosoft_peering) as deeply-typed objects, rendered with nesteddynamicblocks. - 🔒 Takes
shared_keyas a sensitive input (the BGP MD5 key) and emitshas_shared_keyas presence-only. ⚠️ Reports thatAzurePublicPeeringis 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.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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.
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;
| 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.
| 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
nameargument. ARM names a peering after its type, so the Resource ID ends in/peerings/AzurePrivatePeeringand 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.
- 🔴
AzurePublicPeeringis 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_configonly forMicrosoftPeering;route_filter_idonly forMicrosoftPeering;ipv6.route_filter_idlikewise; andipv6only forMicrosoftPeeringorAzurePrivatePeering. - 🔴 The IPv4 and IPv6 forms of the same block disagree on requiredness.
microsoft_peering_config.advertised_public_prefixesis required;ipv6.microsoft_peering.advertised_public_prefixesis optional. Converselyipv6'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_keymaxes out at 25 characters, which the provider publishes.⚠️ The prefixes are/30s — published, and enforced here.⚠️ Notagsattribute. 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.⚠️ lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy.
| 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 neededuntil a support case with documentation clears it.
🔒
shared_keyis 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.
- 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
/30subnets 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.
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
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 noname. This peering will be calledAzurePrivatePeeringin Azure, and a second resource with the same type on this circuit would overwrite it.
🔒
var.bgp_shared_keyshould be declaredsensitive = trueand provisioned out of band.
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 |
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 = trueredacts plan output; it does not encrypt state. Use an encrypted, access-controlled backend.has_shared_keyexists 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_prefixesis required; the same field insideipv6.microsoft_peeringis optional. Andipv6'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.enabledtotrue, so omitting the flag does not mean the feature is off —ipv6_enabledresolves 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_enableddefaults totrue, so leaving it alone while omitting the prefixes is a validation error rather than an IPv6-only peering. The check sits onipv4_enabledand 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 aMicrosoftPeeringon 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
ipv6is 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 neededmeans 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_typebecause the provider's list is not exhaustive;routing_registry_namebecause the provider's own examples sayRIPEwhile Microsoft's registry list saysRIPENCC— 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_asnand 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 toterraform-azurerm-express-route-circuit-connectionand joins two circuits rather than reaching Azure.
| 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
tagsvariable and nonamevariable, because the resource has neither.
| 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_keyis sensitive, and it is an input rather than an output.has_shared_keyreports its presence without revealing it. Every other value here is public routing information — an ASN, a port, a prefix.
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.
| 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. |
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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.
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.
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
| 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. |
azurerm_express_route_circuit_peeringprovider reference- Microsoft Learn — ExpressRoute circuits and peering
- Microsoft Learn — ExpressRoute routing requirements
- Microsoft Learn — Create and modify peering for a circuit (CLI)
- Parent:
terraform-azurerm-express-route-circuit— suppliesexpress_route_circuit_name. - Consumer:
terraform-azurerm-express-route-connection— takes this peering'sidasexpress_route_circuit_peering_idto reach a Virtual WAN hub. Requires a private peering. - Consumer:
terraform-azurerm-express-route-circuit-connection— takes it aspeering_idfor ExpressRoute Global Reach. Also requires a private peering. - Sibling:
terraform-azurerm-route-filter— suppliesroute_filter_id, and only a Microsoft peering accepts one. - Sibling:
terraform-azurerm-express-route-circuit-authorization— delegates a circuit to another subscription; unrelated to peering configuration but part of the same circuit's story. - Sibling:
terraform-azurerm-express-route-gateway— the Virtual WAN side of an on-premises-to-Azure connection. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

