Quick take — A production-ready Terraform module for aws_route_table on hashicorp/aws ~> 5.0: var-driven routes, subnet associations, optional propagating VGWs, and validated CIDR/target inputs for clean VPC routing. 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 "aws" {
region = "us-east-1"
}
module "route_table" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-route-table?ref=v1.0.0"
name = "..." # Name tag for the route table (1–255 chars).
vpc_id = "..." # ID of the VPC the route table belongs to; validated aga…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An AWS Route Table is the control point that decides where traffic leaves a subnet: every packet whose destination doesn’t match a more-specific route follows the rules in the route table attached to its subnet. A route maps a destination (an IPv4 CIDR, an IPv6 CIDR, or a managed prefix list) to exactly one target — an internet gateway, NAT gateway, transit gateway, VPC peering connection, gateway/interface VPC endpoint, network interface, or another supported next hop. Get this wrong and you either black-hole traffic or accidentally expose a private subnet to the internet.
Wrapping aws_route_table in a reusable module matters because route tables are rarely a single resource in isolation. In production you almost always need three things together: the table itself, a set of routes (often a mix of 0.0.0.0/0 to NAT, peering CIDRs, and on-prem ranges via a transit gateway), and the subnet associations that actually put the table into service. Hand-writing those aws_route and aws_route_table_association blocks per environment is where drift and copy-paste mistakes creep in. This module turns all of that into a few typed variables with validation, so a private-subnet route table in dev and prod come from the same audited code path with consistent tagging and naming.
When to use it
- You manage VPCs with separate public, private, and isolated/database subnet tiers and want one route table definition per tier, reused across environments.
- You need routes to a mix of targets — NAT gateway, internet gateway, transit gateway, VPC peering, or gateway endpoints — expressed as data rather than hand-written resource blocks.
- You want subnet-to-route-table associations managed declaratively and in lockstep with the routes themselves.
- You rely on a Transit Gateway or a Virtual Private Gateway and need route propagation toggled per table.
- You want validation that catches malformed CIDRs or routes missing a target before
terraform applyever talks to AWS.
Reach for the default VPC main route table or inline route blocks only for throwaway sandboxes. For anything that carries real traffic, an explicit, versioned module is the safer path.
Module structure
terraform-module-aws-route-table/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Normalize the route map so each entry carries an explicit, single destination key.
# Exactly one of the destination_* / target_* attributes per route should be set.
routes = var.routes
}
resource "aws_route_table" "this" {
vpc_id = var.vpc_id
# Route propagation from attached Virtual Private Gateways (Direct Connect / VPN).
propagating_vgws = var.propagating_vgws
tags = merge(
var.tags,
{
Name = var.name
}
)
}
# Routes are managed as standalone aws_route resources (not inline route blocks)
# so additions/removals don't force replacement of the whole table.
resource "aws_route" "this" {
for_each = local.routes
route_table_id = aws_route_table.this.id
# Destination (set exactly one)
destination_cidr_block = try(each.value.destination_cidr_block, null)
destination_ipv6_cidr_block = try(each.value.destination_ipv6_cidr_block, null)
destination_prefix_list_id = try(each.value.destination_prefix_list_id, null)
# Target (set exactly one)
gateway_id = try(each.value.gateway_id, null)
nat_gateway_id = try(each.value.nat_gateway_id, null)
transit_gateway_id = try(each.value.transit_gateway_id, null)
vpc_peering_connection_id = try(each.value.vpc_peering_connection_id, null)
vpc_endpoint_id = try(each.value.vpc_endpoint_id, null)
network_interface_id = try(each.value.network_interface_id, null)
egress_only_gateway_id = try(each.value.egress_only_gateway_id, null)
timeouts {
create = "5m"
update = "2m"
delete = "5m"
}
}
# Associate the route table with the supplied subnets.
resource "aws_route_table_association" "this" {
for_each = toset(var.subnet_ids)
route_table_id = aws_route_table.this.id
subnet_id = each.value
}
# Optionally make this the main route table for the VPC (replaces the default).
resource "aws_main_route_table_association" "this" {
count = var.set_as_main ? 1 : 0
vpc_id = var.vpc_id
route_table_id = aws_route_table.this.id
}
# variables.tf
variable "name" {
description = "Name tag for the route table (e.g. \"prod-private-rt-use1\")."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 255
error_message = "The name must be between 1 and 255 characters."
}
}
variable "vpc_id" {
description = "ID of the VPC the route table belongs to."
type = string
validation {
condition = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
error_message = "vpc_id must be a valid VPC ID, e.g. vpc-0123456789abcdef0."
}
}
variable "subnet_ids" {
description = "Subnet IDs to associate with this route table."
type = list(string)
default = []
validation {
condition = alltrue([
for s in var.subnet_ids : can(regex("^subnet-[0-9a-f]{8,17}$", s))
])
error_message = "Every entry in subnet_ids must be a valid subnet ID, e.g. subnet-0123456789abcdef0."
}
}
variable "routes" {
description = <<-EOT
Map of routes keyed by a stable, human-readable name. Each value sets exactly
one destination (destination_cidr_block | destination_ipv6_cidr_block |
destination_prefix_list_id) and exactly one target (gateway_id | nat_gateway_id |
transit_gateway_id | vpc_peering_connection_id | vpc_endpoint_id |
network_interface_id | egress_only_gateway_id).
EOT
type = map(object({
destination_cidr_block = optional(string)
destination_ipv6_cidr_block = optional(string)
destination_prefix_list_id = optional(string)
gateway_id = optional(string)
nat_gateway_id = optional(string)
transit_gateway_id = optional(string)
vpc_peering_connection_id = optional(string)
vpc_endpoint_id = optional(string)
network_interface_id = optional(string)
egress_only_gateway_id = optional(string)
}))
default = {}
# Each route must declare exactly one destination.
validation {
condition = alltrue([
for r in values(var.routes) : length(compact([
r.destination_cidr_block,
r.destination_ipv6_cidr_block,
r.destination_prefix_list_id,
])) == 1
])
error_message = "Each route must set exactly one of destination_cidr_block, destination_ipv6_cidr_block, or destination_prefix_list_id."
}
# Each route must declare exactly one target.
validation {
condition = alltrue([
for r in values(var.routes) : length(compact([
r.gateway_id,
r.nat_gateway_id,
r.transit_gateway_id,
r.vpc_peering_connection_id,
r.vpc_endpoint_id,
r.network_interface_id,
r.egress_only_gateway_id,
])) == 1
])
error_message = "Each route must set exactly one target (gateway_id, nat_gateway_id, transit_gateway_id, vpc_peering_connection_id, vpc_endpoint_id, network_interface_id, or egress_only_gateway_id)."
}
# Sanity-check any IPv4 CIDRs that are provided.
validation {
condition = alltrue([
for r in values(var.routes) :
r.destination_cidr_block == null ? true : can(cidrhost(r.destination_cidr_block, 0))
])
error_message = "Every destination_cidr_block must be a valid IPv4 CIDR, e.g. 10.0.0.0/16 or 0.0.0.0/0."
}
}
variable "propagating_vgws" {
description = "List of Virtual Private Gateway IDs that propagate routes into this table (Direct Connect / VPN)."
type = list(string)
default = []
}
variable "set_as_main" {
description = "If true, associate this route table as the VPC's main route table."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to the route table (merged with the Name tag)."
type = map(string)
default = {}
}
# outputs.tf
output "route_table_id" {
description = "ID of the route table."
value = aws_route_table.this.id
}
output "route_table_arn" {
description = "ARN of the route table."
value = aws_route_table.this.arn
}
output "name" {
description = "Name tag assigned to the route table."
value = var.name
}
output "vpc_id" {
description = "VPC ID the route table belongs to."
value = aws_route_table.this.vpc_id
}
output "owner_id" {
description = "AWS account ID that owns the route table."
value = aws_route_table.this.owner_id
}
output "association_ids" {
description = "Map of subnet ID to the route table association ID."
value = { for k, a in aws_route_table_association.this : k => a.id }
}
output "route_ids" {
description = "Map of route key to the managed aws_route resource ID."
value = { for k, r in aws_route.this : k => r.id }
}
How to use it
module "private_route_table" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-route-table?ref=v1.0.0"
name = "prod-private-rt-use1-az-a"
vpc_id = module.vpc.vpc_id
subnet_ids = [module.vpc.private_subnet_ids["az-a"]]
routes = {
# Default egress through the AZ-local NAT gateway.
default_egress = {
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = module.nat.nat_gateway_ids["az-a"]
}
# On-prem and shared-services CIDRs over the Transit Gateway.
corp_onprem = {
destination_cidr_block = "10.0.0.0/8"
transit_gateway_id = var.transit_gateway_id
}
# Reach an S3 gateway endpoint via its managed prefix list.
s3_endpoint = {
destination_prefix_list_id = data.aws_prefix_list.s3.id
vpc_endpoint_id = module.s3_endpoint.id
}
}
tags = {
Environment = "prod"
Tier = "private"
ManagedBy = "terraform"
}
}
# Downstream reference: feed the route table ID into a VPC flow-logs/analysis
# resource or simply expose it from the calling stack for other modules.
output "private_rt_id" {
value = module.private_route_table.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 = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 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-aws-route-table?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
}
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 |
n/a | Yes | Name tag for the route table (1–255 chars). |
vpc_id |
string |
n/a | Yes | ID of the VPC the route table belongs to; validated against the vpc- pattern. |
subnet_ids |
list(string) |
[] |
No | Subnet IDs to associate with this route table; each is validated against the subnet- pattern. |
routes |
map(object) |
{} |
No | Map of routes keyed by name; each entry sets exactly one destination and exactly one target (validated). |
propagating_vgws |
list(string) |
[] |
No | Virtual Private Gateway IDs that propagate routes into this table. |
set_as_main |
bool |
false |
No | If true, make this the VPC’s main route table. |
tags |
map(string) |
{} |
No | Tags applied to the route table, merged with the Name tag. |
Outputs
| Name | Description |
|---|---|
route_table_id |
ID of the route table. |
route_table_arn |
ARN of the route table. |
name |
Name tag assigned to the route table. |
vpc_id |
VPC ID the route table belongs to. |
owner_id |
AWS account ID that owns the route table. |
association_ids |
Map of subnet ID to its route table association ID. |
route_ids |
Map of route key to the managed aws_route resource ID. |
Enterprise scenario
A retail platform runs a hub-and-spoke network where each spoke VPC has private application subnets that must reach on-prem inventory systems over a Transit Gateway and the internet only via centralized NAT. The platform team instantiates this module once per availability zone per spoke, passing the AZ-local NAT gateway as the 0.0.0.0/0 target and the 10.0.0.0/8 corporate range to the shared Transit Gateway, while an S3 gateway-endpoint route keeps backup traffic off the NAT meter. Because the route map and validations are identical across all 40-plus spokes, a single reviewed module version guarantees no application subnet ever gets a stray default route to an internet gateway.
Best practices
- Never give a private-tier table a route to an internet gateway. Keep
0.0.0.0/0pointed at a NAT gateway for private subnets and reserve IGW routes for tables explicitly named and tagged as public — the per-route target validation in this module makes those routes auditable in code review. - Use AZ-local NAT gateways. Create one route table (and one NAT gateway) per AZ so cross-AZ data-processing charges don’t quietly inflate your NAT bill; this module’s per-instance design encourages exactly that pattern.
- Prefer gateway endpoints over NAT for S3 and DynamoDB. Route those services through a gateway VPC endpoint via its prefix list; it removes per-GB NAT data-processing cost and keeps the traffic on the AWS backbone.
- Manage routes as
aws_route, not inline blocks. Standalone route resources let you add or remove a single destination without forcing replacement of the whole table and dropping every association momentarily — this module never mixes the two styles, which avoids perpetual diffs. - Adopt a consistent, scoped naming convention. Encode environment, tier, region, and AZ in the
Nametag (e.g.prod-private-rt-use1-az-a) so route tables are unambiguous in the console and in cost/flow-log queries. - Enable route propagation deliberately. Only set
propagating_vgwson tables that genuinely need Direct Connect/VPN routes; leaving propagation off elsewhere keeps the effective route set small and predictable.