Project: Reusable VPC Module
Almost every AWS workload starts with a VPC, and copy-pasting subnet and route-table blocks into each project is how drift and bugs creep in. A reusable module turns that boilerplate into a single, tested building block with a clean interface: callers pass a CIDR and a list of availability zones, and get back a fully wired network. In this project you will build a VPC module from scratch — typed inputs with validation, well-named outputs, a usage example, a README, and Git-tag versioning — then consume it from a dev and a prod environment. Everything works identically with OpenTofu (tofu in place of terraform).
Module layout
A module is just a directory of .tf files. Standard Terraform convention splits them by role so the interface is obvious at a glance. We also ship an examples/ directory that doubles as documentation and a smoke test.
terraform-aws-vpc/
├── main.tf # resources
├── variables.tf # inputs
├── outputs.tf # outputs
├── versions.tf # provider/Terraform constraints
├── README.md # docs
└── examples/
└── complete/
└── main.tf # runnable usage example
Inputs with validation
Typed variables make the interface self-documenting, and validation blocks fail fast with a readable message instead of a cryptic AWS API error halfway through apply. Note nullable = false on collections so callers cannot pass null by accident.
# variables.tf
variable "name" {
type = string
description = "Name prefix applied to all resources and tags."
}
variable "cidr_block" {
type = string
description = "Primary IPv4 CIDR for the VPC, e.g. 10.0.0.0/16."
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "cidr_block must be a valid IPv4 CIDR, e.g. 10.0.0.0/16."
}
}
variable "azs" {
type = list(string)
description = "Availability zones to spread subnets across."
nullable = false
validation {
condition = length(var.azs) >= 2
error_message = "Provide at least two AZs for high availability."
}
}
variable "enable_nat_gateway" {
type = bool
description = "Create a NAT gateway so private subnets reach the internet."
default = true
}
variable "tags" {
type = map(string)
description = "Tags merged onto every resource."
default = {}
nullable = false
}
Resources
The module derives one public and one private subnet per AZ from the parent CIDR using cidrsubnet, so callers never compute subnet ranges by hand. Public subnets route through an internet gateway; private subnets optionally route through a single NAT gateway.
# main.tf
locals {
tags = merge(var.tags, { ManagedBy = "terraform", Module = "vpc" })
}
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = var.name })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(local.tags, { Name = var.name })
}
resource "aws_subnet" "public" {
for_each = { for i, az in var.azs : az => i }
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.cidr_block, 8, each.value)
map_public_ip_on_launch = true
tags = merge(local.tags, { Name = "${var.name}-public-${each.key}", Tier = "public" })
}
resource "aws_subnet" "private" {
for_each = { for i, az in var.azs : az => i }
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.cidr_block, 8, each.value + 100)
tags = merge(local.tags, { Name = "${var.name}-private-${each.key}", Tier = "private" })
}
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? 1 : 0
domain = "vpc"
tags = merge(local.tags, { Name = "${var.name}-nat" })
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? 1 : 0
allocation_id = aws_eip.nat[0].id
subnet_id = values(aws_subnet.public)[0].id
tags = merge(local.tags, { Name = var.name })
depends_on = [aws_internet_gateway.this]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = merge(local.tags, { Name = "${var.name}-public" })
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[0].id
}
}
tags = merge(local.tags, { Name = "${var.name}-private" })
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
Outputs and version constraints
Expose only what callers need — IDs they will wire into other resources. Keep implementation details (route tables, EIPs) internal.
# outputs.tf
output "vpc_id" {
description = "ID of the VPC."
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets."
value = [for s in aws_subnet.public : s.id]
}
output "private_subnet_ids" {
description = "IDs of the private subnets."
value = [for s in aws_subnet.private : s.id]
}
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
A module should declare
required_providersbut not aproviderblock. Configuration (region, credentials) belongs to the root module that calls it, keeping the module portable across accounts and regions.
Documenting the module
The README is the module’s contract. Lead with a copy-pasteable example, then table the inputs and outputs. You can generate the tables automatically with terraform-docs:
terraform-docs markdown table . > README.md
A minimal hand-written README looks like this:
| Name | Type | Default | Required |
|---|---|---|---|
name | string | — | yes |
cidr_block | string | — | yes |
azs | list(string) | — | yes |
enable_nat_gateway | bool | true | no |
tags | map(string) | {} | no |
Versioning with Git tags
Terraform can source modules straight from Git, and the ref argument pins to a tag. Tag releases with semantic versions so environments upgrade deliberately rather than tracking a moving branch.
git tag -a v1.0.0 -m "Initial VPC module"
git push origin v1.0.0
Consuming it from two environments
Each environment is a thin root module that calls the shared VPC module and supplies environment-specific values. Both pin the same tag, so dev and prod stay in lockstep until you intentionally bump one.
# environments/dev/main.tf
provider "aws" {
region = "eu-west-1"
}
module "vpc" {
source = "git::https://github.com/acme/terraform-aws-vpc.git?ref=v1.0.0"
name = "acme-dev"
cidr_block = "10.10.0.0/16"
azs = ["eu-west-1a", "eu-west-1b"]
enable_nat_gateway = false # save cost in dev
tags = { Environment = "dev" }
}
# environments/prod/main.tf
provider "aws" {
region = "eu-west-1"
}
module "vpc" {
source = "git::https://github.com/acme/terraform-aws-vpc.git?ref=v1.0.0"
name = "acme-prod"
cidr_block = "10.20.0.0/16"
azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
tags = { Environment = "prod" }
}
Run each environment from its own directory with its own state.
terraform -chdir=environments/dev init
terraform -chdir=environments/dev apply
Output:
Initializing modules...
Downloading git::https://github.com/acme/terraform-aws-vpc.git?ref=v1.0.0 for vpc...
- vpc in .terraform/modules/vpc
Plan: 12 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
Outputs:
module.vpc.vpc_id = "vpc-0a1b2c3d4e5f67890"
Best practices
- Type every input and add
validationblocks for anything a caller can get wrong — invalid CIDRs, too few AZs, empty names. - Keep modules provider-agnostic: declare
required_providersbut configure providers in the root module only. - Pin module sources to an immutable Git tag (
?ref=v1.0.0), never a branch, so environments are reproducible. - Follow semantic versioning — bump the major version for any breaking interface change, and document it in the README or a CHANGELOG.
- Ship a runnable
examples/completedirectory; it documents usage and gives you a target forterraform validatein CI. - Output IDs and ARNs other configurations need, but hide internal plumbing so the interface stays small and stable.
- Generate input/output tables with
terraform-docsto keep the README in sync with the code automatically.