Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure VPN Gateway Connection Terraform Module

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). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • 🔌 The tunnels between a Virtual WAN VPN gateway and one remote VPN site, one vpn_link per 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 sensitive map, both because that is this suite's pattern for gateway secrets and because a sensitive value cannot be a for_each argument.
  • 🔴 Nested force-new: bgp_enabled and vpn_site_link_id are 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, SHA1 and the low DH/PFS groups are all valid provider values, and an old device may need one.
  • ⚠️ Omitting ipsec_policy applies Azure's default policy set — which is emphatically not "no encryption".
  • ⚠️ Omitting routing creates a default route table implicitly, so routing always exists.
  • ⚠️ This is where a gateway NAT rule takes effect — via egress_nat_rule_ids / ingress_nat_rule_ids on a link.
  • ⚠️ No tags attribute 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.

❤️ Support this project

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


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

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.

✅ Provider / Versions

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_key is optional and computed. Omitting it leaves whatever Azure holds (example 3).
  • 🔒 A sensitive value cannot be a for_each argument (example 3).
  • ⚠️ vpn_link has min_items = 1 (example 4).
  • 🔒 None, DES, DES3, MD5, SHA1 and the low DH/PFS groups are legal (example 7).
  • ⚠️ The provider's text labels ike_* as IKE phase 2 and the IPsec fields as phase 1, the reverse of the usual convention (example 7).
  • ⚠️ integrity_algorithm has no SHA384; ike_integrity_algorithm does (example 7).
  • ⚠️ Omitting routing creates a default route table implicitly (example 9).
  • ⚠️ bandwidth_mbps states an expectation, not a limit, defaulting to 10 (example 4).
  • ⚠️ Top-level force-new: name, vpn_gateway_id, remote_vpn_site_id (example 5).
  • ⚠️ No tags attribute, and timeouts silently discards unknown keys.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy (example 12).

🔑 Required Azure RBAC Roles / Permissions

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).

⚠️ Reader on 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).

Azure Prerequisites

  • 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, if internet_security_enabled is true (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).

📁 Module Structure

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

⚙️ Quick Start

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.

🔌 Cross-Module Contract

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 ⚠️ What Terraform is not managing. security review
orphaned_shared_key_names ⚠️ Derived. Assert empty. 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 ⚠️ Derived. Assert false. review
tunnel_state_is_not_visible_to_terraform 🔴 Always true. runbook
requires_matching_configuration_on_the_remote_device Always true. runbook

📚 Example Library

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_links has 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 tags variable, because the provider exposes no tags attribute 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.

⚠️ Reader on 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 sensitive input and are never emitted. And a sensitive value cannot be used as a for_each argument: marking the whole vpn_links map sensitive would make dynamic "vpn_link" impossible to render at all. Splitting the secret out is the only shape that keeps both the redaction and the loop.

🔒 sensitive = true redacts 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_key is optional and computed. Omitting it leaves whatever Azure already holds rather than clearing it — so a link absent from links_with_shared_key is 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_names catches (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 name the 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. Each vpn_site_link_id must be one of that site's links, and the sibling site module emits them as link_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_mbps defaults 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_enabled and vpn_site_link_id are force-new within vpn_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 as must 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 the dynamic block iterates vpn_links and 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 with shared_key being 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
    }]
  }
}

⚠️ Omitting ipsec_policies applies 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, PFS1 and PFS2 are 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 the ike_* 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. And integrity_algorithm has no SHA384 while ike_integrity_algorithm does, so the two value sets are not interchangeable.

⚠️ Both sa_data_size_kb and sa_lifetime_sec are 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 routing is 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_table is required within the block, so supplying routing at all commits you to naming a table. Likewise route_table_ids within propagated_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_rule exists 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 as EgressSnat must be referenced in egress_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 its id as 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: passing module.vpn_gateway.id rather than a literal is what tells Terraform to remove this connection before the gateway.

✅ A CanNotDelete management lock is worth applying to a connection carrying production traffic — though note it protects against deletion, not against the force-new replacement that a nested bgp_enabled edit would cause.

