IaC AWS

Terraform Module: AWS Internet Gateway — one-click public egress for your VPC

Quick take — A reusable Terraform module for hashicorp/aws ~> 5.0 that provisions an AWS Internet Gateway, attaches it to a VPC, and wires default routes for public subnets — with tagging, validation, and clean outputs. 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 "internet_gateway" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-internet-gateway?ref=v1.0.0"

  name   = "..."  # Name tag for the Internet Gateway; merged into the tag …
  vpc_id = "..."  # ID of the VPC to attach the IGW to. Validated against t…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Internet Gateway (IGW) is the horizontally-scaled, redundant, AWS-managed component that allows communication between instances in your VPC and the public internet. It performs two jobs: it provides a target in your VPC route tables for internet-routable IPv4/IPv6 traffic, and it performs network address translation (NAT) for instances that have been assigned a public IPv4 address. Without an IGW attached to a VPC, nothing in that VPC can reach — or be reached from — the internet, no matter how many public IPs or open security groups you configure.

The IGW itself is deceptively simple — aws_internet_gateway has almost no configurable attributes. The complexity and the bugs live in the wiring: it must be attached to exactly one VPC, your public route tables need a 0.0.0.0/0 (and optionally ::/0) route pointing at it, and that route depends on the attachment existing first. Get the ordering wrong and terraform apply fails with Gateway not attached errors, or worse, succeeds but produces subnets that silently have no egress.

Wrapping this in a module gives every VPC a consistent, named IGW with the correct attachment ordering, optional egress routing baked in, mandatory tagging for cost allocation, and outputs that downstream route tables and NAT-instance modules can consume — so nobody hand-rolls the depends_on dance again.

When to use it

Module structure

terraform-module-aws-internet-gateway/
├── 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 {
  # Merge a Name tag in automatically so every IGW is identifiable in the console.
  igw_tags = merge(
    var.tags,
    {
      Name = var.name
    }
  )

  # Only create routes when the caller opts in and provides route tables.
  create_ipv4_route = var.create_default_routes && length(var.route_table_ids) > 0
  create_ipv6_route = var.create_default_ipv6_route && length(var.route_table_ids) > 0
}

resource "aws_internet_gateway" "this" {
  vpc_id = var.vpc_id
  tags   = local.igw_tags
}

# Default IPv4 egress route (0.0.0.0/0) for each public route table.
resource "aws_route" "default_ipv4" {
  for_each = local.create_ipv4_route ? toset(var.route_table_ids) : toset([])

  route_table_id         = each.value
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.this.id

  # The IGW must be attached to the VPC before a route can target it.
  depends_on = [aws_internet_gateway.this]

  timeouts {
    create = "5m"
  }
}

# Optional default IPv6 egress route (::/0) for dual-stack subnets.
resource "aws_route" "default_ipv6" {
  for_each = local.create_ipv6_route ? toset(var.route_table_ids) : toset([])

  route_table_id              = each.value
  destination_ipv6_cidr_block = "::/0"
  gateway_id                  = aws_internet_gateway.this.id

  depends_on = [aws_internet_gateway.this]

  timeouts {
    create = "5m"
  }
}

variables.tf

variable "name" {
  description = "Name tag for the Internet Gateway (also used in the merged tag set)."
  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 to attach the Internet Gateway to. An IGW can be attached to exactly one VPC."
  type        = string

  validation {
    condition     = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
    error_message = "The vpc_id must be a valid VPC ID (e.g. vpc-0a1b2c3d4e5f67890)."
  }
}

variable "route_table_ids" {
  description = "Route table IDs of public subnets that should receive a default route to the IGW. Leave empty to skip route creation."
  type        = list(string)
  default     = []

  validation {
    condition = alltrue([
      for rt in var.route_table_ids : can(regex("^rtb-[0-9a-f]{8,17}$", rt))
    ])
    error_message = "Every entry in route_table_ids must be a valid route table ID (e.g. rtb-0123456789abcdef0)."
  }
}

variable "create_default_routes" {
  description = "Whether to create a 0.0.0.0/0 IPv4 route to the IGW in each route table in route_table_ids."
  type        = bool
  default     = true
}

variable "create_default_ipv6_route" {
  description = "Whether to additionally create a ::/0 IPv6 route to the IGW (for dual-stack VPCs)."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Tags to apply to the Internet Gateway. A Name tag is merged in automatically from var.name."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "The ID of the Internet Gateway."
  value       = aws_internet_gateway.this.id
}

output "arn" {
  description = "The ARN of the Internet Gateway."
  value       = aws_internet_gateway.this.arn
}

output "name" {
  description = "The Name tag assigned to the Internet Gateway."
  value       = var.name
}

output "vpc_id" {
  description = "The ID of the VPC the Internet Gateway is attached to."
  value       = aws_internet_gateway.this.vpc_id
}

output "ipv4_route_ids" {
  description = "Map of route table ID to the created IPv4 default route ID."
  value       = { for k, r in aws_route.default_ipv4 : k => r.id }
}

output "ipv6_route_ids" {
  description = "Map of route table ID to the created IPv6 default route ID."
  value       = { for k, r in aws_route.default_ipv6 : k => r.id }
}

How to use it

module "internet_gateway" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-internet-gateway?ref=v1.0.0"

  name   = "kloudvin-prod-igw"
  vpc_id = aws_vpc.main.id

  # Wire default egress for the public route tables only.
  route_table_ids           = [aws_route_table.public.id]
  create_default_routes     = true
  create_default_ipv6_route = true

  tags = {
    Environment = "prod"
    CostCenter  = "platform"
    ManagedBy   = "terraform"
  }
}

# Downstream: a NAT gateway lives in a public subnet and depends on the IGW
# being attached so its Elastic IP can reach the internet. Referencing the
# module output creates the implicit dependency on the IGW + its route.
resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "kloudvin-prod-nat"
  }

  # Ensure the IGW route exists before the NAT gateway is provisioned.
  depends_on = [module.internet_gateway]
}

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/internet_gateway/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-internet-gateway?ref=v1.0.0"
}

inputs = {
  name = "..."
  vpc_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/internet_gateway && 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 Internet Gateway; merged into the tag set. Must be 1–255 chars.
vpc_id string n/a yes ID of the VPC to attach the IGW to. Validated against the vpc-… format.
route_table_ids list(string) [] no Public route table IDs that should receive a default route to the IGW. Each validated against rtb-….
create_default_routes bool true no Create a 0.0.0.0/0 IPv4 route to the IGW in each route table.
create_default_ipv6_route bool false no Additionally create a ::/0 IPv6 route to the IGW (dual-stack).
tags map(string) {} no Tags applied to the IGW; a Name tag is merged in from var.name.

Outputs

Name Description
id The ID of the Internet Gateway.
arn The ARN of the Internet Gateway.
name The Name tag assigned to the Internet Gateway.
vpc_id The ID of the VPC the Internet Gateway is attached to.
ipv4_route_ids Map of route table ID to the created IPv4 default route ID.
ipv6_route_ids Map of route table ID to the created IPv6 default route ID.

Enterprise scenario

A retail platform team runs a landing-zone account per business unit, each with a standardized three-tier VPC built from internal Terraform modules. They consume this IGW module once per VPC, passing only the public route table IDs so that the 0.0.0.0/0 route is never accidentally added to private or database tiers. Because the module emits id and ipv4_route_ids, the downstream NAT-gateway and ALB modules take an explicit dependency on it, guaranteeing the gateway is attached and routable before any internet-facing load balancer or NAT path is created during a fresh terraform apply.

Best practices

TerraformAWSInternet GatewayModuleIaC
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