A Real Network Module: VPC, Subnet, and EC2

K
KaiΒ·Β·5 min read

The three Part IV articles gave us how to write a module, call a module, and wire them together. Now we assemble all of it into something genuinely usable: a network module that builds a complete VPC, then places an EC2 on the Internet inside it. This is the typical foundational module β€” nearly every AWS project starts with a network like this, and it combines everything learned: for_each by key, the cidrsubnet function, data sources, and composition between a module and a resource at the root.

Goal

Be able to write a reusable network module (VPC, multi-AZ subnets, internet gateway, route table) and place an EC2 into that network, understanding how each piece connects to the others.

πŸ’° Cost

VPC, subnet, internet gateway and route table are all free. An EC2 t3.micro is very cheap and free-tier eligible on many accounts; either way we destroy it right after looking at the result, so the actual cost is just a few cents. Note: the NAT Gateway (not used in this article) is the thing that costs meaningful money.

What an AWS network is made of

Before writing, you need to be clear on the four pieces of a minimal public network and how they connect:

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ VPC (10.0.0.0/16) ───────────────────────┐
   β”‚                                                                  β”‚
   β”‚   β”Œβ”€β”€ subnet public AZ-a ──┐      β”Œβ”€β”€ subnet public AZ-b ──┐     β”‚
   β”‚   β”‚   10.0.0.0/24          β”‚      β”‚   10.0.1.0/24          β”‚     β”‚
   β”‚   β”‚   [EC2]                β”‚      β”‚                        β”‚     β”‚
   β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
   β”‚               β”‚   route table: 0.0.0.0/0 ──► IGW                 β”‚
   β”‚               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
   β”‚                               β–Ό                                   β”‚
   β”‚                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                          β”‚
   └───────────────────── Internet Gateway β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                 β–Ό  Internet

The VPC is a private network space. Subnets carve the VPC into smaller ranges, each subnet living in one AZ. The internet gateway is the VPC's door to the Internet. The route table tells the subnet where traffic goes β€” here "every external address (0.0.0.0/0) goes through the internet gateway", turning the subnet into a public one.

The network module

Input (variables.tf): a name, the VPC CIDR, and a map of AZ β†’ subnet CIDR:

variable "name" { type = string }
variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}
variable "public_subnets" {
  type        = map(string)
  description = "Map: AZ -> CIDR subnet cΓ΄ng khai"
}

The internals (main.tf) build the four pieces, using for_each to create a subnet and a route-table-association per AZ in the map:

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
}

resource "aws_subnet" "public" {
  for_each                = var.public_subnets
  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value
  availability_zone       = each.key
  map_public_ip_on_launch = true
}

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
  }
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

Two places worth pausing on. One is for_each = var.public_subnets using the AZ→CIDR map: the key is the AZ (each.key), the value is the CIDR (each.value) — exactly the stable-key for_each usage Article 11 recommended. Two is that the association uses for_each = aws_subnet.public — iterating directly over another resource. The docs call this a one-to-one relationship between two sets of objects, and it's why for_each is more powerful than count: you can wire them directly.

Output (outputs.tf) returns the VPC id and the list of subnets for the root to use:

output "vpc_id" {
  value = aws_vpc.this.id
}
output "public_subnet_ids" {
  value = [for s in aws_subnet.public : s.id]
}

Root: call the module and place the EC2

The root generates subnet CIDRs with cidrsubnet (Article 3), pulls AZs and the AMI from data sources (Article 10), then places the EC2 into the module's first subnet:

data "aws_availability_zones" "available" { state = "available" }

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
}

module "network" {
  source   = "./modules/network"
  name     = "tf-series-bai14"
  vpc_cidr = "10.0.0.0/16"
  public_subnets = {
    (data.aws_availability_zones.available.names[0]) = cidrsubnet("10.0.0.0/16", 8, 0) # 10.0.0.0/24
    (data.aws_availability_zones.available.names[1]) = cidrsubnet("10.0.0.0/16", 8, 1) # 10.0.1.0/24
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"
  subnet_id              = module.network.public_subnet_ids[0]
  vpc_security_group_ids = [aws_security_group.web.id]
  tags                   = { Name = "tf-series-bai14-web" }
}

cidrsubnet("10.0.0.0/16", 8, 0) slices the VPC /16 into /24s and takes the 0th (10.0.0.0/24), the 1st (10.0.1.0/24) β€” no hand-typing the network ranges. The EC2 receives subnet_id = module.network.public_subnet_ids[0], which is composition between a module and a resource: the module's output feeds the instance's input.

Trying it out

$ terraform apply -auto-approve
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

$ terraform state list
data.aws_ami.al2023
data.aws_availability_zones.available
aws_instance.web
aws_security_group.web
module.network.aws_internet_gateway.this
module.network.aws_route_table.public
module.network.aws_route_table_association.public["ap-southeast-1a"]
module.network.aws_route_table_association.public["ap-southeast-1b"]
module.network.aws_subnet.public["ap-southeast-1a"]
module.network.aws_subnet.public["ap-southeast-1b"]
module.network.aws_vpc.this

Nine resources, of which the network resources carry the module.network. prefix and the subnet/association are keyed by AZ (["ap-southeast-1a"]). The output shows the network working:

$ terraform output
instance_public_ip = "13.212.x.x"
subnet_ids         = ["subnet-0e8186...", "subnet-08b0a6..."]
vpc_id             = "vpc-0b2f5189..."

The EC2 gets a public IP because it sits in a subnet with map_public_ip_on_launch and a route to the internet gateway β€” exactly as in the diagram at the start.

🧹 Cleanup

$ terraform destroy -auto-approve
Destroy complete! Resources: 9 destroyed.

Terraform destroys in reverse graph order: EC2 first, then association, subnet, route table, internet gateway, and finally the VPC β€” exactly the dependency relationship dissected in Article 5.

Wrap-up

A real network module wraps a VPC, multi-AZ subnets (via for_each over an AZ→CIDR map), internet gateway and route table into a reusable block, returning the VPC id and the list of subnets. The root generates CIDRs with cidrsubnet, pulls AZ/AMI from data sources, calls the module, then places the EC2 into one of its subnets via module.network.public_subnet_ids[0]. This is the foundational pattern that the later articles build on top of.

Part IV closes with a tidy module, but it all still runs in a single state for a single environment. Reality needs separate dev, staging, prod. Part V opens with Article 15: two ways to organize multiple environments β€” workspaces and directory layout β€” along with the pros and cons of each.