Manage an Azure Route Table and its user-defined routes as one keyed unit, on
hashicorp/azurerm ~> 4.0.
- 🧭 Creates one
azurerm_route_tableand itsazurerm_routechildren from a keyed map (for_each). - 🚏 Routes are managed as separate resources — never inline — so adding or removing one never re-indexes the rest.
- ✅ Enum-validates
next_hop_typeand requiresnext_hop_in_ip_addressforVirtualApplianceroutes atplantime. - 🔀 Exposes
bgp_route_propagation_enabledfor forced-tunneling / isolated-egress topologies.
💡 Why it matters: user-defined routes decide where a subnet's traffic actually goes — through a firewall, a gateway, or a black hole. Owning the table and its routes as one reviewable unit keeps egress control explicit.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart TD
RG["terraform-azurerm-resource-group"]
RT["terraform-azurerm-route-table"]
ART["azurerm_route_table"]
ROUTE["azurerm_route (for_each)"]
VNET["terraform-azurerm-virtual-network (subnet association)"]
FW["Firewall / NVA (next hop)"]
RG -->|"resource_group_name + location"| RT
RT --> ART
ART --> ROUTE
FW -->|"next_hop_in_ip_address"| RT
RT -->|"id"| VNET
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class RT this;
class ART key;
flowchart LR
I1["name / location / resource_group_name"]
I2["bgp_route_propagation_enabled"]
I3["routes (map)<br/>address_prefix, next_hop_type, next_hop_in_ip_address"]
RT["azurerm_route_table.this"]
ROUTE["azurerm_route.this<br/>for_each = routes"]
O1["id"]
O2["name"]
O3["route_ids"]
O4["subnets"]
I1 --> RT
I2 --> RT
I3 --> ROUTE
RT --> ROUTE
RT --> O1
RT --> O2
ROUTE --> O3
RT --> O4
classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
classDef key fill:#004578,color:#ffffff,stroke:#004578;
class RT key;
class ROUTE this;
Resource inventory
| Resource | Cardinality | Role |
|---|---|---|
azurerm_route_table.this |
1 (keystone) | The route table. |
azurerm_route.this |
0..N (for_each) |
User-defined routes, keyed by name. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite
name,resource_group_name, andlocationare immutable — changing any forces replacement.- Do not manage these routes and also declare inline
routeblocks on the table — double management causes perpetual diffs. This module owns the routes. - A route with
next_hop_type = "VirtualAppliance"requiresnext_hop_in_ip_address, and the field is illegal on every other hop type. Both rules are enforced by this module at plan — the provider has no plan-time check for either, so without the module they fail at apply. bgp_route_propagation_enabled = falseblocks propagation of gateway-learned routes — intended for forced tunneling, but it can black-hole ExpressRoute/VPN traffic if misapplied.- The
subnetsattribute is computed (associations are made by the VNet/subnet module), so it is an output only.
- Network Contributor on the target resource group (or a custom role with
Microsoft.Network/routeTables/*andMicrosoft.Network/routeTables/routes/*).
- The
Microsoft.Networkresource provider registered on the subscription. - An existing resource group.
- For
VirtualApplianceroutes, the appliance (firewall/NVA) and its private IP already exist. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
terraform-azurerm-route-table/
├── providers.tf # required_version + azurerm ~> 4.0; no provider block
├── variables.tf # name, rg, location, bgp_route_propagation_enabled, routes (map), tags, timeouts
├── main.tf # azurerm_route_table.this + for_each azurerm_route
├── outputs.tf # id, name, route_ids, subnets
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical Terraform ignore set
provider "azurerm" {
features {}
}
module "route_table" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-egress-prod"
resource_group_name = "rg-network-prod-eastus2"
location = "eastus2"
routes = {
default-to-firewall = {
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.0.255.4"
}
}
}ℹ️ Pin the module by immutable tag (
?ref=v1.0.0), never a branch.
Consumes
| Input | Type | From |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / resource group (location) |
routes[*].next_hop_in_ip_address |
string |
a firewall / NVA private IP |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Route table Resource ID | terraform-azurerm-virtual-network (subnet association) |
name |
Route table name | diagnostics, tagging |
route_ids |
map: route key → route ID | audit |
subnets |
associated subnet IDs (computed) | reference |
1 · Minimal (no custom routes)
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-baseline"
resource_group_name = "rg-network-eastus2"
location = "eastus2"
}2 · Default route through a firewall (NVA)
routes = {
default = {
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.0.255.4"
}
}🔒 Forcing 0.0.0.0/0 through a firewall centralizes egress inspection.
3 · Black-hole a range (None)
routes = {
drop-metadata = {
address_prefix = "169.254.169.254/32"
next_hop_type = "None"
}
}🔒
Nonediscards traffic to the prefix — useful to block a range entirely.
4 · Force-tunnel to a gateway
routes = {
onprem = {
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualNetworkGateway"
}
}5 · Disable BGP route propagation
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-isolated"
resource_group_name = "rg-network-eastus2"
location = "eastus2"
bgp_route_propagation_enabled = false
routes = {
default = { address_prefix = "0.0.0.0/0", next_hop_type = "VirtualAppliance", next_hop_in_ip_address = "10.0.255.4" }
}
}
⚠️ Disabling propagation can black-hole ExpressRoute/VPN routes if the table lacks an explicit path.
6 · Route to the internet directly
routes = {
internet = { address_prefix = "13.107.6.152/31", next_hop_type = "Internet" }
}7 · Keep intra-VNet local
routes = {
vnet-local = { address_prefix = "10.0.0.0/8", next_hop_type = "VnetLocal" }
}8 · Multiple routes
routes = {
default = { address_prefix = "0.0.0.0/0", next_hop_type = "VirtualAppliance", next_hop_in_ip_address = "10.0.255.4" }
onprem = { address_prefix = "192.168.0.0/16", next_hop_type = "VirtualNetworkGateway" }
no-meta = { address_prefix = "169.254.169.254/32", next_hop_type = "None" }
}9 · Named routes via name
routes = {
r1 = { name = "Egress-Default", address_prefix = "0.0.0.0/0", next_hop_type = "VirtualAppliance", next_hop_in_ip_address = "10.0.255.4" }
}10 · for_each from a data structure
locals {
spokes = { "spoke-a" = "10.1.0.0/16", "spoke-b" = "10.2.0.0/16" }
}
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-hub"
resource_group_name = "rg-network-eastus2"
location = "eastus2"
routes = {
for k, cidr in local.spokes : "to-${k}" => {
address_prefix = cidr
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.0.255.4"
}
}
}11 · Hub egress with tagged routes
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-egress"
resource_group_name = "rg-network-eastus2"
location = "eastus2"
tags = { tier = "network", purpose = "forced-egress" }
routes = { default = { address_prefix = "0.0.0.0/0", next_hop_type = "VirtualAppliance", next_hop_in_ip_address = "10.0.255.4" } }
}12 · Custom timeouts
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-slow"
resource_group_name = "rg-network-eastus2"
location = "eastus2"
timeouts = { create = "10m", delete = "10m" }
}13 · 🏗️ End-to-end composition
A resource group, a route table forcing egress through a firewall IP, and a VNet subnet that associates it.
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-network-prod-eastus2"
location = "eastus2"
}
module "rt" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-route-table.git?ref=v1.0.0"
name = "rt-egress-prod"
resource_group_name = module.rg.name
location = module.rg.location
routes = {
default = {
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.20.255.4"
}
}
}
module "vnet" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
name = "vnet-prod-eastus2"
resource_group_name = module.rg.name
location = module.rg.location
address_space = ["10.20.0.0/16"]
subnets = {
workload = {
address_prefixes = ["10.20.1.0/24"]
route_table_id = module.rt.id
}
}
}💡 The route table owns its routes; the VNet module owns the subnet-to-route-table association, wired by
module.rt.id.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string |
✅ | — | Route table name. Immutable. |
resource_group_name |
string |
✅ | — | Containing resource group. Immutable. |
location |
string |
✅ | — | Azure region. Immutable. |
bgp_route_propagation_enabled |
bool |
— | true |
Whether gateway-learned routes propagate. |
routes |
map(object) |
— | {} |
Keyed user-defined routes. |
tags |
map(string) |
— | {} |
Tags on the route table. |
timeouts |
object |
— | null |
Optional timeouts. |
Full variable schemas
variable "routes" {
type = map(object({
name = optional(string) # defaults to map key
address_prefix = string # destination CIDR
next_hop_type = string # VirtualNetworkGateway | VnetLocal | Internet | VirtualAppliance | None
next_hop_in_ip_address = optional(string) # required when next_hop_type = VirtualAppliance
}))
default = {}
# validations: next_hop_type enum; VirtualAppliance requires next_hop_in_ip_address
}| Output | Description | Kind |
|---|---|---|
id |
Resource ID of the route table (emitted first) | Passthrough |
name |
Name of the route table | Passthrough |
resource_group_name |
Resource group the route table and its routes live in | Passthrough |
location |
Azure region of the route table, as the provider NORMALIZED it - lowercased with spaces stripped | Passthrough |
subscription_id |
Subscription the route table lives in, parsed from its Resource ID | Derived |
route_ids |
Map of route key to that route's Azure Resource ID | Derived |
route_names |
Map of route key to the effective route name Azure sees | Derived |
route_count |
Number of user-defined routes in the table | Passthrough |
address_prefixes |
Map of route key to the destination the route applies to | Derived |
next_hop_types |
Map of route key to that route's next hop type | Derived |
next_hop_types_used |
Sorted, distinct next hop types present in the configuration | Derived |
default_route_keys |
Keys of routes whose destination is 0.0.0.0/0 - every IPv4 destination | Derived |
has_default_route |
Whether the table carries a 0.0.0.0/0 route | Derived |
default_route_next_hop_types |
Sorted, distinct next hop types used by the 0.0.0.0/0 routes, or an empty list when there is no default route | Derived |
forces_all_egress_through_a_virtual_appliance |
True when a 0.0.0.0/0 route points at a VirtualAppliance | Derived |
blackhole_route_keys |
Keys of routes whose next hop type is "None" | Derived |
virtual_appliance_route_keys |
Keys of routes that forward to a network virtual appliance | Derived |
virtual_appliance_next_hop_ips |
Sorted, distinct next hop addresses this table forwards to | Derived |
gateway_learned_routes_suppressed |
True when bgp_route_propagation_enabled is false | Derived |
bgp_route_propagation_enabled |
The propagation setting as configured, stated positively | Passthrough |
non_cidr_address_prefixes |
Destination prefixes in this table that carry no "/" and so are not in CIDR form | Derived |
subnets |
The set of subnet Resource IDs currently associated with this route table, as Azure reports them | Passthrough |
associated_subnet_count |
How many subnets Azure currently reports as associated with this table | Passthrough |
tags |
Tags as applied to the route table | Passthrough |
tag_count |
Number of tags on the route table, against the Azure maximum of 50 | Passthrough |
routes_take_effect_only_when_a_subnet_is_associated |
Always true | Constant |
this_module_creates_no_subnet_association |
Always true | Constant |
a_subnet_may_have_at_most_one_route_table |
Always true | Constant |
azure_selects_a_route_by_longest_prefix_match |
Always true, and the reason route order is meaningless here | Constant |
inline_route_blocks_are_never_rendered_by_this_module |
Always true, and the reason this module is safe to combine with the route table's own schema | Constant |
an_inline_route_block_elsewhere_would_overwrite_these_routes |
Always true | Constant |
removing_every_route_requires_an_explicit_empty_set |
Always true, and a consequence of route being computed on the table |
Constant |
routes_are_written_one_at_a_time |
Always true | Constant |
next_hop_type_is_compared_case_sensitively |
Always true | Constant |
location_is_compared_case_and_space_insensitively |
Always true, and the opposite of how next_hop_type behaves | Constant |
the_virtual_appliance_next_hop_requirement_is_not_checked_by_the_provider |
Always true, and the reason this module checks it | Constant |
azure_never_validates_that_a_next_hop_is_reachable |
Always true | Constant |
name_resource_group_and_location_are_force_new |
Always true | Constant |
routes_tags_and_propagation_update_in_place |
Always true, and the useful counterpart to the force-new fields | Constant |
recreating_a_route_briefly_removes_it_from_the_data_path |
Always true | Constant |
routes_carry_no_tags_of_their_own |
Always true | Constant |
the_route_quota_is_a_subscription_limit_this_module_cannot_see |
Always true | Constant |
accepts_no_credential |
Always true | Constant |
- The module owns its routes. Routes are separate
azurerm_routeresources viafor_each, never inlinerouteblocks. Managing both causes perpetual diffs. - Stable keys, no
count. Each route is keyed by the map key; adding or removing one never re-indexes the rest. - Validated next hops.
next_hop_typeis enum-checked (case-sensitively, matching the provider),VirtualApplianceroutes must carrynext_hop_in_ip_address, and that field is rejected on any other hop type — all atplantime, and all by this module. The provider itself defers every one of these to apply. - Associations live elsewhere. Subnet-to-route-table association is owned by the VNet/subnet module; this module only emits
idand reads backsubnets. - Immutable identity.
name/resource_group_name/locationforce replacement. features {}dependence. Noprovider {}block here; the caller configuresprovider "azurerm" { features {} }.
| Concern | Secure default (empty call) | Opt-out / opt-in |
|---|---|---|
| Custom routes | None (system routes only) | Add entries to routes |
| Egress control | Caller-defined | Route 0.0.0.0/0 to a firewall/NVA |
| BGP propagation | true (Azure default) |
set false for forced tunneling |
| Next-hop validation | next_hop_type enum + VirtualAppliance IP enforced |
— |
cd terraform-azurerm-route-table
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinuePin the module by tag (
?ref=v1.0.0), never a branch. Plan-only during authoring; a human runsplan/applyfrom CI.
The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema (including the next-hop validations) and canonically formatted, with no cloud calls. What it does not exercise: whether the appliance IP is reachable, route-precedence interactions with system/BGP routes, and subnet association — those surface only under terraform plan/apply against real credentials from CI.
$ terraform output
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod-eastus2/providers/Microsoft.Network/routeTables/rt-egress-prod"
name = "rt-egress-prod"
route_ids = {
"default" = ".../routeTables/rt-egress-prod/routes/default"
}
subnets = []| Symptom | Cause | Fix |
|---|---|---|
| Perpetual diff on routes | Routes also managed inline on the table | Manage routes only through this module. |
| Plan validation: VirtualAppliance route | next_hop_in_ip_address missing |
Provide the appliance's private IP. |
| Plan validation: bad next hop | next_hop_type not in the legal set |
Use one of the documented values. |
| Egress broken after disabling BGP | bgp_route_propagation_enabled = false removed the gateway path |
Add an explicit route or re-enable propagation. |
AuthorizationFailed |
Identity lacks Network Contributor | Grant Network Contributor on the resource group. |
| Traffic not following the route | Association missing, or a more specific route wins | Associate the table to the subnet; check prefix specificity. |
- Provider resources:
azurerm_route_table,azurerm_route - Sibling modules:
terraform-azurerm-virtual-network,terraform-azurerm-resource-group - This module's cross-module contract:
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."