Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Route Table Terraform Module

Manage an Azure Route Table and its user-defined routes as one keyed unit, on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • 🧭 Creates one azurerm_route_table and its azurerm_route children 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_type and requires next_hop_in_ip_address for VirtualAppliance routes at plan time.
  • 🔀 Exposes bgp_route_propagation_enabled for 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.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!

🗺️ Where this fits in the family

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

🧬 What this module builds

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

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.

✅ Provider / Versions

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, and location are immutable — changing any forces replacement.
  • Do not manage these routes and also declare inline route blocks on the table — double management causes perpetual diffs. This module owns the routes.
  • A route with next_hop_type = "VirtualAppliance" requires next_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 = false blocks propagation of gateway-learned routes — intended for forced tunneling, but it can black-hole ExpressRoute/VPN traffic if misapplied.
  • The subnets attribute is computed (associations are made by the VNet/subnet module), so it is an output only.

🔑 Required Azure RBAC Roles / Permissions

  • Network Contributor on the target resource group (or a custom role with Microsoft.Network/routeTables/* and Microsoft.Network/routeTables/routes/*).

Azure Prerequisites

  • The Microsoft.Network resource provider registered on the subscription.
  • An existing resource group.
  • For VirtualAppliance routes, 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.

📁 Module Structure

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

⚙️ Quick Start

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.

🔌 Cross-Module Contract

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

📚 Example Library

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"
  }
}

🔒 None discards 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.

📥 Inputs

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
}

🧾 Outputs

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

🧠 Architecture Notes

  • The module owns its routes. Routes are separate azurerm_route resources via for_each, never inline route blocks. 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_type is enum-checked (case-sensitively, matching the provider), VirtualAppliance routes must carry next_hop_in_ip_address, and that field is rejected on any other hop type — all at plan time, 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 id and reads back subnets.
  • Immutable identity. name/resource_group_name/location force replacement.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

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

🚀 Runbook

cd terraform-azurerm-route-table
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

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.

💬 Example Output

$ 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 = []

🔍 Troubleshooting

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.

🔗 Related Docs


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