How a single folder of config spins up a VPC, two subnets, a gateway, a firewall, and a running server — and the two small things that tripped me up.
The AWS console and I have a complicated relationship. Every time I build a network by hand, I click through the same fifteen screens, forget one route table, and end up with a server that can't reach the internet for reasons I can't remember an hour later.
So I stopped clicking. This is the story of a small Terraform project that builds an entire AWS network from scratch — and tears it back down — with two commands. Nothing exotic. Just the pieces you actually need, wired together so they're reproducible.
Here's the whole thing on one picture before we get into the code.
┌───────────────┐
Terraform ──────┐ │ Internet │
│ └───────┬───────┘
▼ │ (IGW)
┌─────────────────────────────┼──────────────────────────────┐
│ AWS Account │ │
│ ┌───────────────────────────┼─────────────────────────────┐│
│ │ Region (IGW) ││
│ │ ┌─────────────────────────┴───────────────────────────┐ ││
│ │ │ VPC 10.0.0.0/16 │ ││
│ │ │ ┌─────────────────────────────────────────────────┐ │ ││
│ │ │ │ Availability Zone (eu-central-1a) │ │ ││
│ │ │ │ ┌─────────────────────────────────────────────┐ │ │ ││
│ │ │ │ │ Public subnet 10.0.1.0/24 │ │ │ ││
│ │ │ │ │ ┌───────────────────────────────┐ │ │ │ ││
│ │ │ │ │ │ Security group → [ EC2 ] │ │ │ │ ││
│ │ │ │ │ └───────────────────────────────┘ │ │ │ ││
│ │ │ │ └─────────────────────────────────────────────┘ │ │ ││
│ │ │ │ ┌─────────────────────────────────────────────┐ │ │ ││
│ │ │ │ │ Private subnet 10.0.2.0/24 (no internet) │ │ │ ││
│ │ │ │ └─────────────────────────────────────────────┘ │ │ ││
│ │ │ └─────────────────────────────────────────────────┘ │ ││
│ │ └─────────────────────────────────────────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
The target: one account, one region, one VPC, two subnets. The public one gets a door to the internet. The private one stays walled off.
The problem with clicking
A network you build by hand exists only in that one account, exactly as you left it. Nobody can review it. Nobody can rebuild it. And when you want a clean copy for staging, you're back to screen one.
Terraform flips that around. You describe the network you want in plain text, and it figures out the order to create things, what depends on what, and how to reconcile reality with your file. The whole setup here is five resources the assignment asked for, plus the plumbing that makes the public subnet actually public.
How the files are laid out
Before the code, here's the shape of the project. Terraform reads every .tf file in the folder and stitches them together, so the split is purely for us humans — inputs in one place, the network in another, results in a third.
Devopsec-terraform/
├── Makefile # init / validate / plan / apply / destroy shortcuts
└── week1/
├── providers.tf # which cloud, which provider version
├── variables.tf # inputs: region, CIDRs, instance type, SSH range
├── main.tf # the network itself: VPC, subnets, IGW, SG, EC2
├── outputs.tf # what to print after apply (IDs, public IP)
└── README.md # how to run it
A quick tour of each:
-
providers.tf— pins the AWS provider (~> 5.0) and the region. This is the "talk to AWS" wiring. -
variables.tf— every value I might want to change without touching logic: the region, the two subnet CIDRs, the instance type, and the SSH range. Defaults live here. -
main.tf— the star. All the resources from the diagram, in one readable file. -
outputs.tf— the handful of things I want echoed back after a build, like the instance's public IP. -
Makefile— thin wrappers so I typemake planinstead of the fullterraform -chdir=week1 plan.
The nice part: order doesn't matter and cross-file references just work. main.tf can say var.region and Terraform knows to look in variables.tf. Now let's build it up piece by piece.
Start with the VPC
The VPC is the fence around everything. It owns a private IP range — here a /16, which gives us 65,536 addresses to carve up.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "devopsec-vpc" }
}
Those two DNS flags matter more than they look. Turn them on and your EC2 instance gets a real public DNS name, not just a bare IP. Skip them and you'll wonder later why nothing resolves.
Two subnets, one important difference
A subnet is a slice of the VPC's address range, pinned to one availability zone. We need two: one public, one private.
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "eu-central-1a"
map_public_ip_on_launch = true # the key line
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "eu-central-1a"
}
The only real difference is map_public_ip_on_launch. Set it to true and anything you launch into the public subnet automatically gets a public IP. The private subnet doesn't get that flag, so its instances stay unreachable from outside — which is the whole point of calling it private.
A subnet isn't public because of its name. It's public because of a public IP, a gateway, and a route that ties them together.
The three pieces that make "public" true
Here's the part the console hides from you behind friendly defaults. A public subnet needs three things working together: a gateway to the internet, a route table, and a rule pointing all outbound traffic at that gateway.
# 1. A door to the internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
# 2. A route table for the public subnet
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
}
# 3. "Send anything not local out the gateway"
resource "aws_route" "public_internet_access" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
Then you glue the table to the subnet. Without this association, the route table exists but does nothing.
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Notice the private subnet gets none of this. No gateway, no route, no association. That's on purpose — it costs nothing to leave it isolated, and adding an outbound path would mean a NAT gateway and real hourly charges we don't need here.
A firewall in front of the server
A security group is a stateful firewall wrapped around the instance. You describe what's allowed in (ingress) and what's allowed out (egress), and it remembers the connections so replies come back automatically.
resource "aws_security_group" "ec2_sg" {
name_prefix = "devopsec-ec2-sg"
vpc_id = aws_vpc.main.id
ingress { # SSH in
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
ingress { # HTTP in
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress { # everything out
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Two ways in: SSH on port 22 and HTTP on port 80. Everything is allowed out. I pulled the SSH range into a variable so I can lock it to my own IP later instead of leaving it open to the whole internet.
Habit worth building: The risk almost always lives on the ingress side. Wide-open egress is usually fine; wide-open
0.0.0.0/0on SSH is the thing to tighten first.
The server itself
Rather than hardcode an AMI ID — which changes constantly and differs per region — I ask AWS for the latest Amazon Linux 2023 image at plan time.
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "web_server" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
}
The instance lands in the public subnet and wears the security group we just built. Because the subnet auto-assigns public IPs and routes out through the gateway, this server is reachable the moment it boots.
Two things that actually bit me
The code above reads clean, but I didn't get there on the first try. Two small things cost me a few minutes each, and both are the kind of thing nobody mentions until you hit them.
1. "Free tier" is regional
I started with t2.micro because that's the classic free-tier instance. The apply failed:
Error: creating EC2 Instance: InvalidParameterCombination:
The specified instance type is not eligible for Free Tier.
Turns out t2.micro is only free-tier in older regions. In eu-central-1 the free-tier type is t3.micro. One word changed, and the apply went through. If you ever see that error, ask AWS what's actually free where you are:
aws ec2 describe-instance-types \
--filters "Name=free-tier-eligible,Values=true" \
--query "InstanceTypes[].InstanceType"
2. The wrong account, silently
My terminal had no AWS_PROFILE set, so Terraform quietly used the default profile — which pointed at a different account than the one I meant. Everything "worked," just in the wrong place. Now I pin the profile so it can't drift:
# at the top of my Makefile
export AWS_PROFILE := spomega
No more guessing which account an apply is about to touch. That one line saved me from a repeat of the mystery-resources-in-the-wrong-account afternoon.
From nothing to a running network
With all of that in a folder, the entire lifecycle is four commands — and I wrapped them in a Makefile so I never fat-finger a flag.
make init # download the AWS provider
make validate # check the config is sound
make plan # preview what will change
make apply # build it for real
A minute later, Terraform hands back the IDs and the public IP of a live server:
instance_id = "i-02c8a8ab7c98892b2"
instance_public_ip = "3.120.245.41"
vpc_id = "vpc-0d27a957bdf8b8c5f"
And when I'm done, make destroy removes every last piece, so nothing sits there quietly billing me overnight. That round-trip — build it, prove it, delete it, rebuild it identically — is the thing you can't get by clicking.
That's the whole project: a network you can read top to bottom, review in a pull request, and recreate anywhere in a minute. Five resources the assignment asked for, a few more to make "public" mean something, and two little gotchas paid forward so you can skip them.
If you're building your first VPC, steal this shape and change the CIDRs. If you've done this a hundred times, I'd love to hear how you'd tighten it — especially where you draw the line on that SSH rule.
👏 Clap if this saved you a trip through the console.
Tags: Terraform · AWS · DevOps · Infrastructure as Code · VPC
Top comments (0)