Terraform in Practice #2 VPC Network as Code: Subnets Across Two AZs, Routing, and the NAT Gateway

5 min read

The first resource of any infrastructure is the network. Everything that follows — EC2, RDS, ECS — sits on subnets, so the structure decided here becomes the skeleton of the whole series. The goal of this part is the textbook two-tier network: internet-reachable public subnets and isolated private subnets across two Availability Zones (AZs), wired up with routing and NAT. If VPC concepts themselves are unfamiliar, I recommend going through the AWS Intermediate series first; here we focus on turning those concepts into code.

Address design: capture it in variables #

Define the CIDRs and the AZ layout in variables. To make good use of the for_each covered in Basics #6, the subnet layout is expressed as maps.

variables.tf
# add to variables.tf
variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "public_subnets" {
  type = map(string)
  default = {
    "ap-northeast-2a" = "10.0.1.0/24"
    "ap-northeast-2c" = "10.0.2.0/24"
  }
}

variable "private_subnets" {
  type = map(string)
  default = {
    "ap-northeast-2a" = "10.0.11.0/24"
    "ap-northeast-2c" = "10.0.12.0/24"
  }
}

AZs are the keys, CIDRs the values. Keeping public in the 1s and private in the 11s means the IP alone tells you which tier you are looking at, and it leaves room to add subnets in between.

The VPC and subnets #

network.tf
# network.tf (new file)
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "myapp-${var.env}" }
}

resource "aws_subnet" "public" {
  for_each = var.public_subnets

  vpc_id                  = aws_vpc.main.id
  availability_zone       = each.key
  cidr_block              = each.value
  map_public_ip_on_launch = true

  tags = { Name = "myapp-${var.env}-public-${each.key}" }
}

resource "aws_subnet" "private" {
  for_each = var.private_subnets

  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = each.value

  tags = { Name = "myapp-${var.env}-private-${each.key}" }
}

The reason for for_each instead of count is exactly what Basics #6 showed: adding or removing an AZ later does not recreate the other subnets. Addresses come out keyed by AZ name, like aws_subnet.public["ap-northeast-2a"], which also makes plans easier to read. enable_dns_hostnames defaults to off, but it needs to be on for the DNS names — like RDS endpoints — we will use later.

The internet gateway and public routing #

What makes a public subnet public is not its name — it is that its route table has a path to the internet gateway (IGW). We write that definition down as code, literally.

network.tf
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.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
}

Note that the association’s for_each receives the resource itself (aws_subnet.public), not the variable map. A resource created with for_each is itself a map, so it feeds directly into another for_each. Since each.value is a subnet resource, we reference .id on it.

NAT gateway: trading cost against structure #

Instances in private subnets still need outbound access (package installs, calls to external APIs). The NAT gateway is that path — it sits in a public subnet and sends private traffic out on their behalf. There is one design decision here. NAT charges by the hour and is a regular near the top of cost rankings, so you have to choose between one per AZ and just one. One per AZ keeps outbound alive in the surviving AZ when an AZ fails, but doubles the cost. For labs (and many dev environments) one is enough, so we go with one. In #9 this choice gets promoted to a per-environment variable.

network.tf
resource "aws_eip" "nat" {
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public["ap-northeast-2a"].id

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.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
}

depends_on makes its appearance. A NAT gateway only works when an IGW exists, yet there is no direct reference between the two — one of the rare legitimate cases of the “order dependency without a reference” laid out in Basics #6. It is also the pattern the AWS provider documentation recommends.

Outputs and verification #

Collect the values the coming parts will consume as outputs.

outputs.tf
# add to outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

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

output "private_subnet_ids" {
  value = [for s in aws_subnet.private : s.id]
}

Applying this creates fourteen resources: 1 VPC, 4 subnets, 1 IGW, 1 NAT, 1 EIP, 2 route tables, and 4 associations. Check the count in the plan, run terraform output after apply to see the IDs come out, and this part’s verification is done. Remember that the NAT is running — if you are finishing the lab for today, do not forget to destroy.

Recap #

What we covered in this post:

  • The subnet layout is defined as map variables keyed by AZ and created with for_each. Adding or removing an AZ does not touch the other subnets
  • What defines a public subnet is the route to the IGW. The route table and its associations are spelled out in code
  • Resources created with for_each are maps, so they feed directly into another resource’s for_each
  • The NAT gateway is a single one for labs and dev because of cost; that choice becomes a per-environment variable in #9. Its relationship with the IGW is a legitimate case for depends_on
  • vpc_id and the subnet ID lists are organized as outputs, ready to serve as the next part’s inputs

In the next post (#3 EC2 and Security Groups), we put the first compute onto this network. We will design security group chaining and boot a web server with user_data.

X