Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Route Tables: var-driven custom routes (UDRs), BGP propagation control, subnet associations, and validated next-hop wiring for forced-tunnel topologies. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "route_table" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-table?ref=v1.0.0"
name = "..." # Name of the route table (2–80 chars, validated).
resource_group_name = "..." # Resource group to create the route table in.
location = "..." # Azure region (e.g. `centralindia`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Route Table holds a set of User-Defined Routes (UDRs) that override Azure’s default system routes for the subnets it is associated with. Once you associate a route table to a subnet, Azure consults your custom routes before the built-in ones, letting you steer traffic by destination prefix to a specific next hop — most commonly a VirtualAppliance (an Azure Firewall or NVA), but also VnetLocal, VirtualNetworkGateway, Internet, or a None (blackhole) hop.
In practice, route tables are the control plane for forced tunneling and traffic inspection. In a hub-and-spoke design you push a 0.0.0.0/0 route at the spoke subnets pointing to the hub firewall’s private IP, so all egress is inspected before it leaves the network. You also frequently set disable_bgp_route_propagation = true to stop on-prem BGP routes (learned over ExpressRoute/VPN) from competing with your deliberate UDRs.
Wrapping this in a module matters because the raw resources are fiddly and easy to get subtly wrong: azurerm_route requires next_hop_in_ip_address only when the next hop type is VirtualAppliance (and forbids it otherwise), subnet associations live in a separate azurerm_subnet_route_table_association resource with its own lifecycle, and teams routinely fat-finger CIDRs or forget to disable BGP propagation. This module gives you a single, validated, var-driven interface so every route table across your estate is named consistently, wired correctly, and associated to the right subnets.
When to use it
- You run a hub-and-spoke or Virtual WAN-adjacent topology and need to force spoke egress (
0.0.0.0/0) through a central Azure Firewall or NVA for inspection and logging. - You need to blackhole specific prefixes (next hop
None) — for example to contain a compromised range or to deny direct internet to a sensitive subnet. - You terminate ExpressRoute or site-to-site VPN and must disable BGP route propagation on selected subnets so on-prem routes don’t override your security UDRs.
- You want per-subnet routing policy as code, reviewed in PRs, instead of someone clicking routes into the portal where they drift and go undocumented.
If you only need the platform’s default connectivity (intra-VNet, internet, peering) with no traffic steering, you don’t need a route table at all — Azure’s system routes already cover it.
Module structure
terraform-module-azure-route-table/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
resource "azurerm_route_table" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
bgp_route_propagation_enabled = var.bgp_route_propagation_enabled
dynamic "route" {
for_each = { for r in var.routes : r.name => r }
content {
name = route.value.name
address_prefix = route.value.address_prefix
next_hop_type = route.value.next_hop_type
next_hop_in_ip_address = route.value.next_hop_type == "VirtualAppliance" ? route.value.next_hop_in_ip_address : null
}
}
tags = var.tags
}
resource "azurerm_subnet_route_table_association" "this" {
for_each = toset(var.subnet_ids)
subnet_id = each.value
route_table_id = azurerm_route_table.this.id
}
variables.tf
variable "name" {
description = "Name of the route table."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 2-80 chars, start with a letter/number and end with a letter, number or underscore."
}
}
variable "resource_group_name" {
description = "Name of the resource group in which to create the route table."
type = string
}
variable "location" {
description = "Azure region where the route table is created (e.g. centralindia)."
type = string
}
variable "bgp_route_propagation_enabled" {
description = "Whether to propagate routes learned by BGP (from ExpressRoute/VPN gateways) onto the associated subnets. Set to false to force traffic onto your UDRs in hub-and-spoke designs."
type = bool
default = true
}
variable "routes" {
description = "List of user-defined routes. next_hop_in_ip_address is required when next_hop_type is VirtualAppliance and ignored otherwise."
type = list(object({
name = string
address_prefix = string
next_hop_type = string
next_hop_in_ip_address = optional(string)
}))
default = []
validation {
condition = alltrue([
for r in var.routes : contains(
["VirtualNetworkGateway", "VnetLocal", "Internet", "VirtualAppliance", "None"],
r.next_hop_type
)
])
error_message = "Each route.next_hop_type must be one of: VirtualNetworkGateway, VnetLocal, Internet, VirtualAppliance, None."
}
validation {
condition = alltrue([
for r in var.routes : (
r.next_hop_type != "VirtualAppliance" || try(r.next_hop_in_ip_address, null) != null
)
])
error_message = "Routes with next_hop_type = VirtualAppliance must set next_hop_in_ip_address."
}
validation {
condition = alltrue([
for r in var.routes : (
r.next_hop_type == "VirtualAppliance" || try(r.next_hop_in_ip_address, null) == null
)
])
error_message = "next_hop_in_ip_address may only be set when next_hop_type = VirtualAppliance."
}
validation {
condition = length(distinct([for r in var.routes : r.name])) == length(var.routes)
error_message = "Each route name must be unique within the route table."
}
}
variable "subnet_ids" {
description = "List of subnet resource IDs to associate with this route table."
type = list(string)
default = []
}
variable "tags" {
description = "Tags to apply to the route table."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The resource ID of the route table."
value = azurerm_route_table.this.id
}
output "name" {
description = "The name of the route table."
value = azurerm_route_table.this.name
}
output "subnets" {
description = "The collection of subnet IDs currently associated with the route table (as reported by Azure)."
value = azurerm_route_table.this.subnets
}
output "route_names" {
description = "Names of the user-defined routes configured on the route table."
value = [for r in var.routes : r.name]
}
output "associated_subnet_ids" {
description = "Map of input subnet ID to the created association resource ID."
value = { for k, v in azurerm_subnet_route_table_association.this : k => v.id }
}
How to use it
module "route_table" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-table?ref=v1.0.0"
name = "rt-spoke-prod-cin"
resource_group_name = azurerm_resource_group.network.name
location = "centralindia"
# Force on-prem learned routes to lose to our UDRs.
bgp_route_propagation_enabled = false
routes = [
{
# Inspect all internet-bound traffic via the hub Azure Firewall.
name = "default-to-firewall"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.10.0.4"
},
{
# Keep VNet-internal traffic local (explicit, documents intent).
name = "vnet-local"
address_prefix = "10.20.0.0/16"
next_hop_type = "VnetLocal"
},
{
# Blackhole a deprecated partner range.
name = "blackhole-legacy-partner"
address_prefix = "203.0.113.0/24"
next_hop_type = "None"
},
]
subnet_ids = [
azurerm_subnet.app.id,
azurerm_subnet.data.id,
]
tags = {
environment = "prod"
workload = "payments"
managed_by = "terraform"
}
}
# Downstream reference: feed the route table ID into a policy/diagnostic
# resource, or export it for a peered spoke deployment.
output "spoke_route_table_id" {
value = module.route_table.id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/route_table/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-table?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/route_table && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the route table (2–80 chars, validated). |
resource_group_name |
string |
— | Yes | Resource group to create the route table in. |
location |
string |
— | Yes | Azure region (e.g. centralindia). |
bgp_route_propagation_enabled |
bool |
true |
No | Propagate BGP routes from ExpressRoute/VPN gateways onto associated subnets. Set false to force traffic onto your UDRs. |
routes |
list(object({ name, address_prefix, next_hop_type, next_hop_in_ip_address })) |
[] |
No | User-defined routes. next_hop_in_ip_address required only when next_hop_type = "VirtualAppliance". |
subnet_ids |
list(string) |
[] |
No | Subnet resource IDs to associate with this route table. |
tags |
map(string) |
{} |
No | Tags applied to the route table. |
Outputs
| Name | Description |
|---|---|
id |
The resource ID of the route table. |
name |
The name of the route table. |
subnets |
Subnet IDs currently associated with the route table, as reported by Azure. |
route_names |
Names of the user-defined routes configured on the route table. |
associated_subnet_ids |
Map of input subnet ID to its created association resource ID. |
Enterprise scenario
A payments platform on Azure runs a hub-and-spoke network across Central India and South India with an Azure Firewall in each regional hub. Each spoke landing zone consumes this module to attach a rt-spoke-* route table that pushes 0.0.0.0/0 to the regional firewall’s private IP and sets bgp_route_propagation_enabled = false, so even routes advertised over the ExpressRoute circuit cannot bypass inspection. Because the routes and subnet associations are declared in the spoke’s Terraform, a PCI auditor can read the repo and confirm that no spoke subnet has an uninspected egress path — and a single PR adds a None blackhole route across every spoke if a malicious prefix needs to be contained.
Best practices
- Disable BGP propagation deliberately on inspected subnets. When forcing
0.0.0.0/0to an NVA, setbgp_route_propagation_enabled = falseso a more-specific or competing BGP route from on-prem can’t silently create a bypass around your firewall. - Only set
next_hop_in_ip_addressforVirtualAppliance. The module enforces this, but understand why: Azure rejects an IP for any other hop type, and aVirtualApplianceroute with no IP is a deployment error. Keep the appliance IP stable (a static firewall private IP), not a value that churns on rebuild. - Name routes by intent, not by CIDR. Use names like
default-to-firewallorblackhole-legacy-partnerso the next engineer reads the why in a plan diff; CIDRs change, intent rarely does. - Use
Noneto blackhole, never a bogus IP. To deny a prefix, route it tonext_hop_type = "None"— this is auditable and free, whereas pointing it at a fake appliance IP silently drops traffic and confuses troubleshooting. - One route table per routing policy, reused across like subnets. Route tables are free; the cost is operational drift. Share a single table across subnets that genuinely need identical policy, and split tables when intent diverges rather than piling exceptions into one.
- Mind the most-specific-prefix rule. Azure matches longest-prefix first and UDRs win ties against system/BGP routes — when adding a route, check it doesn’t unintentionally shadow a more-general one (e.g. a
/16VnetLocal route alongside a0.0.0.0/0firewall route is fine, but overlapping/24s need review).