Every workload you will ever run on Azure — a VM, an AKS cluster, a private database, a Function behind a private endpoint — sits inside a Virtual Network (VNet). The VNet is the software-defined boundary that decides which packets can reach which resource, where traffic egresses, and how two clouds’ worth of subnets connect without colliding. Get the network right and everything above it is easy; get it wrong and you spend the next quarter renumbering subnets on a running estate, which is exactly the kind of surgery nobody enjoys. This lesson builds the network layer the way a platform team actually builds it: as Terraform, so the address plan, the firewall rules, the routing, and the cross-VNet peering are all reviewed in a pull request and reproduced identically in dev, staging and prod.
You will build a small but genuinely production-shaped hub-spoke topology: a hub VNet with a public and a private subnet, a Network Security Group that allows SSH and HTTP, a route table that forces outbound traffic through a network virtual appliance (NVA), the subnet associations that bind those two to the private subnet, and a bidirectional peering to a spoke VNet. Along the way you will meet the four things that make Azure networking in Terraform subtly harder than clicking around the portal: the for_each-over-a-subnets-map pattern that keeps subnets DRY, the NSG-association-is-a-separate-resource rule, the inline-block-versus-standalone-resource conflict that thrashes your plan, and the non-overlapping-CIDR discipline that peering enforces at create time. We finish with a complete init → plan → apply → verify → destroy walkthrough and a troubleshooting table for the errors you will actually hit.
This lesson assumes you can already run the Terraform loop and have the Azure provider authenticated with a remote backend. If that is not yet true, read Terraform on Azure: Getting Started, Provider Authentication & Remote Backend first — it sets up the azurerm provider, the Storage-Account backend, and the login flow this lesson depends on. The for_each pattern used heavily here is covered in depth in Resources & Meta-Arguments: count, for_each, lifecycle. And once the network exists, Terraform on Azure: Linux & Windows VMs, VMSS & cloud-init drops compute into the very subnets you build here.
What you’ll build
The scenario is the one every organisation reaches within its first month on Azure: a hub VNet that holds shared services (a firewall/NVA, a VPN or ExpressRoute gateway, shared DNS) and one or more spoke VNets that hold the actual workloads. Spokes peer to the hub, route their internet-bound traffic through the hub’s firewall, and stay isolated from each other. It is the reference topology in Microsoft’s Cloud Adoption Framework, and it is small enough to stand up in one terraform apply yet complete enough to teach every core networking resource.
Concretely, the demo provisions: one resource group; one hub VNet (10.0.0.0/16); two subnets carved from it — a public subnet (10.0.1.0/24) and a private subnet (10.0.2.0/24) — created from a single for_each map so adding a third subnet is a one-line change; one NSG (nsg-web) that allows inbound SSH (22) and HTTP (80) and is associated to the public subnet; one route table with a user-defined route (UDR) that sends 0.0.0.0/0 to a VirtualAppliance next hop, associated to the private subnet; and a second spoke VNet (10.1.0.0/16) with a bidirectional peering to the hub. Verification uses az network vnet show, az network nsg rule list, and az network vnet peering list; cleanup is a single terraform destroy.
Why Terraform rather than the portal, az CLI, or ARM/Bicep? Because a network is the one layer where drift is dangerous and reproducibility is mandatory. A subnet added by hand in the portal breaks the next apply; an NSG rule someone loosened during an incident and forgot to revert is a silent hole. Terraform makes the whole topology a reviewed artifact and refuses to forget. The table below frames the trade-off.
| Tool | How you express the network | Reproducible across envs? | Drift handling | Best for |
|---|---|---|---|---|
| Azure Portal | Click through blades | No — manual, unversioned | None; changes are invisible | Learning, one-off inspection |
az CLI / scripts |
Imperative commands | Partly (if scripted) | You write your own diffing | Quick fixes, glue automation |
| ARM / Bicep | Declarative JSON/DSL, Azure-native | Yes | what-if; drift not auto-corrected |
Azure-only shops, Azure Policy tie-in |
Terraform (azurerm) |
Declarative HCL, multi-cloud | Yes — same code, per-env vars | plan shows drift; apply reconciles |
Platform teams, multi-cloud, PR review |
Here is the component map you will end the lesson with — every resource, the Terraform type that models it, and the one thing to remember about each.
| Component | Terraform resource | Address / key detail | One-line gotcha |
|---|---|---|---|
| Resource group | azurerm_resource_group |
rg-network-hub |
Deleting it destroys everything inside |
| Hub VNet | azurerm_virtual_network |
address_space = ["10.0.0.0/16"] |
Address space is a list; subnets live inside it |
| Subnets | azurerm_subnet (via for_each) |
10.0.1.0/24, 10.0.2.0/24 |
address_prefixes is plural; Azure reserves 5 IPs |
| NSG | azurerm_network_security_group |
nsg-web, allow 22/80 |
Lower priority number wins |
| NSG ↔ subnet | azurerm_subnet_network_security_group_association |
binds nsg-web → public subnet |
Separate resource; never also set inline |
| Route table | azurerm_route_table + azurerm_route |
0.0.0.0/0 → VirtualAppliance |
UDR overrides system routes |
| Route ↔ subnet | azurerm_subnet_route_table_association |
binds RT → private subnet | Separate resource, like the NSG one |
| Spoke VNet | azurerm_virtual_network |
address_space = ["10.1.0.0/16"] |
Must not overlap the hub |
| Peering | azurerm_virtual_network_peering ×2 |
hub↔spoke | One resource per direction |
IP address planning: non-overlapping CIDRs
Before a single resource, you plan addresses — because in Azure, IP ranges are effectively immutable once workloads land in them, and peering refuses to connect VNets whose ranges overlap. This is the discipline that separates a network you can grow from one you have to rebuild.
A VNet owns one or more CIDR blocks (its address_space); subnets are non-overlapping slices of that space. Azure works with RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). The prefix length sets the size, and — this trips up everyone coming from on-prem — Azure reserves five addresses in every subnet: the network address, the first three host addresses (.1 gateway, .2 and .3 for Azure DNS mapping), and the broadcast address. A /24 therefore gives you 251 usable IPs, not 254.
| CIDR prefix | Total addresses | Azure-reserved | Usable | Typical use |
|---|---|---|---|---|
/29 |
8 | 5 | 3 | Tiny — gateway/appliance only |
/28 |
16 | 5 | 11 | Small management subnet |
/27 |
32 | 5 | 27 | Bastion, jumpbox |
/26 |
64 | 5 | 59 | Small app tier |
/24 |
256 | 5 | 251 | Standard app/data subnet |
/22 |
1024 | 5 | 1019 | AKS node pool (needs headroom) |
/16 |
65536 | 5 per subnet | — | Whole VNet address space |
The rule that governs everything downstream: subnet prefixes must fall inside the VNet’s address_space, must not overlap each other, and — for any VNets you intend to peer — the whole VNets must not overlap each other. Plan the estate’s supernet once. A common scheme gives each VNet a /16 from a reserved /12, and each subnet a /24 from the VNet. The demo uses exactly that.
| VNet / subnet | CIDR | Purpose | Peers with |
|---|---|---|---|
| Hub VNet | 10.0.0.0/16 |
Shared services | Spoke |
— snet-public |
10.0.1.0/24 |
Internet-facing tier | — |
— snet-private |
10.0.2.0/24 |
Private tier, routed via NVA | — |
— (reserved) AzureFirewallSubnet |
10.0.3.0/24 |
NVA / firewall (named subnet) | — |
| Spoke VNet | 10.1.0.0/16 |
Workload | Hub |
— snet-workload |
10.1.1.0/24 |
App servers | — |
Note the reserved-name subnet: certain Azure services demand an exactly-named subnet — AzureFirewallSubnet (min /26), GatewaySubnet (for VPN/ExpressRoute gateways), AzureBastionSubnet (min /26). If you deploy those services you must name the subnet precisely and size it to the service’s minimum, or the deployment fails.
The Virtual Network: azurerm_virtual_network
The VNet is the top-level container. Its most important argument is address_space — a list of CIDR blocks (you can attach several non-contiguous ranges to one VNet). Everything else — DNS servers, DDoS plan, encryption — is optional.
resource "azurerm_virtual_network" "hub" {
name = "vnet-hub"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
address_space = ["10.0.0.0/16"]
# Optional: custom DNS (omit to use Azure-provided DNS)
dns_servers = []
tags = {
environment = "demo"
tier = "network"
}
}
A deliberate choice is visible here: there are no subnet blocks inside this resource. azurerm_virtual_network accepts inline subnet blocks, but mixing them with standalone azurerm_subnet resources causes the two to fight — a conflict we cover in the next section. Keep subnets as their own resources.
| Argument | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | VNet name, unique in the resource group |
location |
string | yes | Azure region, e.g. centralindia |
resource_group_name |
string | yes | Parent RG |
address_space |
list(string) | yes | One or more CIDRs; list, not a scalar |
dns_servers |
list(string) | no | Custom DNS; empty/omit = Azure DNS |
subnet |
block | no | Inline subnets — avoid; use azurerm_subnet |
ddos_protection_plan |
block | no | Attach a DDoS plan (paid) |
bgp_community |
string | no | Advertise a BGP community over ExpressRoute |
flow_timeout_in_minutes |
number | no | Connection flow timeout (4–30) |
tags |
map(string) | no | Cost/ownership tags |
Subnets, service endpoints, delegation & the for_each pattern
Subnets slice the VNet. In azurerm the key argument is address_prefixes — note the plural and the list; a very common first error is writing address_prefix = "10.0.1.0/24" (the deprecated singular) and getting an “Unsupported argument” error. Beyond the prefix, three optional features matter:
- Service endpoints extend a subnet’s identity to selected Azure PaaS services (Storage, SQL, Key Vault) so those services can firewall to the subnet over the Azure backbone — the traffic never touches the public internet, and you never manage an IP allow-list.
- Delegation hands a subnet to a specific Azure service that needs to inject its own infrastructure (Azure Container Instances, App Service VNet integration, PostgreSQL Flexible Server). A delegated subnet can only host that service.
- Private-endpoint network policies control whether NSGs and UDRs apply to private endpoints placed in the subnet.
Here is one subnet written longhand, then the pattern you will actually use.
resource "azurerm_subnet" "private" {
name = "snet-private"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.0.2.0/24"] # plural + list
service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"]
# Example delegation (commented — a delegated subnet is single-purpose):
# delegation {
# name = "aci"
# service_delegation {
# name = "Microsoft.ContainerInstance/containerGroups"
# actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
# }
# }
}
| Argument | Type | Notes |
|---|---|---|
name |
string | Subnet name; some services require an exact name |
resource_group_name |
string | Same RG as the VNet |
virtual_network_name |
string | Parent VNet |
address_prefixes |
list(string) | Plural; the deprecated address_prefix scalar is gone in v4 |
service_endpoints |
list(string) | e.g. Microsoft.Storage, Microsoft.Sql |
delegation |
block | name + service_delegation { name, actions } |
private_endpoint_network_policies |
string | Enabled / Disabled / NetworkSecurityGroupEnabled / RouteTableEnabled |
service_endpoint_policy_ids |
list(string) | Attach service-endpoint policies |
Common service endpoints and what they gate:
| Service endpoint value | Locks down access to | Typical pairing |
|---|---|---|
Microsoft.Storage |
Blob/File/Queue/Table accounts | Private app tier reading blobs |
Microsoft.Sql |
Azure SQL / Synapse | App subnet → SQL firewall rule |
Microsoft.KeyVault |
Key Vault | Secrets access from the subnet |
Microsoft.ServiceBus |
Service Bus namespaces | Messaging tier |
Microsoft.ContainerRegistry |
ACR | AKS/agent nodes pulling images |
Microsoft.Web |
App Service | VNet-integrated web apps |
And the common delegations (a delegated subnet is dedicated to that one service):
Delegation service_delegation.name |
Service that owns the subnet |
|---|---|
Microsoft.ContainerInstance/containerGroups |
Azure Container Instances |
Microsoft.Web/serverFarms |
App Service regional VNet integration |
Microsoft.DBforPostgreSQL/flexibleServers |
PostgreSQL Flexible Server |
Microsoft.DBforMySQL/flexibleServers |
MySQL Flexible Server |
Microsoft.Netapp/volumes |
Azure NetApp Files |
Microsoft.ServiceFabricMesh/networks |
Service Fabric Mesh |
The for_each-over-a-subnets-map pattern
Writing one azurerm_subnet block per subnet does not scale — five subnets means five near-identical blocks, and adding one is copy-paste. The idiomatic answer is a map variable plus a single for_each resource. Define the shape once:
variable "subnets" {
description = "Map of subnet name => config. Add a key to add a subnet."
type = map(object({
address_prefixes = list(string)
service_endpoints = optional(list(string), [])
delegation = optional(string, null)
}))
default = {
public = {
address_prefixes = ["10.0.1.0/24"]
}
private = {
address_prefixes = ["10.0.2.0/24"]
service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"]
}
}
}
Then create every subnet from the map in one resource block:
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = "snet-${each.key}"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = each.value.address_prefixes
service_endpoints = each.value.service_endpoints
dynamic "delegation" {
for_each = each.value.delegation == null ? [] : [each.value.delegation]
content {
name = "delegation"
service_delegation {
name = delegation.value
actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
}
}
}
}
Now the subnets are addressed as azurerm_subnet.this["public"] and azurerm_subnet.this["private"]. Adding a subnet is a one-line map entry; removing one deletes exactly that subnet. Crucially, because for_each keys on the map key (a stable string like public), Terraform tracks each subnet by name — adding management to the map does not renumber or force-replace public and private, which is precisely the failure mode of count (index shifting). This is the single most important reason to prefer for_each for a set of similar-but-named resources; the mechanics are covered in Resources & Meta-Arguments: count, for_each, lifecycle.
Inline subnet blocks vs standalone azurerm_subnet
Azure exposes subnets two ways and Terraform mirrors both, which creates a genuine trap: you can declare subnets inside azurerm_virtual_network (as subnet blocks) or as standalone azurerm_subnet resources — but not both for the same VNet. If you do, each apply sees the other as drift: the VNet resource wants to remove the subnet it doesn’t know about, the standalone resource re-creates it, and your plan is never empty.
| Aspect | Inline subnet {} in the VNet |
Standalone azurerm_subnet |
|---|---|---|
| Where defined | Nested in azurerm_virtual_network |
Its own resource |
| Granular add/remove | No — whole VNet re-planned | Yes — per subnet |
Works with for_each per subnet |
Awkward | Natural |
| Associations (NSG/route table) | Harder to wire | Clean — reference .id |
| Verdict | Avoid | Use this |
The fix for the conflict is to pick one model and stick to it. Use standalone azurerm_subnet (as this lesson does) and never add inline subnet blocks to the VNet. If you inherit a config that mixes them, remove the inline blocks and let the standalone resources own the subnets.
Network Security Groups: NSG + rules
A Network Security Group is a stateful packet filter — a list of allow/deny rules evaluated by priority — that you attach to a subnet or a NIC. Lower priority numbers are evaluated first; the first matching rule wins and evaluation stops. Azure ships a set of hidden default rules (allow intra-VNet, allow Azure load-balancer probes, deny everything else inbound; allow all outbound) at priorities ≥ 65000, so your rules layer on top at priorities 100–4096.
In Terraform there are two ways to express rules, and — exactly like subnets — mixing them conflicts. You can put security_rule blocks inline in the NSG, or declare each rule as a standalone azurerm_network_security_rule. Here is the inline form (compact, good for a fixed rule set):
resource "azurerm_network_security_group" "web" {
name = "nsg-web"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
security_rule {
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "1.2.3.4/32" # your office IP, NOT *
destination_address_prefix = "*"
}
security_rule {
name = "allow-http"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "Internet" # a service tag
destination_address_prefix = "*"
}
tags = { tier = "network" }
}
Every rule takes the same core attributes:
| Attribute | Values | Meaning |
|---|---|---|
priority |
100–4096 | Lower = evaluated first; must be unique per NSG |
direction |
Inbound / Outbound |
Which way the flow initiates |
access |
Allow / Deny |
What to do on match |
protocol |
Tcp / Udp / Icmp / Ah / Esp / * |
Transport protocol |
source_port_range(s) |
*, 80, 1024-2048, list |
Almost always * (ephemeral source ports) |
destination_port_range(s) |
22, 443, 8080, range, list |
The listening port(s) |
source_address_prefix(es) |
CIDR, IP, *, or a service tag |
Where traffic comes from |
destination_address_prefix(es) |
CIDR, IP, *, ASG id |
Where it’s going |
Note the plural variants (destination_port_ranges, source_address_prefixes) for lists — use the singular for one value, the plural for several; setting both on one rule errors. Service tags like Internet, VirtualNetwork, AzureLoadBalancer, and Storage let you name Azure’s managed IP sets instead of hard-coding ranges. Application Security Groups (ASGs) let you target rules at logical groups of NICs instead of IPs.
The worked rule set for the demo — read it top-to-bottom as Azure does:
| Prio | Name | Dir | Access | Proto | Src | Dst port | Purpose |
|---|---|---|---|---|---|---|---|
| 100 | allow-ssh | Inbound | Allow | Tcp | 1.2.3.4/32 |
22 | Admin SSH from office IP only |
| 110 | allow-http | Inbound | Allow | Tcp | Internet |
80 | Public web traffic |
| 120 | allow-https | Inbound | Allow | Tcp | Internet |
443 | Public TLS |
| 200 | allow-vnet | Inbound | Allow | * |
VirtualNetwork |
* |
Intra-VNet (redundant w/ default, explicit) |
| 4096 | deny-all-in | Inbound | Deny | * |
* |
* |
Explicit belt-and-braces deny |
Inline security_rule vs standalone azurerm_network_security_rule
The standalone form declares each rule as its own resource pointing back at the NSG:
resource "azurerm_network_security_rule" "allow_https" {
name = "allow-https"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.net.name
network_security_group_name = azurerm_network_security_group.web.name
}
⚠️ The conflict: if the NSG resource contains any inline security_rule block, it takes exclusive ownership of the rule set — Terraform believes the inline list is the complete, authoritative set of rules. A standalone azurerm_network_security_rule pointing at that same NSG is then seen as an extra rule the NSG doesn’t know about, so every plan flaps: the NSG wants to delete the standalone rule, the standalone resource re-creates it, forever. Pick one model:
| Model | When to use | Rule of thumb |
|---|---|---|
Inline security_rule blocks |
Rules are fixed and defined with the NSG | Simple, all rules in one place |
Standalone azurerm_network_security_rule |
Rules added dynamically or by other modules | Never also put inline rules on that NSG |
The safe default: use inline security_rule blocks for a self-contained NSG (as the demo does), and switch to standalone rules only when a separate module or for_each must contribute rules to an NSG it doesn’t own — and in that case the NSG must have zero inline rules.
For reference, the platform default rules you are layering on top of:
| Priority | Name | Direction | Access | Effect |
|---|---|---|---|---|
| 65000 | AllowVnetInBound | Inbound | Allow | Traffic within the VNet |
| 65001 | AllowAzureLoadBalancerInBound | Inbound | Allow | LB health probes |
| 65500 | DenyAllInBound | Inbound | Deny | Everything else inbound |
| 65000 | AllowVnetOutBound | Outbound | Allow | Traffic within the VNet |
| 65001 | AllowInternetOutBound | Outbound | Allow | All outbound to internet |
| 65500 | DenyAllOutBound | Outbound | Deny | (After the allow above) |
Associating an NSG to a subnet
Creating the NSG does nothing on its own — it filters traffic only once bound to a subnet (or NIC). In Terraform that binding is a separate resource: azurerm_subnet_network_security_group_association. This surprises people: why not just set network_security_group_id on the subnet? Because a subnet and its NSG (and its route table) have independent lifecycles — you frequently swap an NSG without touching the subnet, or reuse one NSG across subnets — and modelling the association as its own resource lets Terraform manage that relationship cleanly and avoids a circular dependency between the subnet and the NSG.
resource "azurerm_subnet_network_security_group_association" "public" {
subnet_id = azurerm_subnet.this["public"].id
network_security_group_id = azurerm_network_security_group.web.id
}
The same pattern binds a route table to a subnet:
resource "azurerm_subnet_route_table_association" "private" {
subnet_id = azurerm_subnet.this["private"].id
route_table_id = azurerm_route_table.egress.id
}
| Association resource | Binds | Key arguments | Why separate |
|---|---|---|---|
azurerm_subnet_network_security_group_association |
NSG → subnet | subnet_id, network_security_group_id |
Independent NSG lifecycle; reuse across subnets |
azurerm_subnet_route_table_association |
Route table → subnet | subnet_id, route_table_id |
Independent RT lifecycle; swap routing without touching subnet |
azurerm_network_interface_security_group_association |
NSG → NIC | network_interface_id, network_security_group_id |
Per-VM override of subnet NSG |
⚠️ The association conflict: do not also set the NSG or route table inside the azurerm_subnet block (older configs sometimes used network_security_group_id on the subnet). If both the subnet and the association resource try to own the relationship, every apply thrashes — one attaches, the other detaches. Own it in exactly one place: the association resource. If you see a plan that repeatedly attaches/detaches an NSG with no config change, this is the cause.
Route tables & UDRs: forcing traffic through an NVA
By default Azure gives every subnet a set of invisible system routes: local VNet traffic stays local, 0.0.0.0/0 goes straight to the internet, peered ranges route to the peer. A route table with user-defined routes (UDRs) overrides those system routes so you can, most importantly, force egress through a firewall or NVA for inspection — the security control that makes hub-spoke worth building.
The classic UDR sends all internet-bound traffic (0.0.0.0/0) to a VirtualAppliance next hop (the firewall’s private IP) instead of straight out:
resource "azurerm_route_table" "egress" {
name = "rt-egress"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
# v4 renamed disable_bgp_route_propagation -> bgp_route_propagation_enabled
bgp_route_propagation_enabled = false
}
resource "azurerm_route" "default_via_nva" {
name = "default-to-firewall"
resource_group_name = azurerm_resource_group.net.name
route_table_name = azurerm_route_table.egress.name
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.0.3.4" # the firewall/NVA private IP
}
The next_hop_type is the heart of a route. next_hop_in_ip_address is set only for VirtualAppliance; for any other type it must be omitted (setting it errors).
next_hop_type |
Sends matching traffic to | Needs next_hop_in_ip_address? |
|---|---|---|
VirtualAppliance |
A firewall/NVA at a given IP | Yes — the appliance’s IP |
VirtualNetworkGateway |
VPN / ExpressRoute gateway | No |
VnetLocal |
Stay inside the VNet | No |
Internet |
Azure’s internet edge | No |
None |
Blackhole — drop the traffic | No |
The demo route table, in table form (this is the UDR reference to keep):
| Route name | address_prefix |
next_hop_type |
next_hop_in_ip_address |
Effect |
|---|---|---|---|---|
| default-to-firewall | 0.0.0.0/0 |
VirtualAppliance |
10.0.3.4 |
All egress inspected by the NVA |
| to-onprem | 192.168.0.0/16 |
VirtualNetworkGateway |
— | On-prem via VPN/ER gateway |
| blackhole-metadata | 169.254.169.254/32 |
None |
— | Block instance metadata abuse |
azurerm_route_table / azurerm_route argument |
Belongs to | Notes |
|---|---|---|
bgp_route_propagation_enabled |
route_table | v4 name (was disable_bgp_route_propagation, inverted) |
route (inline block) |
route_table | Optional inline routes — same inline-vs-standalone caution as NSG rules |
address_prefix |
route | The destination CIDR this route matches |
next_hop_type |
route | One of the five values above |
next_hop_in_ip_address |
route | Only with VirtualAppliance |
Like NSG rules, routes come in inline (route {} inside the table) and standalone (azurerm_route) forms with the same conflict — don’t mix them for one table. The demo uses standalone azurerm_route so a module can contribute routes cleanly.
VNet peering: hub-spoke
Peering connects two VNets over the Azure backbone so their resources talk by private IP as if on one network — no gateway, no public hop, low latency. Two facts drive the Terraform model:
- Peering is directional.
azurerm_virtual_network_peeringdescribes one direction. To connect hub and spoke you declare two resources: hub→spoke and spoke→hub. Omit one and traffic flows only one way (and the portal shows the link as “Initiated”, never “Connected”). - Peering is non-transitive. If spoke-A peers to the hub and spoke-B peers to the hub, spoke-A and spoke-B cannot talk through the hub automatically — you either peer them directly or route them through an NVA in the hub (the reason the UDR above exists).
# hub -> spoke
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
name = "peer-hub-to-spoke"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.hub.name
remote_virtual_network_id = azurerm_virtual_network.spoke.id
allow_virtual_network_access = true
allow_forwarded_traffic = true # accept traffic the NVA forwards
allow_gateway_transit = true # hub shares its gateway
use_remote_gateways = false
}
# spoke -> hub
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
name = "peer-spoke-to-hub"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.spoke.name
remote_virtual_network_id = azurerm_virtual_network.hub.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = true # spoke uses the hub's gateway
}
The four boolean toggles are where hub-spoke behaviour actually lives:
| Argument | Default | What it does | Hub-spoke setting |
|---|---|---|---|
allow_virtual_network_access |
true |
Let the peered VNet’s resources reach this one | true both sides |
allow_forwarded_traffic |
false |
Accept traffic forwarded by an NVA (not originating in the peer) | true (so NVA-routed traffic is accepted) |
allow_gateway_transit |
false |
This VNet shares its VPN/ER gateway with the peer | true on hub |
use_remote_gateways |
false |
This VNet uses the peer’s gateway (can’t have its own) | true on spoke |
The allow_gateway_transit / use_remote_gateways pair is the classic spoke-uses-hub-gateway setup: set transit on the hub and use-remote on the spoke, and never both use_remote_gateways = true on both sides (only one VNet can own the gateway). A subtle ordering point: both peering resources should be applied together; Azure marks the link “Connected” only once both directions exist, which Terraform handles because it creates both in one apply.
Private endpoints vs service endpoints, briefly
Two features secure access to Azure PaaS from a subnet, and they are often confused. Service endpoints (seen earlier on the subnet) keep traffic to a PaaS service on the Azure backbone and let the service firewall to your subnet — but the service still has a public IP. Private endpoints (azurerm_private_endpoint) go further: they place a private NIC for the PaaS service inside your subnet, giving the service a private IP in your address space and (with Private DNS) removing its public exposure entirely.
| Aspect | Service endpoint | Private endpoint |
|---|---|---|
| Terraform | service_endpoints on the subnet |
azurerm_private_endpoint resource |
| Service gets | Firewall rule allowing your subnet | A private NIC/IP in your subnet |
| Public IP of service | Still public | Can be fully removed |
| Cost | Free | Per-endpoint + data processing |
| DNS | Unchanged | Needs Private DNS zone override |
| Use when | Cheap subnet-level lockdown | Full private connectivity / compliance |
Private endpoints are a lesson of their own; the important thing here is that the subnet is where both land, and that a subnet hosting private endpoints may need private_endpoint_network_policies = "Enabled" to have NSGs/UDRs apply to them.
Hands-on: build it with Terraform
Now assemble everything into a working configuration and run it end to end. ⚠️ This provisions real Azure resources. The good news: VNets, subnets, NSGs, route tables and peering are free — you pay only for data and gateways, neither of which this demo creates — so the cost of running it is effectively ₹0, and destroy returns you to zero. (Leave it running only if you add VMs or gateways later.)
Layout:
azure-network/
├── versions.tf
├── providers.tf
├── variables.tf
├── main.tf
└── outputs.tf
versions.tf — pin Terraform and the provider, and configure the remote backend (a Storage Account created in the getting-started lesson):
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "kvtfstate2026" # your unique account
container_name = "tfstate"
key = "network/hub.tfstate"
}
}
providers.tf — in azurerm v4 the subscription_id is required (here or via ARM_SUBSCRIPTION_ID):
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
variables.tf — parameterise region, subscription, and the subnets map:
variable "subscription_id" {
type = string
description = "Target Azure subscription ID."
}
variable "location" {
type = string
default = "centralindia"
}
variable "hub_address_space" {
type = list(string)
default = ["10.0.0.0/16"]
}
variable "spoke_address_space" {
type = list(string)
default = ["10.1.0.0/16"]
}
variable "nva_ip" {
type = string
default = "10.0.3.4"
description = "Private IP of the firewall/NVA the UDR points at."
}
variable "admin_source_cidr" {
type = string
default = "1.2.3.4/32"
description = "Your office/home IP for SSH. NEVER use 0.0.0.0/0."
}
variable "subnets" {
type = map(object({
address_prefixes = list(string)
service_endpoints = optional(list(string), [])
}))
default = {
public = { address_prefixes = ["10.0.1.0/24"] }
private = { address_prefixes = ["10.0.2.0/24"], service_endpoints = ["Microsoft.Storage", "Microsoft.KeyVault"] }
}
}
main.tf — the whole topology:
resource "azurerm_resource_group" "net" {
name = "rg-network-hub"
location = var.location
}
# ---- Hub VNet + subnets (for_each) ----
resource "azurerm_virtual_network" "hub" {
name = "vnet-hub"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
address_space = var.hub_address_space
tags = { tier = "network" }
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = "snet-${each.key}"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = each.value.address_prefixes
service_endpoints = each.value.service_endpoints
}
# ---- NSG + rules, associated to the public subnet ----
resource "azurerm_network_security_group" "web" {
name = "nsg-web"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
security_rule {
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.admin_source_cidr
destination_address_prefix = "*"
}
security_rule {
name = "allow-http"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "public" {
subnet_id = azurerm_subnet.this["public"].id
network_security_group_id = azurerm_network_security_group.web.id
}
# ---- Route table + UDR, associated to the private subnet ----
resource "azurerm_route_table" "egress" {
name = "rt-egress"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
bgp_route_propagation_enabled = false
}
resource "azurerm_route" "default_via_nva" {
name = "default-to-firewall"
resource_group_name = azurerm_resource_group.net.name
route_table_name = azurerm_route_table.egress.name
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = var.nva_ip
}
resource "azurerm_subnet_route_table_association" "private" {
subnet_id = azurerm_subnet.this["private"].id
route_table_id = azurerm_route_table.egress.id
}
# ---- Spoke VNet + bidirectional peering ----
resource "azurerm_virtual_network" "spoke" {
name = "vnet-spoke"
location = azurerm_resource_group.net.location
resource_group_name = azurerm_resource_group.net.name
address_space = var.spoke_address_space
tags = { tier = "workload" }
}
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
name = "peer-hub-to-spoke"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.hub.name
remote_virtual_network_id = azurerm_virtual_network.spoke.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
}
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
name = "peer-spoke-to-hub"
resource_group_name = azurerm_resource_group.net.name
virtual_network_name = azurerm_virtual_network.spoke.name
remote_virtual_network_id = azurerm_virtual_network.hub.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
}
outputs.tf — surface the IDs downstream resources (VMs, endpoints) will need:
output "hub_vnet_id" {
value = azurerm_virtual_network.hub.id
}
output "subnet_ids" {
description = "Map of subnet name => id, for VM/NIC placement."
value = { for k, s in azurerm_subnet.this : k => s.id }
}
output "nsg_id" {
value = azurerm_network_security_group.web.id
}
output "peering_state" {
value = azurerm_virtual_network_peering.hub_to_spoke.name
}
The diagram below is what this configuration builds — read it left to right: Terraform declares the resource group and hub VNet, fans out the subnets from the map with for_each, attaches the NSG and route table as separate association resources, then peers the hub to the spoke in both directions.
Step 1 — terraform init
export ARM_SUBSCRIPTION_ID="<your-sub-id>" # or set var.subscription_id
az login # if not already
terraform init
You should see the backend initialise against the Storage Account and the azurerm provider download:
Initializing the backend...
Successfully configured the backend "azurerm"!
Initializing provider plugins...
- Installing hashicorp/azurerm v4.x.x...
Terraform has been successfully initialized!
Step 2 — terraform plan
terraform plan -out=tfplan
Representative tail — note two subnets from one block (["public"], ["private"]) and both peering directions:
# azurerm_subnet.this["private"] will be created
# azurerm_subnet.this["public"] will be created
# azurerm_virtual_network_peering.hub_to_spoke will be created
# azurerm_virtual_network_peering.spoke_to_hub will be created
...
Plan: 11 to add, 0 to change, 0 to destroy.
Eleven resources: RG, 2 VNets, 2 subnets, NSG, NSG association, route table, route, route-table association, and 2 peerings.
Step 3 — terraform apply
terraform apply tfplan
Terraform respects dependencies automatically — the VNet before its subnets, the subnets before their associations, both VNets before the peerings — because each association references the other resource’s .id. Expect Apply complete! Resources: 11 added, 0 changed, 0 destroyed. and your outputs printed.
Step 4 — verify with az
Confirm the real cloud state, not just Terraform’s word:
# VNet + its address space and subnets
az network vnet show -g rg-network-hub -n vnet-hub \
--query "{name:name, space:addressSpace.addressPrefixes, subnets:subnets[].name}" -o jsonc
# NSG rules actually landed
az network nsg rule list --nsg-name nsg-web -g rg-network-hub \
--query "[].{name:name, prio:priority, port:destinationPortRange, access:access}" -o table
# Route table + UDR
az network route-table route list -g rg-network-hub \
--route-table-name rt-egress -o table
# Peering state — must say 'Connected' both ways
az network vnet peering list -g rg-network-hub --vnet-name vnet-hub \
--query "[].{name:name, state:peeringState, remote:remoteVirtualNetwork.id}" -o table
The peering peeringState should read Connected (not “Initiated”) because both directions exist. If it says “Initiated”, one peering resource is missing — the single most common peering mistake.
Step 5 — terraform destroy (⚠️ cleanup)
terraform destroy
Everything is free, but destroy anyway to keep the subscription clean and rehearse teardown. Terraform removes resources in reverse-dependency order: peerings and associations first, then subnets, then VNets and the RG. Expect Destroy complete! Resources: 11 destroyed. If destroy stalls on a subnet (“subnet is in use”), something outside this config (a VM NIC, a private endpoint) is still attached — see troubleshooting.
Variables, outputs & making it reusable
The demo is already parameterised (region, address spaces, the subnets map, the NVA IP). The natural next step is to lift it into a reusable module so every environment and every spoke calls the same code with different inputs. A minimal module interface:
# modules/vnet/variables.tf
variable "name" { type = string }
variable "location" { type = string }
variable "rg_name" { type = string }
variable "address_space" { type = list(string) }
variable "subnets" {
type = map(object({
address_prefixes = list(string)
service_endpoints = optional(list(string), [])
}))
}
# root: call it per environment / per spoke
module "hub" {
source = "./modules/vnet"
name = "vnet-hub"
location = var.location
rg_name = azurerm_resource_group.net.name
address_space = ["10.0.0.0/16"]
subnets = var.subnets
}
Because subnets are a map, the module needs no code change to support 2 or 20 subnets — the caller’s map drives it. Outputs should return the subnet_ids map (shown above) so callers place NICs by subnet name (module.hub.subnet_ids["private"]) rather than by fragile index.
Should you write your own or use a registry module? The community Azure/vnet/azurerm and Azure/network-security-group/azurerm modules are battle-tested and worth using for standard topologies; roll your own when your subnet/association/peering conventions are opinionated enough that bending a generic module costs more than owning a thin one.
| Approach | Use when | Trade-off |
|---|---|---|
Registry module (Azure/vnet/azurerm) |
Standard VNet/subnet layout, want speed | Less control; pin the version, read its inputs |
| Thin roll-your-own module | Opinionated naming/associations/peering | You maintain it; but it’s exactly your shape |
| No module (root only) | One environment, learning | Copy-paste across envs; drifts over time |
Common mistakes and troubleshooting
Networking errors in azurerm are usually one of a dozen recognisable shapes. Match the symptom, apply the fix.
| Symptom / error | Cause | Fix |
|---|---|---|
Unsupported argument: address_prefix |
Used the deprecated singular scalar | Use address_prefixes = ["10.0.1.0/24"] (plural list) |
| Plan flaps: NSG attach/detach every apply, no config change | Subnet and an association resource both own the NSG link | Own it in one place — the association resource; remove network_security_group_id from the subnet |
| Plan flaps: a security rule added then removed forever | NSG has inline security_rule and a standalone azurerm_network_security_rule |
Pick one model; if standalone, the NSG must have zero inline rules |
Address space ... overlaps with ... on peering create |
Hub and spoke CIDRs overlap | Renumber one VNet; peered VNets must not overlap |
Peering shows Initiated, traffic one-way |
Only one direction declared | Declare both azurerm_virtual_network_peering resources |
Two spokes can't reach each other |
Peering is non-transitive | Peer spokes directly, or route via an NVA in the hub |
Subnet <x> is in use and cannot be deleted on destroy |
A NIC / private endpoint / delegation still attached | Delete the attached resource (or its config) first, then destroy |
Security rule has conflicting priority |
Two rules share a priority in one NSG |
Priorities must be unique per NSG; renumber |
next_hop_in_ip_address must be specified / must not be specified |
Set it with a non-VirtualAppliance hop, or omitted for VirtualAppliance | Set the IP only for VirtualAppliance; omit otherwise |
subnet is not valid in virtual network |
Subnet prefix outside the VNet address_space |
Carve subnet CIDRs from inside the VNet range |
AuthorizationFailed creating the VNet |
Identity lacks Network Contributor on the RG/sub | Grant Network Contributor; re-run |
| Delegated subnet won’t host your resource | Subnet delegated to a different service (single-purpose) | Use a dedicated subnet per delegated service |
The four you will actually hit in your first week deserve prose. The NSG-association thrash is the number-one confusion: if a plan endlessly attaches and detaches an NSG with no source change, you have two owners for the relationship — delete the network_security_group_id from the subnet block and let azurerm_subnet_network_security_group_association own it. The inline-vs-standalone conflict is the same disease for rules and routes: an NSG (or route table) with inline blocks believes it owns the complete set, so a standalone rule/route pointed at it flaps forever — choose one model per NSG/table. Overlapping CIDRs fail loudly at peering-create time, which is a gift (it fails before anything is half-built); the fix is always upstream in your address plan, never a Terraform flag. And subnet-in-use on destroy means something outside this config is still attached — a VM’s NIC, a private endpoint, a service delegation — so Terraform can’t remove the subnet; find and remove the attachment, then destroy.
Cost, cleanup & production notes
Cost. The resources in this lesson are among Azure’s free networking primitives. VNets, subnets, NSGs, route tables and intra-region peering carry no hourly charge; you pay for the things you did not build here.
| Resource | Cost while running |
|---|---|
| VNet, subnets, NSG, route table | Free |
| VNet peering (same region) | Free to configure; ~₹0.008/GB each direction for data |
| VNet peering (cross-region / global) | Higher per-GB inbound + outbound |
| VPN / ExpressRoute gateway (not in demo) | Hourly + data — the expensive part |
| Azure Firewall / NVA (the UDR target) | Hourly + data if you deploy a real one |
| Public IP, NAT Gateway (not in demo) | Hourly + data |
So the demo costs effectively nothing, but the UDR points at an NVA IP (10.0.3.4) that doesn’t exist yet — traffic to 0.0.0.0/0 from the private subnet would blackhole until you deploy a firewall there. That’s fine for a networking demo with no VMs; just know the route is “armed” for when compute and a firewall arrive.
Cleanup is terraform destroy. Because everything is free, the only reason to destroy is hygiene and rehearsal — but do it, because a forgotten gateway added later is not free.
Production hardening notes:
- Remote state, always. A network is shared infrastructure; its state must be remote (Azure Storage backend) with locking so two engineers can’t apply concurrently — set up in the getting-started lesson.
- Least privilege. The pipeline identity needs
Network Contributorscoped to the networking RG, notOwneron the subscription. - Tag everything (
environment,owner,cost-center) via adefault_tags-style local merged onto every resource, so networking spend and ownership are queryable. - Never
0.0.0.0/0on SSH. The demo scopes SSH toadmin_source_cidr; in production front management with Azure Bastion and drop the public SSH rule entirely. - Watch drift. Run
terraform planon a schedule (or in CI) so a portal change to an NSG rule surfaces as drift before it becomes a silent security hole.
Cheat-sheet
The resources and their load-bearing arguments:
| Resource | Key arguments | Remember |
|---|---|---|
azurerm_virtual_network |
address_space (list), dns_servers |
Address space is a list of CIDRs |
azurerm_subnet |
address_prefixes (list), service_endpoints, delegation |
Plural address_prefixes; use for_each |
azurerm_network_security_group |
inline security_rule, tags |
Inline or standalone rules, never both |
azurerm_network_security_rule |
priority, direction, access, protocol |
Standalone; NSG must have zero inline rules |
azurerm_subnet_network_security_group_association |
subnet_id, network_security_group_id |
Separate resource — the one true owner |
azurerm_route_table |
bgp_route_propagation_enabled |
v4 renamed the BGP flag (inverted) |
azurerm_route |
address_prefix, next_hop_type, next_hop_in_ip_address |
IP only with VirtualAppliance |
azurerm_subnet_route_table_association |
subnet_id, route_table_id |
Separate resource |
azurerm_virtual_network_peering |
remote_virtual_network_id, allow_forwarded_traffic, allow_gateway_transit, use_remote_gateways |
Two resources; transit on hub, use-remote on spoke |
Commands:
| Command | Purpose |
|---|---|
terraform init |
Init backend + download azurerm |
terraform plan -out=tfplan |
Preview; save the plan |
terraform apply tfplan |
Apply the saved plan |
terraform state list |
See azurerm_subnet.this["public"] etc. |
az network vnet show -g <rg> -n <vnet> |
Verify VNet + subnets |
az network nsg rule list --nsg-name <nsg> -g <rg> -o table |
Verify NSG rules |
az network route-table route list -g <rg> --route-table-name <rt> -o table |
Verify UDRs |
az network vnet peering list -g <rg> --vnet-name <vnet> -o table |
Verify peering (expect Connected) |
terraform destroy |
Tear it all down |
Interview and exam questions
1. Why is address_prefixes plural and what breaks if you use address_prefix?
azurerm_subnet takes address_prefixes as a list (a subnet can carry more than one prefix). The old singular address_prefix is deprecated/removed in provider v4, so using it raises “Unsupported argument.” Always use the plural list form.
2. Why is the NSG-to-subnet binding a separate resource instead of an argument on the subnet?
Because the subnet, its NSG, and its route table have independent lifecycles — you swap or reuse NSGs without touching subnets — and modelling the association separately (azurerm_subnet_network_security_group_association) avoids a circular dependency and lets each be managed on its own. Setting it in two places (subnet + association) makes every apply thrash.
3. You added a subnet to your for_each map and Terraform wants to replace existing subnets. What went wrong — and would count have been better?
It shouldn’t with for_each keyed on stable map keys — adding a key only adds that subnet. If you saw replacement you were likely using count (index-based), where inserting an element shifts indices and forces-replaces everything after it. for_each over a map is exactly the fix; keys are stable identities.
4. Explain the inline-security_rule-vs-azurerm_network_security_rule conflict.
An NSG with inline security_rule blocks owns the entire rule set authoritatively. A standalone azurerm_network_security_rule targeting the same NSG is then perpetual drift — the NSG removes it, the resource re-creates it, every apply. Use one model: inline for a self-contained NSG, standalone only when the NSG has no inline rules.
5. Two VNets won’t peer — the apply errors on overlapping address space. Fix?
Peered VNets must have non-overlapping CIDRs. Renumber one VNet’s address_space so the ranges are disjoint (e.g. hub 10.0.0.0/16, spoke 10.1.0.0/16). This is an address-plan fix, not a provider flag.
6. How many peering resources connect a hub and a spoke, and why?
Two — one per direction (hub_to_spoke, spoke_to_hub). azurerm_virtual_network_peering is directional; with only one, the link stays “Initiated” and traffic flows one way. Both together make it “Connected.”
7. What does a UDR with next_hop_type = "VirtualAppliance" achieve, and what else must you set?
It overrides Azure’s default system route so matching traffic (often 0.0.0.0/0) is sent to a firewall/NVA for inspection instead of straight to the internet. You must also set next_hop_in_ip_address to the appliance’s private IP — required for VirtualAppliance, forbidden for every other hop type.
8. In hub-spoke, which peering gets allow_gateway_transit and which gets use_remote_gateways?
The hub (owning the VPN/ExpressRoute gateway) sets allow_gateway_transit = true; the spoke sets use_remote_gateways = true to use the hub’s gateway. Only one VNet can own the gateway, so never set use_remote_gateways on both.
9. Peering is non-transitive — what does that mean for two spokes? Spoke-A ↔ hub and Spoke-B ↔ hub does not give Spoke-A ↔ Spoke-B. To connect them you either peer the two spokes directly or force their traffic through an NVA in the hub (via UDRs), which is why hub-spoke pairs peering with routing.
10. How many usable IPs does a /24 subnet give you on Azure, and why not 254?
251. Azure reserves five addresses in every subnet — the network address, three host addresses (.1 default gateway, .2/.3 for DNS mapping), and the broadcast address.
11. (Associate-style) True/False: you can create subnets both inline in azurerm_virtual_network and as standalone azurerm_subnet resources for the same VNet.
False. Mixing the two makes each apply fight the other (the VNet removes the “unknown” subnet, the standalone resource re-creates it). Pick one model — standalone azurerm_subnet is recommended.
12. Service endpoint vs private endpoint — one-line distinction? A service endpoint keeps subnet→PaaS traffic on the backbone and lets the PaaS service firewall to your subnet (service keeps a public IP); a private endpoint puts a private NIC for the PaaS service inside your subnet, giving it a private IP and removing public exposure.
Key takeaways
- Plan addresses first. VNets and subnets carve from a non-overlapping supernet; Azure reserves 5 IPs per subnet, and peering hard-fails on overlap — the address plan is the one decision you can’t cheaply undo.
address_spaceis a list;address_prefixesis a plural list. The singular scalar is gone in provider v4.- Use
for_eachover a subnets map, not repeated blocks orcount— stable map keys mean adding a subnet never renumbers the others. - Associations are separate resources.
azurerm_subnet_network_security_group_associationandazurerm_subnet_route_table_associationare the one true owner of those bindings — never also set them on the subnet, or the plan thrashes. - Inline vs standalone is an either/or. NSG rules and routes must use one model per NSG/table; mixing them flaps forever.
- A UDR to a
VirtualAppliancenext hop is how hub-spoke forces egress through a firewall — setnext_hop_in_ip_addressonly for that hop type. - Peering is two directional resources and is non-transitive — declare both directions (transit on hub, use-remote on spoke), and connect spokes via the hub’s NVA, not through the peering.
- It’s all free to build, so stand it up, verify with
az, anddestroy— then drop VMs into these subnets with Terraform on Azure: Linux & Windows VMs, VMSS & cloud-init.