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
- You are standing up a VPC that needs public-facing resources — ALBs, bastion hosts, NAT gateways, or internet-facing instances with public IPs.
- You want NAT gateways in private-subnet designs: a NAT gateway itself sits in a public subnet and routes outbound through the IGW, so the IGW is a prerequisite even for “private” architectures.
- You are enabling dual-stack (IPv6) connectivity and need an egress route for
::/0alongside IPv4. - You want a single, auditable place where public-internet reachability for a VPC is granted, so security reviews have one resource to inspect.
- You do not need this for fully private VPCs (PrivateLink / Transit Gateway only) or for outbound-only IPv6 — those use an egress-only internet gateway (
aws_egress_only_internet_gateway), a different resource.
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 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/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
- Attach default routes to public route tables only. Passing a private or database route table into
route_table_idsis the classic way to accidentally expose backend subnets — keep the public/private split explicit at the call site. - One IGW per VPC, no sharing. An IGW attaches to exactly one VPC and cannot be shared; for many VPCs needing common egress, use a Transit Gateway or centralized egress VPC pattern instead of trying to reuse a single IGW.
- Let the module own the ordering. The
depends_onbetween the route and the gateway prevents intermittentInvalidGatewayID.NotFound/Gateway.NotAttachederrors — never create the0.0.0.0/0route by hand outside the module. - Cost is in what’s behind it, not the IGW. The Internet Gateway itself is free; data-transfer-out and any NAT gateways it fronts are where spend accrues. Tag the IGW with
CostCenter/Environmentso egress can be traced back per VPC. - Enable IPv6 routing only for dual-stack VPCs. Setting
create_default_ipv6_route = trueon a VPC without an IPv6 CIDR will fail the apply — gate it on whether the VPC actually has an IPv6 association. - Use VPC Flow Logs and an SCP guardrail. Pair the IGW with Flow Logs for egress visibility, and consider a Service Control Policy that blocks
ec2:CreateInternetGatewayoutside approved Terraform pipelines so internet exposure is always provisioned through this module.