Terraform Lesson 40 of 89

Terraform on Azure: Virtual Networks, Subnets, NSGs, Route Tables & VNet Peering

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:

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:

  1. Peering is directional. azurerm_virtual_network_peering describes 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”).
  2. 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.

Terraform-driven Azure hub-spoke network: the azurerm provider creates a resource group and a hub VNet with address space 10.0.0.0/16, fans out a public and a private subnet from a map using for_each, attaches an NSG allowing SSH and HTTP and a route table with a user-defined route to an NVA as separate subnet-association resources, and peers the hub bidirectionally to a spoke VNet on the non-overlapping range 10.1.0.0/16

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:

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

TerraformazurermAzureVNetSubnetsNSGRoute TableVNet PeeringNetworkingService EndpointsHub-SpokeIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments