IaC Azure

Terraform Module: Azure Route Table — Centralized UDR Control for Hub-and-Spoke Egress

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

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 configlive/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 configlive/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

TerraformAzureRoute TableModuleIaC
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

Keep Reading