Site-to-site tunnels between a Virtual WAN VPN gateway and a remote site — where a successful apply does not mean a working tunnel (
azurerm_vpn_gateway_connection). Targetshashicorp/azurerm ~> 4.0.
- 🔌 The tunnels between a Virtual WAN VPN gateway and one remote VPN site, one
vpn_linkper tunnel. - 🔴 A successful apply does not mean a working tunnel. Azure never contacts the far-end device, so a wrong
pre-shared key, a mismatched IPsec policy or a firewall in the path all produce
Apply complete!and no traffic. - 🔒 The pre-shared keys arrive through their own
sensitivemap, both because that is this suite's pattern for gateway secrets and because a sensitive value cannot be afor_eachargument. - 🔴 Nested force-new:
bgp_enabledandvpn_site_link_idare force-new inside a link and replace the whole connection — so flipping BGP on one tunnel tears down every tunnel. - 🔒 Weak-but-legal IPsec algorithms are reported, not refused.
None,DES,MD5,SHA1and the low DH/PFS groups are all valid provider values, and an old device may need one. ⚠️ Omittingipsec_policyapplies Azure's default policy set — which is emphatically not "no encryption".⚠️ Omittingroutingcreates a default route table implicitly, so routing always exists.⚠️ This is where a gateway NAT rule takes effect — viaegress_nat_rule_ids/ingress_nat_rule_idson a link.⚠️ Notagsattribute on this resource.
💡 Why it matters: Terraform owns one end of a negotiation. Everything that decides whether the tunnel actually carries traffic — the key, the algorithms, the selectors, the route to the public IP — has a counterpart on a device in somebody else's building, and Azure checks none of it at apply time.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
gw["terraform-azurerm-vpn-gateway, in a virtual hub. Pass its id so the destroy is ordered. A Virtual WAN gateway, NOT a classic virtual network gateway - the two families are separate and their IDs are not interchangeable."]
site["terraform-azurerm-vpn-site describes the far end, and its link_ids are what each tunnel attaches to. Passing the SITE id where a LINK id belongs is the usual mistake, so both checks are anchored."]
kv["terraform-azurerm-key-vault holding the pre-shared keys, supplied through a SEPARATE sensitive map. A sensitive value cannot be a for_each argument, so marking the whole links map sensitive would make the dynamic block impossible to render."]
conn["terraform-azurerm-vpn-gateway-connection"]
nat["terraform-azurerm-vpn-gateway-nat-rule: created on the gateway, and INERT until a vpn_link here names its id. This module is where a NAT rule takes effect."]
device["THE FAR-END DEVICE, which Terraform does not own and often another team does. Every negotiated setting must match: the key, the IKE version, each IPsec algorithm, the traffic selectors, and whether BGP is on."]
truth["AND AZURE NEVER CONTACTS IT AT APPLY TIME. So a wrong key, a mismatched policy or a firewall in the path all produce the same result: Apply complete, and no traffic. Tunnel state lives in the gateway's metrics, not here."]
gw -->|"vpn_gateway_id, force-new"| conn
site -->|"remote_vpn_site_id plus one link_id per tunnel"| conn
kv -->|"vpn_link_shared_keys, sensitive and never emitted"| conn
nat -->|"referenced by egress or ingress nat_rule_ids"| conn
conn -->|"negotiates with"| device
device -->|"but see"| truth
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class conn mine;
class device,truth keystone;
class gw,site,kv,nat sib;
flowchart TB
links["vpn_links is a KEYED MAP, so the key IS the link name and a duplicate cannot be expressed. At least one is required. Closed sets validated: protocol IKEv1 or IKEv2, connection_mode Default or InitiatorOnly or ResponderOnly, dpd_timeout 9 to 3600."]
keys["vpn_link_shared_keys is a SEPARATE sensitive map, keyed to match. Two reasons: it follows this suite's gateway-secret pattern, and a sensitive value CANNOT be a for_each argument - so marking vpn_links sensitive would break the dynamic block entirely."]
orphan["orphaned_shared_key_names: derived, because a typo in a map key renders NOTHING and raises no error. Only the key NAMES are unwrapped with nonsensitive; no key material is involved. Assert empty."]
fn["NESTED FORCE-NEW: bgp_enabled and vpn_site_link_id are force-new INSIDE a link, and they replace the WHOLE CONNECTION. On a multi-link connection, flipping BGP on one tunnel tears down every tunnel."]
ipsec["ipsec_policies is OPTIONAL, and omitting it applies Azure's DEFAULT policy set - which is not the absence of encryption. Six closed algorithm sets are enforced; note integrity_algorithm has no SHA384 while ike_integrity_algorithm does."]
weak["weak_ipsec_algorithms_in_use: derived, and the module's main security contribution. None, DES, DES3, MD5, SHA1 and the low DH and PFS groups are all LEGAL, so they are REPORTED by link and field rather than refused - an old device may require one. Assert empty."]
this["azurerm_vpn_gateway_connection.this"]
flags["tunnel_state_is_not_visible_to_terraform and requires_matching_configuration_on_the_remote_device: two constants, because the absence of an error is the most misleading signal this resource gives."]
links -->|"required, at least one"| this
keys -->|"rendered with sensitive at point of use"| this
keys -->|"and a mismatched key is reported by"| orphan
fn -->|"lifecycle"| this
ipsec -->|"validated sets, judgement reported"| weak
weak -->|"emitted"| this
this -->|"emits"| flags
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this mine;
class weak mine;
class flags,fn,keys keystone;
class links,orphan,ipsec sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_vpn_gateway_connection.this |
1 | The keystone. |
vpn_link block |
1..n | Required, min_items = 1. One per tunnel. |
ipsec_policy block |
0..n per link | Omitted → Azure's default policy set. |
custom_bgp_address block |
0..n per link | For BGP peers needing a specific address. |
routing block |
0..1 | Omitted → an implicit default route table. |
traffic_selector_policy block |
0..n | Usually none; Virtual WAN is route-based. |
timeouts block |
0..1 | All four operations exist. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Azure resource provider | Microsoft.Network/vpnGateways/vpnConnections |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite — confirmed against the live provider schema and its documentation:
- 🔴 A successful apply does not mean a working tunnel (example 2).
- 🔴 Nested force-new inside
vpn_link—bgp_enabled,vpn_site_link_id— replacing the whole connection (example 5). - 🔒
shared_keyis optional and computed. Omitting it leaves whatever Azure holds (example 3). - 🔒 A sensitive value cannot be a
for_eachargument (example 3). ⚠️ vpn_linkhasmin_items = 1(example 4).- 🔒
None,DES,DES3,MD5,SHA1and the low DH/PFS groups are legal (example 7). ⚠️ The provider's text labelsike_*as IKE phase 2 and the IPsec fields as phase 1, the reverse of the usual convention (example 7).⚠️ integrity_algorithmhas noSHA384;ike_integrity_algorithmdoes (example 7).⚠️ Omittingroutingcreates a default route table implicitly (example 9).⚠️ bandwidth_mbpsstates an expectation, not a limit, defaulting to 10 (example 4).⚠️ Top-level force-new:name,vpn_gateway_id,remote_vpn_site_id(example 5).⚠️ Notagsattribute, andtimeoutssilently discards unknown keys.lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy(example 12).
| Operation | Role | Scope |
|---|---|---|
| Create, update or delete the connection | Network Contributor | the gateway's resource group |
| Read the connection | Reader | the connection |
| Reference the VPN gateway | Microsoft.Network/vpnGateways/write |
the gateway |
| Reference the VPN site and its links | Reader | the site |
| Reference gateway NAT rules | Reader | the NAT rules |
| Read the pre-shared key from a secret store | Key Vault Secrets User | the secret |
🔒 Plan access is close to credential access here. The pre-shared keys sit in Terraform state in plaintext, so whoever can read the state holds the keys to every tunnel this connection carries (example 3).
⚠️ Readeron the connection does not reveal tunnel health. Diagnosing a down tunnel needs the gateway's metrics and diagnostics — a different resource, often a different permission (example 2).
- An existing Virtual WAN VPN gateway, and a VPN site with at least one link (example 4).
- 🔒 The pre-shared keys available out of band, and 🔒 an encrypted state backend (example 3).
- 🔴 The far-end device configured to match — key, IKE version, algorithms, selectors, BGP (example 2).
- 🔴 Network reachability to the site's public IP, which nothing verifies at apply (example 2).
⚠️ A secured virtual hub, ifinternet_security_enabledistrue(example 8).⚠️ Any referenced NAT rules created first, on the same gateway (example 10).⚠️ Gateway metrics or diagnostics configured, or nobody notices a tunnel that never came up (example 2).
terraform-azurerm-vpn-gateway-connection/
├── providers.tf # required_version + the pinned azurerm provider. No provider block.
├── variables.tf # name, vpn_gateway_id, remote_vpn_site_id, vpn_links,
# # vpn_link_shared_keys (sensitive), internet_security_enabled,
# # routing, traffic_selector_policies, timeouts (no tags)
├── main.tf # the keystone `this` + dynamic vpn_link / ipsec_policy / routing blocks
├── outputs.tf # id first, then the links, then the security-derived flags, then two constants
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "branch_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-gateway-connection.git?ref=v1.0.0"
name = "conn-branch-london"
# 🔴 Both force-new. Pass the siblings' attributes so the destroy is ordered (example 5).
vpn_gateway_id = module.vpn_gateway.id
remote_vpn_site_id = module.vpn_site.id
# One tunnel per link on the site (example 4).
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
}
}
# 🔒 A separate sensitive map, keyed to match (example 3).
vpn_link_shared_keys = {
primary = var.branch_london_psk
}
}🔴 This apply will succeed whether or not the tunnel works. Read example 2 before treating a clean apply as done.
ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source module |
|---|---|---|
name |
string |
caller — force-new |
vpn_gateway_id |
string |
terraform-azurerm-vpn-gateway output id |
remote_vpn_site_id |
string |
terraform-azurerm-vpn-site output id |
vpn_links |
map(object(...)) |
keyed by name; vpn_site_link_id from terraform-azurerm-vpn-site output link_ids |
vpn_link_shared_keys |
map(string) |
🔒 out of band — sensitive |
internet_security_enabled |
bool |
caller — defaults false |
routing |
object(...) |
hub route tables; omitted → implicit default |
traffic_selector_policies |
list(object(...)) |
caller — rarely needed |
timeouts |
object(...) |
caller |
ℹ️ No
tags. Tag the gateway and the site instead.
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The connection's Resource ID. | diagnostics, RBAC |
name / vpn_gateway_id / remote_vpn_site_id |
Identity. Force-new. | review |
vpn_link_names / vpn_link_count |
The tunnels. | resilience review |
internet_security_enabled |
✅ Positively stated. | network review |
links_with_shared_key |
🔒 Names only. | security review |
links_without_shared_key |
security review | |
orphaned_shared_key_names |
security review | |
bgp_enabled_links |
🔴 Nested force-new. | change review |
links_using_azure_default_ipsec_policy |
✅ Derived. | security review |
weak_ipsec_algorithms_in_use |
🔒 Derived. Assert empty. | security review |
links_with_nat_rules |
Where a NAT rule takes effect. | network review |
uses_implicit_default_route_table |
✅ Derived. | routing review |
associated_route_table / propagated_route_table_ids |
Hub routing. | routing review |
traffic_selector_policy_count |
Usually 0. |
review |
traffic_selectors_without_an_enabled_link |
false. |
review |
tunnel_state_is_not_visible_to_terraform |
🔴 Always true. |
runbook |
requires_matching_configuration_on_the_remote_device |
Always true. |
runbook |
The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:
variable "hub_route_table_id" {
description = "id of an existing hub route table that these examples reference but do not create."
type = string
}
variable "other_gateway_id" {
description = "id of an existing other gateway that these examples reference but do not create."
type = string
}
variable "other_site_id" {
description = "id of an existing other site that these examples reference but do not create."
type = string
}1 · The smallest working connection
module "branch_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-gateway-connection.git?ref=v1.0.0"
name = "conn-branch-london"
vpn_gateway_id = module.vpn_gateway.id
remote_vpn_site_id = module.vpn_site.id
vpn_links = {
primary = { vpn_site_link_id = module.vpn_site.link_ids[0] }
}
vpn_link_shared_keys = { primary = var.branch_london_psk }
}ℹ️ Four inputs and one tunnel. Everything else — IPsec policy, routing, traffic selectors, BGP — takes Azure's defaults, which for a Virtual WAN site-to-site tunnel is usually correct.
⚠️ vpn_linkshas no default because the provider requires at least one block. A connection with no tunnels carries no traffic, so there is no empty call for this library's secure-by-default rule to harden.
🔴 And this apply succeeds regardless of whether the far end agrees (example 2). That is the single most important thing to know about this resource.
ℹ️ There is no
tagsvariable, because the provider exposes notagsattribute here — verified against the schema.
2 · 🔴 A successful apply does not mean a working tunnel
terraform apply -> Apply complete! Resources: 1 added.
Meanwhile, any of these leaves the tunnel down:
the pre-shared key does not match the far-end device
the IPsec or IKE algorithms do not overlap
the site's public IP is wrong, or unreachable
a firewall in the path drops UDP 500 / 4500
the far-end device is not configured at all
🔴 Azure never contacts the far end at apply time. It accepts a configuration, and the negotiation happens later — so every one of the failures above produces exactly the same Terraform output as success.
✅ The module states it rather than letting the silence mislead:
output "not_proof_of_anything" {
value = module.branch_connection.tunnel_state_is_not_visible_to_terraform # always true
}
output "the_other_half" {
value = module.branch_connection.requires_matching_configuration_on_the_remote_device # always true
}
⚠️ Tunnel state lives on the gateway, not here. Configure diagnostic settings and metrics on the VPN gateway, and alert on connection status — otherwise the first symptom is a user reporting that a branch cannot reach anything.
⚠️ Readeron this connection tells you nothing about health. It shows the configuration you already have in state.
💡 Treat the far end as a change dependency, not a detail. The device is usually owned by a network team, sometimes by a third party, and every negotiated field here has a counterpart there (example 7).
3 · 🔒 Why the pre-shared keys are a separate map
vpn_links = {
primary = { vpn_site_link_id = module.vpn_site.link_ids[0] }
# ⚠️ no shared_key here
}
vpn_link_shared_keys = { # 🔒 sensitive = true
primary = var.branch_london_psk
}🔒 Two reasons, and the second is the binding one. It follows this library's established pattern for gateway secrets — pre-shared keys arrive through their own
sensitiveinput and are never emitted. And a sensitive value cannot be used as afor_eachargument: marking the wholevpn_linksmap sensitive would makedynamic "vpn_link"impossible to render at all. Splitting the secret out is the only shape that keeps both the redaction and the loop.
🔒
sensitive = trueredacts plan output. It does not encrypt state. The keys sit in Terraform state in plaintext, so whoever can read the state holds the keys to every tunnel — which makes the backend's access controls part of this resource's security.
✅ No key is ever emitted. Only which links have one:
output "keyed" { value = module.branch_connection.links_with_shared_key } # names only
output "unkeyed" { value = module.branch_connection.links_without_shared_key }
⚠️ shared_keyis optional and computed. Omitting it leaves whatever Azure already holds rather than clearing it — so a link absent fromlinks_with_shared_keyis not necessarily keyless, and there is no in-band way to remove a key.
🔴 A typo in a map key is silently ignored, because nothing renders it. That is what
orphaned_shared_key_namescatches (example 6).
4 · Links, and the resilient shape
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
bgp_enabled = true
}
secondary = {
vpn_site_link_id = module.vpn_site.link_ids[1] # a second branch device
bgp_enabled = true
}
}
vpn_link_shared_keys = {
primary = var.psk_primary
secondary = var.psk_secondary
}ℹ️ Keyed by name rather than a list, so two links cannot share a name and the key is the
namethe provider receives. A duplicate becomes inexpressible rather than a runtime error.
✅ Two links to a site with two devices is the usual resilient shape. The Azure end is already redundant — a VPN gateway is an instance pair — so a single link makes the branch the single point of failure.
⚠️ The site bounds what you can configure. Eachvpn_site_link_idmust be one of that site's links, and the sibling site module emits them aslink_ids, index-aligned to the links defined there:
Error: Invalid value for variable
Every vpn_links[*].vpn_site_link_id must be a full VPN Site LINK Resource ID ending in
/vpnSites/<site>/vpnSiteLinks/<link>. It is anchored, so the SITE's own ID is rejected —
that belongs in remote_vpn_site_id, and confusing the two is the usual mistake.
⚠️ bandwidth_mbpsdefaults to 10 and states an expectation, not a limit. Azure uses it for planning; real throughput depends on the gateway's scale unit and the far-end device.
⚠️ connection_mode = "ResponderOnly"on both ends never connects. Somebody has to initiate.
5 · 🔴 Nested force-new replaces the whole connection
# 🔴 Force-new at the top level:
name = "conn-branch-london-v2"
vpn_gateway_id = var.other_gateway_id
remote_vpn_site_id = var.other_site_id
# 🔴 Force-new INSIDE a link — and it replaces the ENTIRE connection:
vpn_links = {
primary = { vpn_site_link_id = module.vpn_site.link_ids[0], bgp_enabled = true } # ← flipping this
secondary = { vpn_site_link_id = module.vpn_site.link_ids[1] } # ← tears this down too
}
# ✅ Updates in place:
# bandwidth_mbps, connection_mode, dpd_timeout_seconds, protocol, ratelimit_enabled,
# route_weight, the NAT rule references, ipsec_policies, internet_security_enabled,
# routing, traffic_selector_policies, and the shared keys🔴 This is the trap.
bgp_enabledandvpn_site_link_idare force-new withinvpn_link, and Terraform's only unit of replacement is the whole resource — so enabling BGP on one tunnel of a two-tunnel connection drops both.
✅ The module surfaces which links have BGP on, because the list is the thing you must not casually change:
output "careful" { value = module.branch_connection.bgp_enabled_links }
⚠️ Read the plan, not the diff you intended. A one-line edit inside a map is exactly the change most likely to appear asmust be replaced.
✅ Everything genuinely operational is mutable, which is the right split: keys rotate, policies tighten, bandwidth expectations change, and none of those needs an outage.
💡 Plan a BGP change as a maintenance window, on a connection carrying production traffic.
6 · ⚠️ The orphaned key nobody notices
vpn_links = {
primary = { vpn_site_link_id = module.vpn_site.link_ids[0] }
}
vpn_link_shared_keys = {
primry = var.branch_london_psk # ⚠️ typo — silently ignored
}
⚠️ Nothing renders that key, because thedynamicblock iteratesvpn_linksand looks each key up by name. So the apply succeeds, the tunnel has no pre-shared key from this configuration, and no error appears anywhere.
✅ Which is why the module derives it:
output "typos" {
value = module.branch_connection.orphaned_shared_key_names # ⚠️ assert this is empty
}
⚠️ And it compounds withshared_keybeing computed (example 3): the tunnel may well keep working on a key Azure already held, so even the symptom can be absent — until somebody rotates and nothing changes.
ℹ️ Only the key names are unwrapped with
nonsensitive(). No key material is involved in producing that output — which matters, because sensitivity is contagious even to values derived from a sensitive collection.
✅ Assert it in CI. It is the cheapest check on this page and it catches a class of error that is otherwise invisible.
7 · 🔒 IPsec policy — validated sets, reported judgement
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
# ⚠️ Only for a far-end device that needs specific algorithms:
ipsec_policies = [{
dh_group = "DHGroup14"
ike_encryption_algorithm = "AES256"
ike_integrity_algorithm = "SHA256"
encryption_algorithm = "AES256"
integrity_algorithm = "SHA256"
pfs_group = "PFS2048"
sa_data_size_kb = 102400000
sa_lifetime_sec = 27000
}]
}
}
⚠️ Omittingipsec_policiesapplies Azure's default policy set, which is not "no encryption" and is usually the right choice. Supply one only to match a device that requires it:
output "defaults" { value = module.branch_connection.links_using_azure_default_ipsec_policy }🔒
None,DES,DES3,MD5,SHA1,DHGroup1,DHGroup2,PFS1andPFS2are all legal provider values. The module validates the closed sets and then reports the weak choices rather than refusing them, because an old far-end device may genuinely require one and rejecting it would refuse a working configuration:
output "weak" {
value = module.branch_connection.weak_ipsec_algorithms_in_use
# e.g. ["primary: pfs_group=None", "primary: integrity_algorithm=SHA1"] ⚠️ assert empty
}
⚠️ Two schema oddities worth knowing. The provider's own text labels theike_*algorithms as IKE phase 2 and the IPsec ones as phase 1, which is the reverse of the usual convention — read the field names rather than the parentheticals. Andintegrity_algorithmhas noSHA384whileike_integrity_algorithmdoes, so the two value sets are not interchangeable.
⚠️ Bothsa_data_size_kbandsa_lifetime_secare required once you supply a policy at all. They bound how much data and time a security association survives before rekeying.
8 · Internet security, and a default not flipped
# ✅ The default, matching the provider:
# internet_security_enabled = false
# ⚠️ Requires a SECURED hub to route through:
internet_security_enabled = true
⚠️ This library usually defaults toward the safer setting; here it does not, and the reason is worth stating. Enabling it sends the branch's internet-bound traffic out through the virtual hub's secured egress — which requires a hub with a firewall to leave through. Defaulting it on would break connectivity wherever that does not exist, and this library's secure-by-default rule does not extend to re-routing somebody's traffic.
✅ Turn it on where the hub is secured, because it is the difference between branch internet traffic being inspected and going straight out at the branch.
✅ Emitted positively, so there is no negated flag to misread:
output "hub_egress" { value = module.branch_connection.internet_security_enabled }ℹ️ Not force-new, so it can be enabled once the hub is ready (example 5).
9 · Routing, and the table you get by not choosing
# ✅ Omitting `routing` entirely — the hub creates a default route table implicitly:
# routing = null
# Or choose deliberately:
routing = {
associated_route_table = var.hub_route_table_id
propagated_route_table = {
route_table_ids = [var.hub_route_table_id]
labels = ["default"]
}
}ℹ️ Omitting this is meaningful rather than merely minimal. The provider documents that a default route table is created implicitly when
routingis absent — so routing always exists, and the only question is whether you chose it:
output "implicit" { value = module.branch_connection.uses_implicit_default_route_table }
⚠️ associated_route_tableis required within the block, so supplyingroutingat all commits you to naming a table. Likewiseroute_table_idswithinpropagated_route_table.
⚠️ Association and propagation answer different questions. Association decides which table this connection uses; propagation decides which tables learn its routes. Omitting propagation means other branches will not learn these routes through it.
ℹ️ Labels group route tables without listing them individually, which is how a hub-and-spoke topology stays manageable as branches multiply.
10 · Where a NAT rule actually takes effect
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
# 🔴 THIS is what makes a gateway NAT rule do anything:
egress_nat_rule_ids = [module.branch_nat.id]
ingress_nat_rule_ids = []
}
}🔴 A
azurerm_vpn_gateway_nat_ruleexists on the gateway and translates nothing until a link names it here. The reference points from the connection to the rule, so the rule itself cannot tell you whether it is in use — which is why its module emits a constant flag saying so.
✅ The module reports which links reference rules, so an inert rule is visible from this side:
output "nat_wired" { value = module.branch_connection.links_with_nat_rules } # empty = rules are inert
⚠️ Egress and ingress are separate lists because they are separate directions. A rule created asEgressSnatmust be referenced inegress_nat_rule_ids; putting it in the other list does not translate in the direction you want.
⚠️ The NAT rule must exist on the same gateway as this connection, and be created first — pass itsidas an attribute so Terraform orders them.
💡 Only reach for NAT when the address plans genuinely overlap. Otherwise it adds a translation layer and a troubleshooting surface for nothing.
11 · Traffic selectors, and a pairing not enforced
traffic_selector_policies = [{
local_address_ranges = ["10.0.0.0/16"]
remote_address_ranges = ["192.168.0.0/16"]
}]
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
policy_based_traffic_selector_enabled = true # ⚠️ without this, the selectors do nothing
}
}
⚠️ The module does not enforce that pairing, because the provider documents the two fields independently and this library does not invent a constraint that could reject legal input. It reports the gap instead:
output "dead_selectors" {
value = module.branch_connection.traffic_selectors_without_an_enabled_link # ✅ assert false
}ℹ️ Normally you want none of this. Virtual WAN is route-based; policy-based selectors exist for far-end devices that require them, and they narrow which ranges traverse the tunnel.
⚠️ Both address-range sets are required within each policy. A one-sided selector is not expressible, and the validation says so.
⚠️ Selectors must match the far end too (example 2). A narrower selector on one side than the other is a classic cause of a tunnel that establishes and then drops specific traffic.
12 · Destroy, locks and importing
terraform destroy on this module:
removes the CONNECTION -> and every tunnel on it; the branch loses connectivity
the gateway survives -> and Azure requires connections gone before it can be deleted
the site survives
any NAT rules survive — now referenced by nothing
⚠️ This is a connectivity-destroying destroy, and so is any replacement (example 5). Destroy ordering depends on attribute references: passingmodule.vpn_gateway.idrather than a literal is what tells Terraform to remove this connection before the gateway.
✅ A
CanNotDeletemanagement lock is worth applying to a connection carrying production traffic — though note it protects against deletion, not against the force-new replacement that a nestedbgp_enablededit would cause.
⚠️ prevent_destroyis not available, becauselifecycleis not valid inside amoduleblock.
Importing an existing connection
terraform import 'module.branch_connection.azurerm_vpn_gateway_connection.this' \
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-wan/providers/Microsoft.Network/vpnGateways/vpngw-hub/vpnConnections/conn-branch-london"🔴 Importing is risky here for a specific reason:
shared_keyis computed, so Azure does not return it in a form you can verify. Supply the keys you believe are correct, plan, and read the diff — and remember a mismatch does not fail, it just leaves a tunnel that will not re-establish after the next rekey.
⚠️ Restatename,vpn_gateway_id,remote_vpn_site_idand everyvpn_site_link_idexactly; all are force-new.
13 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-wan-hub"
location = "eastus"
}
# ── The site: the far end, and the links tunnels attach to (example 4) ───────
module "vpn_site" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-site.git?ref=v1.0.0"
name = "site-branch-london"
resource_group_name = module.rg.name
location = module.rg.location
virtual_wan_id = var.virtual_wan_id
address_cidrs = ["192.168.10.0/24"]
device_vendor = "Contoso Networks"
# Two links = two branch devices = the resilient shape (example 4).
# ⚠️ Each carries `bgp`, because the tunnels below set bgp_enabled = true — a link
# with no BGP settings cannot peer, and the connection would not know.
links = [
{
name = "london-primary"
ip_address = "203.0.113.10"
bgp = { asn = 65010, peering_address = "203.0.113.10" }
},
{
name = "london-secondary"
ip_address = "203.0.113.11"
bgp = { asn = 65010, peering_address = "203.0.113.11" }
},
]
}
# ── The gateway: the Azure end, an instance pair ─────────────────────────────
module "vpn_gateway" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-gateway.git?ref=v1.0.0"
name = "vpngw-hub-eastus"
resource_group_name = module.rg.name
location = module.rg.location
virtual_hub_id = var.virtual_hub_id
}
# ── A NAT rule, because the branch overlaps Azure's plan (example 10) ────────
module "branch_nat" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-gateway-nat-rule.git?ref=v1.0.0"
name = "nat-branch-london-egress"
vpn_gateway_id = module.vpn_gateway.id
mode = "EgressSnat"
type = "Static"
internal_mappings = [{ address_space = "10.0.0.0/24" }]
external_mappings = [{ address_space = "10.200.0.0/24" }]
}
# ── This module: the tunnels ─────────────────────────────────────────────────
module "branch_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-vpn-gateway-connection.git?ref=v1.0.0"
name = "conn-branch-london"
# 🔴 Attributes, not literals — this is what orders the destroy (example 12).
vpn_gateway_id = module.vpn_gateway.id
remote_vpn_site_id = module.vpn_site.id
# Two tunnels, one per branch device (example 4). BGP on both — and note that
# 🔴 changing bgp_enabled later replaces the WHOLE connection (example 5).
vpn_links = {
primary = {
vpn_site_link_id = module.vpn_site.link_ids[0]
bgp_enabled = true
# 🔴 The only place the NAT rule takes effect (example 10).
egress_nat_rule_ids = [module.branch_nat.id]
}
secondary = {
vpn_site_link_id = module.vpn_site.link_ids[1]
bgp_enabled = true
egress_nat_rule_ids = [module.branch_nat.id]
}
# ✅ ipsec_policies omitted -> Azure's default policy set, which is the right
# choice unless the far-end device needs specific algorithms (example 7).
}
# 🔒 Separate sensitive map. These land in state in plaintext (example 3).
vpn_link_shared_keys = {
primary = var.psk_london_primary
secondary = var.psk_london_secondary
}
# ⚠️ Left false: the hub has no secured egress yet (example 8).
internet_security_enabled = false
# Chosen deliberately rather than taking the implicit default (example 9).
routing = {
associated_route_table = var.hub_default_route_table_id
propagated_route_table = {
route_table_ids = [var.hub_default_route_table_id]
labels = ["default"]
}
}
}
output "connection_posture" {
value = {
id = module.branch_connection.id
tunnels = module.branch_connection.vpn_link_names
keyed = module.branch_connection.links_with_shared_key # 🔒 names only
orphans = module.branch_connection.orphaned_shared_key_names # ✅ expect []
weak = module.branch_connection.weak_ipsec_algorithms_in_use # ✅ expect []
nat = module.branch_connection.links_with_nat_rules # ✅ expect both
implicit = module.branch_connection.uses_implicit_default_route_table # false here
# 🔴 A runbook fact, not a status:
not_proof = module.branch_connection.tunnel_state_is_not_visible_to_terraform
}
}🔒 What the composition gets right: two tunnels to two branch devices so the branch is not a single point of failure, pre-shared keys in a separate sensitive map with their state exposure acknowledged, Azure's default IPsec policy left alone rather than hand-rolled, the NAT rule referenced from both links so it is not inert, routing chosen deliberately, attributes rather than literals in all three cross-module references, and the orphan and weak-algorithm assertions exported for CI.
⚠️ Notemodule.branch_nat.idappears insidevpn_links— that reference is what both wires the rule in and orders its creation before this connection.
🔴 What still needs a human: configuring the far-end devices to match, confirming the tunnels actually came up via the gateway's metrics, and agreeing the translated address space with whoever owns the branch network.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
— | Required. Force-new. |
vpn_gateway_id |
string |
— | Required. Force-new. Anchored; classic gateway rejected. |
remote_vpn_site_id |
string |
— | Required. Force-new. A site, not a site link. |
vpn_links |
map(object(...)) |
— | Required, ≥1. Keyed by link name. |
vpn_link_shared_keys |
map(string) |
{} |
🔒 sensitive. Keyed to match vpn_links. |
internet_security_enabled |
bool |
false |
Matches the provider (example 8). |
routing |
object(...) |
null |
Omitted → implicit default route table. |
traffic_selector_policies |
list(object(...)) |
[] |
Rarely needed. |
timeouts |
object(...) |
null |
All four operations exist. |
ℹ️ There is no
tagsvariable, because the provider exposes notagsattribute on this resource.
Full schemas
variable "vpn_links" {
type = map(object({
vpn_site_link_id = string
bandwidth_mbps = optional(number)
bgp_enabled = optional(bool) # 🔴 force-new; replaces the CONNECTION
connection_mode = optional(string) # Default | InitiatorOnly | ResponderOnly
dpd_timeout_seconds = optional(number) # 9..3600
egress_nat_rule_ids = optional(set(string))
ingress_nat_rule_ids = optional(set(string))
local_azure_ip_address_enabled = optional(bool)
policy_based_traffic_selector_enabled = optional(bool)
protocol = optional(string) # IKEv1 | IKEv2
ratelimit_enabled = optional(bool)
route_weight = optional(number)
custom_bgp_addresses = optional(list(object({ ip_address = string, ip_configuration_id = string })), [])
ipsec_policies = optional(list(object({ /* six closed sets + sa sizes */ })), [])
}))
# ⚠️ min 1 — the provider requires at least one vpn_link. Keyed so the key IS the name. 🔒 No shared_key here; see
# vpn_link_shared_keys. Examples 3-7.
}
variable "vpn_link_shared_keys" {
type = map(string)
default = {}
sensitive = true
# 🔒 Separate for two reasons: this suite's gateway-secret pattern, AND because a sensitive value cannot be a
# `for_each` argument — marking vpn_links sensitive would make the dynamic block impossible. `sensitive` redacts plan
# output and does NOT encrypt state. Never emitted. See examples 3 and 6.
}
variable "internet_security_enabled" {
type = bool
default = false
# ⚠️ Deliberately NOT defaulted true: enabling it re-routes branch internet traffic through the hub and needs a secured
# hub to route through, so a `true` default would break correct configurations. See example 8.
}| Output | Description | Sensitive |
|---|---|---|
id |
The connection's Resource ID. | no |
name / vpn_gateway_id / remote_vpn_site_id |
Identity. Force-new. | no |
vpn_link_names / vpn_link_count |
The tunnels. | no |
internet_security_enabled |
✅ Positively stated. | no |
links_with_shared_key |
🔒 Names only — no key. | no |
links_without_shared_key |
no | |
orphaned_shared_key_names |
no | |
bgp_enabled_links |
🔴 Nested force-new. | no |
links_using_azure_default_ipsec_policy |
✅ Derived. | no |
weak_ipsec_algorithms_in_use |
🔒 Derived. Assert empty. | no |
links_with_nat_rules |
Where NAT takes effect. | no |
uses_implicit_default_route_table |
✅ Derived. | no |
associated_route_table / propagated_route_table_ids |
Hub routing. | no |
traffic_selector_policy_count |
Usually 0. |
no |
traffic_selectors_without_an_enabled_link |
false. |
no |
tunnel_state_is_not_visible_to_terraform |
🔴 Always true. |
no |
requires_matching_configuration_on_the_remote_device |
Always true. |
no |
🔒 No pre-shared key output exists. Not sensitive-marked — absent (example 3).
-
The module's centre of gravity is the gap between a clean apply and a working tunnel. Azure accepts this configuration without contacting the far-end device, so five distinct failures — wrong key, mismatched algorithms, bad public IP, blocked UDP, unconfigured device — all produce
Apply complete!. Two constant outputs name that, because the absence of an error is the most misleading signal this resource gives, and because tunnel state lives on the gateway rather than here. -
🔒 The pre-shared keys are a separate
sensitivemap for two reasons, and the second is binding. It follows this library's established pattern for gateway secrets — and a sensitive value cannot be used as afor_eachargument, so markingvpn_linkssensitive would makedynamic "vpn_link"impossible to render. Splitting the secret out is the only shape that keeps both the redaction and the loop, and the variable says so rather than leaving the split looking arbitrary. -
The module states that
sensitiveredacts plan output rather than encrypting state, so the marking does not imply protection it lacks. Keys are never emitted — only which links have one — because re-emitting a credential copies it into every consuming configuration's state for no benefit. -
orphaned_shared_key_namesexists because a typo in a map key renders nothing and raises no error, and it compounds withshared_keybeing computed: the tunnel may keep working on a key Azure already held, so even the symptom can be absent until somebody rotates. Only the key names are unwrapped withnonsensitive(); no key material is involved, which matters because sensitivity is contagious to values derived from a sensitive collection. -
🔒
weak_ipsec_algorithms_in_usereports rather than refuses, and that is a deliberate reading of this library's validation philosophy. The six closed algorithm sets are enforced, because the provider publishes them. ButNone,DES,MD5,SHA1and the low DH and PFS groups are all legal values that an old far-end device may require, so rejecting them would refuse a working configuration. The judgement is surfaced with the link and field named so it can be asserted against instead. -
links_using_azure_default_ipsec_policyis emitted alongside it, because an empty weak-algorithm list means two different things — a strong custom policy, or no custom policy at all — and distinguishing them is the difference between a real assertion and a vacuous one. -
Nested force-new is called out four times, because it is uniquely easy to miss:
bgp_enabledandvpn_site_link_idare force-new inside a link, and Terraform's unit of replacement is the whole resource. A one-line edit inside a map drops every tunnel on the connection. -
internet_security_enabledkeeps the provider'sfalsedefault, and the module explains the refusal. Enabling it re-routes the branch's internet traffic through the hub and requires a secured hub to route through — so atruedefault would break correct configurations. This is the same reasoning this library applies to a capability-asserting flag elsewhere. -
Two pairings are reported rather than enforced — traffic selectors against the per-link flag, and shared keys against links — because the provider documents the fields independently. And
uses_implicit_default_route_tableexists because omittingroutingis a choice: the provider documents that a default table is created implicitly, so routing always exists. -
Resource-ID regexes are anchored and reject the adjacent family in both directions: a classic
virtualNetworkGatewaysID, a site link ID where the site belongs, and the site's own ID where a link belongs. Each message names the confusion rather than restating the pattern. -
No
tagsvariable is carried, because the provider exposes notagsattribute here — checked against the schema rather than assumed, since this library's universal tail is conditional on the provider offering it.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| A clean apply mistaken for a working tunnel | two constant flags emitted | — |
| A credential in a loop | separate sensitive map; no key output |
— |
| False secrecy | "redacts plans, does not encrypt state" stated | — |
| A silently ignored key | orphaned_shared_key_names derived |
— |
| Weak but legal algorithms | reported by link and field | choose one, knowingly |
| A vacuous "no weak algorithms" | default-policy links reported separately | — |
| Nested force-new | flagged in code, docs and an output | — |
| Re-routing somebody's traffic | internet_security_enabled left false, and said why |
set true, knowingly |
| An unenforceable pairing | reported, not enforced | — |
| Routing chosen by omission | uses_implicit_default_route_table derived |
— |
| Inert NAT rules | links_with_nat_rules derived |
— |
| Wrong-family IDs | anchored, both directions, named in the message | — |
| A tags tail the provider lacks | omitted, verified against the schema | — |
- Before trusting a clean apply: the tunnel may be down, and nothing here will say so.
- Before rotating a key: check
orphaned_shared_key_namesis empty. - Before hand-rolling IPsec: Azure's default set is usually correct.
- Before editing a link:
bgp_enabledandvpn_site_link_idreplace the whole connection. - Before creating a NAT rule: it does nothing until a link here references it.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag —
?ref=v1.0.0— never a branch. - Plan-only from here. A human applies from CI.
- 🔴 A clean apply is not a working tunnel. Verify on the gateway's metrics (example 2).
- 🔴 Nested force-new: editing
bgp_enabledorvpn_site_link_idreplaces the connection (example 5). - 🔒 Keys land in state in plaintext — encrypted, access-controlled backend only (example 3).
⚠️ Assertorphaned_shared_key_namesis empty (example 6).⚠️ Assertweak_ipsec_algorithms_in_useis empty, or record why not (example 7).⚠️ Asserttraffic_selectors_without_an_enabled_linkisfalse(example 11).⚠️ Reference NAT rules from a link, or they are inert (example 10).- 💡 A
CanNotDeletelock guards deletion, not replacement (example 12).
terraform validate and terraform fmt -check are the offline gate. They confirm:
nameis non-empty and is not a Resource ID;vpn_gateway_idis an anchored VPN gateway ID — a classicvirtualNetworkGatewaysID is rejected;remote_vpn_site_idis an anchored site ID — a site link ID is rejected;vpn_linkshas at least one entry, every key is non-empty, and everyvpn_site_link_idis an anchored site link ID;- every
protocol,connection_mode, and each of the six IPsec/IKE algorithm fields is in its closed set; - every
dpd_timeout_secondsis a whole number 9–3600, and everybandwidth_mbpsa whole number above zero; - every supplied
ipsec_policysetssa_data_size_kbandsa_lifetime_secabove zero; - every
vpn_link_shared_keysvalue is non-empty; routing.associated_route_tableis non-empty whenroutingis supplied, androute_table_idsnon-empty whenpropagated_route_tableis;- every traffic selector policy supplies both address-range sets;
- no pre-shared key is emitted as an output at all;
- the module declares no
providerblock and notagsvariable.
💡 These were proved by evaluating the conditions in
terraform consoleinside the module — which does fire root-module variable validations, unliketerraform validateon a calling configuration. A classic gateway ID fires the gateway check, a site ID invpn_site_link_idfires the link check,"ikev2"fires the protocol case check,8fires the DPD floor,"SHA384"inintegrity_algorithmfires the set that lacks it, and an emptyvpn_linksfires the min-one check.
⚠️ And note a property of the harness: Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a check is missing.
🔒 What the gate deliberately does not attempt: refusing a weak-but-legal algorithm. Those are reported by
weak_ipsec_algorithms_in_useinstead, because an old far-end device may require one (example 7).
What only plan and apply exercise:
- whether the gateway, the site and the referenced links exist;
- whether the NAT rules exist on the same gateway;
- whether the hub route tables exist.
What no Terraform command checks at any stage:
- 🔴 whether the tunnel comes up (example 2);
- 🔴 whether the pre-shared key matches the far end (examples 2, 3);
- 🔴 whether the far-end device's algorithms overlap (example 7);
⚠️ whether the site's public IP is reachable, or UDP 500/4500 is permitted;⚠️ whether the hub has secured egress, ifinternet_security_enabledistrue(example 8).
Outputs:
associated_route_table = "/subscriptions/00000000-.../hubRouteTables/defaultRouteTable"
bgp_enabled_links = ["primary", "secondary"]
id = "/subscriptions/00000000-.../vpnGateways/vpngw-hub-eastus/vpnConnections/conn-branch-london"
internet_security_enabled = false
links_using_azure_default_ipsec_policy = ["primary", "secondary"]
links_with_nat_rules = ["primary", "secondary"]
links_with_shared_key = ["primary", "secondary"]
links_without_shared_key = []
name = "conn-branch-london"
orphaned_shared_key_names = []
propagated_route_table_ids = ["/subscriptions/00000000-.../hubRouteTables/defaultRouteTable"]
remote_vpn_site_id = "/subscriptions/00000000-.../vpnSites/site-branch-london"
requires_matching_configuration_on_the_remote_device = true
traffic_selector_policy_count = 0
traffic_selectors_without_an_enabled_link = false
tunnel_state_is_not_visible_to_terraform = true
uses_implicit_default_route_table = false
vpn_gateway_id = "/subscriptions/00000000-.../vpnGateways/vpngw-hub-eastus"
vpn_link_count = 2
vpn_link_names = ["primary", "secondary"]
weak_ipsec_algorithms_in_use = []
🔒 No pre-shared key appears anywhere, and
links_with_shared_keynames both tunnels — presence, not material (example 3).
✅
orphaned_shared_key_names = []andweak_ipsec_algorithms_in_use = []are the two assertions worth automating (examples 6, 7).
✅
links_using_azure_default_ipsec_policynaming both links is why the empty weak list is meaningful rather than vacuous: no custom policy was supplied at all (example 7).
🔴 The two
trueconstants are facts, not statuses.tunnel_state_is_not_visible_to_terraform = truedoes not mean the tunnel is up — it means this output cannot tell you.
| Symptom | Cause | Fix |
|---|---|---|
| Apply succeeded, no traffic | Azure never contacted the far end. | Check the gateway's metrics (example 2). |
| The tunnel will not establish | Key, algorithms, IP or firewall mismatch. | Compare both ends field by field (examples 2, 7). |
| A rotated key changed nothing | The map key was a typo, or Azure held the old one. | Check orphaned_shared_key_names (examples 3, 6). |
| Editing one link replaced everything | bgp_enabled / vpn_site_link_id are nested force-new. |
Expected — plan a window (example 5). |
Plan rejects vpn_gateway_id |
A classic virtualNetworkGateways ID. |
Use a Virtual WAN gateway (example 1). |
Plan rejects vpn_site_link_id |
The site's own ID was passed. | Use link_ids[n] (example 4). |
Plan rejects remote_vpn_site_id |
A site link ID was passed. | Use the site's id (example 4). |
Plan rejects protocol |
Wrong case, e.g. "ikev2". |
IKEv1 or IKEv2 (example 4). |
Plan rejects integrity_algorithm = "SHA384" |
That set has no SHA384. |
Only ike_integrity_algorithm does (example 7). |
| A NAT rule appears to do nothing | No link references it. | Add its id to a link (example 10). |
| Traffic selectors do nothing | No link enables the per-link flag. | Set it (example 11). |
| Branch internet traffic bypasses the hub | internet_security_enabled is false. |
Set it true, with a secured hub (example 8). |
| Other branches do not learn these routes | No propagated route table. | Configure propagation (example 9). |
destroy failed part-way |
A literal ID removed the dependency edge. | Pass attributes (example 12). |
| An import proposed a replacement | A force-new field differs. | Restate all of them (example 12). |
Wanted prevent_destroy |
lifecycle is not valid inside a module block. |
Use a CanNotDelete lock (example 12). |
azurerm_vpn_gateway_connection— provider documentation, including the six algorithm value sets of example 7 and thevpn_linkcontract of example 4.- About Azure Virtual WAN site-to-site connections — how gateways, sites, links and hubs relate.
- About cryptographic requirements and Azure VPN gateways — what the algorithm choices of example 7 mean in practice.
- Configure NAT rules for your Virtual WAN VPN gateway — the link reference of example 10.
- Sibling modules:
terraform-azurerm-vpn-gateway(the Azure end),terraform-azurerm-vpn-site(the far end and itslink_ids),terraform-azurerm-vpn-gateway-nat-rule(inert until referenced here, example 10),terraform-azurerm-key-vault(holding the pre-shared keys of example 3),terraform-azurerm-resource-group. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."