⚠️ prevent_destroy is not available, because lifecycle is not valid inside a module block.

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_key is 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.

⚠️ Restate name, vpn_gateway_id, remote_vpn_site_id and every vpn_site_link_id exactly; 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.

⚠️ Note module.branch_nat.id appears inside vpn_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.

📥 Inputs

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 tags variable, because the provider exposes no tags attribute 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.
}

🧾 Outputs

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 ⚠️ What Terraform is not managing. no
orphaned_shared_key_names ⚠️ Derived. Assert empty. 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 ⚠️ Derived. Assert 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).

🧠 Architecture Notes

  • 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 sensitive map 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 a for_each argument, so marking vpn_links sensitive would make dynamic "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 sensitive redacts 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_names exists because a typo in a map key renders nothing and raises no error, and it compounds with shared_key being 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 with nonsensitive(); no key material is involved, which matters because sensitivity is contagious to values derived from a sensitive collection.

  • 🔒 weak_ipsec_algorithms_in_use reports 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. But None, DES, MD5, SHA1 and 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_policy is 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_enabled and vpn_site_link_id are 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_enabled keeps the provider's false default, 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 a true default 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_table exists because omitting routing is 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 virtualNetworkGateways ID, 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 tags variable is carried, because the provider exposes no tags attribute here — checked against the schema rather than assumed, since this library's universal tail is conditional on the provider offering it.

🧱 Design Principles

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_names is empty.
  • Before hand-rolling IPsec: Azure's default set is usually correct.
  • Before editing a link: bgp_enabled and vpn_site_link_id replace the whole connection.
  • Before creating a NAT rule: it does nothing until a link here references it.

🚀 Runbook

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_enabled or vpn_site_link_id replaces the connection (example 5).
  • 🔒 Keys land in state in plaintext — encrypted, access-controlled backend only (example 3).
  • ⚠️ Assert orphaned_shared_key_names is empty (example 6).
  • ⚠️ Assert weak_ipsec_algorithms_in_use is empty, or record why not (example 7).
  • ⚠️ Assert traffic_selectors_without_an_enabled_link is false (example 11).
  • ⚠️ Reference NAT rules from a link, or they are inert (example 10).
  • 💡 A CanNotDelete lock guards deletion, not replacement (example 12).

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • name is non-empty and is not a Resource ID;
  • vpn_gateway_id is an anchored VPN gateway ID — a classic virtualNetworkGateways ID is rejected;
  • remote_vpn_site_id is an anchored site ID — a site link ID is rejected;
  • vpn_links has at least one entry, every key is non-empty, and every vpn_site_link_id is 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_seconds is a whole number 9–3600, and every bandwidth_mbps a whole number above zero;
  • every supplied ipsec_policy sets sa_data_size_kb and sa_lifetime_sec above zero;
  • every vpn_link_shared_keys value is non-empty;
  • routing.associated_route_table is non-empty when routing is supplied, and route_table_ids non-empty when propagated_route_table is;
  • every traffic selector policy supplies both address-range sets;
  • no pre-shared key is emitted as an output at all;
  • the module declares no provider block and no tags variable.

💡 These were proved by evaluating the conditions in terraform console inside the module — which does fire root-module variable validations, unlike terraform validate on a calling configuration. A classic gateway ID fires the gateway check, a site ID in vpn_site_link_id fires the link check, "ikev2" fires the protocol case check, 8 fires the DPD floor, "SHA384" in integrity_algorithm fires the set that lacks it, and an empty vpn_links fires 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_use instead, 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, if internet_security_enabled is true (example 8).

💬 Example Output

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_key names both tunnels — presence, not material (example 3).

✅ orphaned_shared_key_names = [] and weak_ipsec_algorithms_in_use = [] are the two assertions worth automating (examples 6, 7).

✅ links_using_azure_default_ipsec_policy naming both links is why the empty weak list is meaningful rather than vacuous: no custom policy was supplied at all (example 7).

🔴 The two true constants are facts, not statuses. tunnel_state_is_not_visible_to_terraform = true does not mean the tunnel is up — it means this output cannot tell you.

🔍 Troubleshooting

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).

🔗 Related Docs

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