IaC AWS

Terraform Module: AWS Route Table — declarative VPC routing with guardrails

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

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

TerraformAWSRoute 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