AWS VPC, Subnets, Gateways & Route Tables
Rebuild the network you made by hand as Terraform modules, and see the plan account for every subnet and route.
- Time
- 47 min
- Level
- Intermediate
- Objectives
- 5 objectives
- Cost
- Billable
Where this fits in the platform
This lab adds
- vpc_id, public and private subnet ids, route tables, NAT gateway
Before you start
Cost — Billable
The NAT Gateway is ~$0.045/hour (~$32/month) plus $0.045/GB processed, and it bills whether or not traffic flows. The VPC, subnets and route tables are free. Destroy the NAT Gateway the moment you are done.
Nothing to pay in the browser. Open the terminal runs this against a simulated cloud — the same API calls and the same commands, with no account and no bill. The figure above applies only if you build it in your own.
The scenario#
You built this network by hand in the console lab. Doing it again in another region would take the same forty minutes and produce something subtly different.
This is the same network as code — and the first plan you can read line by line before anything is created.
Hands-on environment
Run this lab in a real terminal, free and in your browser. The environment is temporary and yours alone — break it as much as you like.
Open the terminalOpens in Killercoda, in a new tab — keep this page open for the steps.
Run it on AWS
This lab builds real cloud infrastructure, so it needs your own AWS account. Follow the cost and cleanup notes above — the resources are yours, and so is the bill.
Anything you tick here is your own record. EgyKode cannot see inside that terminal, so the success criteria stay self-assessed even when the environment checks your work for you.
The network
Step 1 of 4
What you are building#
VPC 10.0.0.0/16
┌──────────────────────────────┬──────────────────────────────┐
│ us-east-1a │ us-east-1b │
│ public 10.0.1.0/24 │ public 10.0.2.0/24 │
│ └─ NAT Gateway + EIP │ │
│ private 10.0.10.0/24 │ private 10.0.11.0/24 │
└──────────────┬───────────────┴──────────────┬───────────────┘
│ │
Internet Gateway route 0.0.0.0/0 > NATA subnet is not public or private as a property. Both are identical
objects. What makes one public is a route table entry sending 0.0.0.0/0 to
an Internet Gateway; what makes the other private is that its route sends
0.0.0.0/0 to a NAT Gateway instead. Nothing else distinguishes them, and
naming a subnet "public" while pointing it at a NAT is a mistake Terraform
will happily make for you.
Two availability zones, because one is not high availability. An AZ is a
distinct set of buildings. Losing one is rare and does happen, and an
architecture with everything in us-east-1a goes down with it.
Build it#
What you are proving: You can lay out a VPC across two availability zones with a public and a private subnet in each
Marking this settles success criterion 1.
variable "azs" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true # required for private DNS and for EKS
tags = { Name = "platform" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 1)
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-${var.azs[count.index]}"
"kubernetes.io/role/elb" = "1" # ALBs go here
}
}
resource "aws_subnet" "private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
availability_zone = var.azs[count.index]
tags = {
Name = "private-${var.azs[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
}
}cidrsubnet("10.0.0.0/16", 8, 1) produces 10.0.1.0/24 — it adds 8 bits to
the prefix and takes block 1. Computing the blocks beats writing them out,
because a hardcoded list is where overlapping CIDRs come from.
The kubernetes.io/role/* tags are how the AWS Load Balancer Controller
discovers which subnets to place load balancers in. Without them, the
Kubernetes ingress lab fails with an error that says nothing about tags.
What you are proving: You can give private subnets outbound internet access that cannot be reached inbound, and say what it costs per hour
Marking this settles success criterion 2.
resource "aws_eip" "nat" {
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id # lives in a PUBLIC subnet
depends_on = [aws_internet_gateway.main]
}A NAT Gateway is about $32/month plus per-GB processing, and in a private-subnet architecture it is usually the largest non-compute line on the bill.
One NAT for both AZs is cheaper and means an AZ failure takes out egress for the surviving one. One per AZ doubles the cost and removes that dependency. For learning, one. For production, decide deliberately — this is a real trade-off, not an oversight.
The depends_on is required: a NAT Gateway created before the IGW is attached
comes up unable to route, and the failure appears later as a timeout.
What you are proving: You can point at the route table entry that makes a subnet public, rather than at its name
Marking this settles success criterion 3.
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" "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" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}A subnet with no explicit association silently uses the VPC's main route table, which has no internet route. Nothing errors; instances simply cannot reach anything, and the cause is an association you forgot rather than a rule you got wrong.
What you are proving: You can apply until a second plan reports no changes
Marking this settles success criterion 4.
terraform init
terraform plan -out=tfplan # read it before you accept it
terraform apply tfplanVerify it worked#
# Every private subnet routes 0.0.0.0/0 at a NAT, never at an IGW
aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=$(terraform output -raw vpc_id)" \
--query 'RouteTables[].{rt:RouteTableId,routes:Routes[?DestinationCidrBlock==`0.0.0.0/0`].[GatewayId,NatGatewayId]}' \
--output table
# Both AZs are represented
aws ec2 describe-subnets --filters "Name=vpc-id,Values=$(terraform output -raw vpc_id)" \
--query 'Subnets[].[Tags[?Key==`Name`]|[0].Value,AvailabilityZone,CidrBlock]' --output table
# The real test: outbound works, inbound does not
aws ssm start-session --target <private-instance-id>
# curl -sI https://example.com | head -1 → 200, via NAT
# (nothing on the internet can open a connection to this instance)
terraform plan -detailed-exitcode; echo "exit $?" # 0 = no driftA private instance cannot reach the internet
Work outward: does the private route table have 0.0.0.0/0 → nat-…, is the
subnet actually associated with that table, is the NAT in a public subnet,
and does the public route table point at the IGW? A NAT in a private subnet is
the classic version of this and produces a silent timeout.
Error: InvalidSubnet.Conflict
Two subnets overlap. cidrsubnet with distinct indexes prevents it; hand-written
blocks do not.
Apply hangs on aws_nat_gateway
NAT Gateways take a few minutes to provision. Ten minutes without progress
means it cannot reach the IGW — check the depends_on.
destroy fails on the VPC
Something Terraform did not create is still inside it — usually an ENI from a load balancer that Kubernetes provisioned. Delete the Ingress objects first, then destroy.
A charge after destroying everything
An Elastic IP that was released from the NAT but not deallocated.
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null]'.
Clean up#
Destructive — This removes real resources. Check which environment you are in first.
terraform destroy -auto-approve
aws ec2 describe-nat-gateways --filter Name=state,Values=available --query 'NatGateways[].NatGatewayId'
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]'Cost of this lab: Billable. The NAT Gateway is roughly $0.045/hour plus per-GB processing — about $32/month if left running. Everything else here is free. Destroy it when you finish.
Success criteria
0 of 5
The concept behind it
Next up
Lab 23 of 59 on the project path