DEV Community

Joseph Davis
Joseph Davis

Posted on • Originally published at Medium

Ninety Lines of Terraform, One Whole AWS Network

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)  │ │ │ ││
 │ │ │ │ └─────────────────────────────────────────────┘ │ │ ││
 │ │ │ └─────────────────────────────────────────────────┘ │ ││
 │ │ └─────────────────────────────────────────────────────┘ ││
 │ └─────────────────────────────────────────────────────────┘│
 └─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 type make plan instead of the full terraform -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" }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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/0 on 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]
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)