Description: A hands-on walkthrough of provisioning a complete AWS network and EC2 instance using Terraform — VPC, subnets, routing, security groups, and more.
Infrastructure as Code changed the way I think about cloud resources. Instead of clicking through the AWS console, I can describe my entire environment in code, version it with Git, and reproduce it in minutes. This post walks through my first Terraform assignment — provisioning a real, working AWS environment from scratch.
What I Built
By the end of this project, Terraform had created the following resources in AWS:
| Resource | Name | Details |
|---|---|---|
| VPC | Terraform-vpc |
10.0.0.0/16 |
| Internet Gateway | Terraform-igw |
Attached to the VPC |
| Public Route Table | Terraform-public-rt |
Routes 0.0.0.0/0 → IGW |
| Public Subnet | Terraform-public-subnet |
10.0.1.0/24, auto-assigns public IPs |
| Private Subnet | Terraform-private-subnet |
10.0.2.0/24, no public IP |
| Security Group | Terraform-ec2-sg |
Allows SSH (22) and HTTP (80) inbound |
| EC2 Instance | Terraform-ec2 |
t3.micro, Amazon Linux 2, in public subnet |
Project Structure
Keeping things clean and modular from day one is a habit worth building. Here's how I structured the project:
Terraform_Assignment1/
├── main.tf # All resource definitions
├── variables.tf # Input variables with defaults
├── outputs.tf # Useful output values
├── PLANNING.md # Design decisions and steps
└── README.md # Usage documentation
Separating variables, outputs, and resources into their own files makes the configuration readable and reusable — even at this small scale.
The Code Walkthrough
1. Provider Configuration
Every Terraform project starts with declaring which providers you need. I pinned the AWS provider to the ~> 5.0 range to avoid unexpected breaking changes.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
The region is driven by a variable rather than hardcoded — a small habit that pays off when you want to deploy to a different region without touching resource code.
2. The VPC and Internet Gateway
The VPC is the foundation. Everything else lives inside it. I gave it a /16 CIDR block, which provides 65,536 IP addresses — plenty of room to grow.
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = { Name = "Terraform-vpc" }
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "Terraform-igw" }
}
The Internet Gateway is what connects the VPC to the public internet. Without it, nothing inside the VPC can reach the outside world.
3. Public and Private Subnets
I created two subnets — one public, one private — both in the first available Availability Zone. Instead of hardcoding the AZ name, I used a data source to fetch it dynamically:
data "aws_availability_zones" "available" {}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
map_public_ip_on_launch = true
availability_zone = data.aws_availability_zones.available.names[0]
tags = { Name = "Terraform-public-subnet" }
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidr
availability_zone = data.aws_availability_zones.available.names[0]
tags = { Name = "Terraform-private-subnet" }
}
Key difference: map_public_ip_on_launch = true on the public subnet means any EC2 instance launched there automatically gets a public IP. The private subnet has no such setting — it's intentionally isolated, reserved for future resources like databases.
4. Routing
A subnet is only "public" if it has a route to the internet. I created a dedicated route table that sends all outbound traffic (0.0.0.0/0) through the Internet Gateway, then associated it with the public subnet.
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = { Name = "Terraform-public-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
The private subnet uses the VPC's default route table, which has no internet route — keeping it isolated by default.
5. Security Group
The security group acts as a virtual firewall for the EC2 instance. I opened port 22 (SSH) and port 80 (HTTP) for inbound traffic, and allowed all outbound traffic.
resource "aws_security_group" "ec2_sg" {
name = "Terraform-ec2-sg"
description = "Allow SSH and HTTP inbound"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "Terraform-ec2-sg" }
}
⚠️ Note: Opening SSH to
0.0.0.0/0is fine for a learning exercise, but in production you should restrict it to your own IP address.
6. EC2 Instance
With the network in place, launching the instance is straightforward. It goes into the public subnet, gets the security group attached, and receives a public IP automatically.
resource "aws_instance" "example" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2_sg.id]
associate_public_ip_address = true
tags = { Name = "Terraform-ec2" }
}
7. Variables
All environment-specific values live in variables.tf with sensible defaults. This makes the entire configuration reusable — swap the region and AMI ID and you can deploy to any AWS region.
variable "aws_region" { default = "eu-central-1" }
variable "vpc_cidr" { default = "10.0.0.0/16" }
variable "public_subnet_cidr" { default = "10.0.1.0/24" }
variable "private_subnet_cidr" { default = "10.0.2.0/24" }
variable "instance_type" { default = "t3.micro" }
variable "ami_id" { default = "ami-051eaec1417c5d4ae" }
8. Outputs
After terraform apply, outputs surface the key values you actually need — no digging through the console.
output "vpc_id" { value = aws_vpc.main.id }
output "public_subnet_id" { value = aws_subnet.public.id }
output "private_subnet_id" { value = aws_subnet.private.id }
output "ec2_instance_id" { value = aws_instance.example.id }
output "ec2_public_ip" { value = aws_instance.example.public_ip }
Deploying It
terraform init # Download the AWS provider
terraform plan # Preview what will be created
terraform apply # Provision everything
terraform destroy # Tear it all down when done
After apply, the terminal prints the outputs — including the EC2 public IP you can SSH into immediately.
Key Takeaways
-
Separate your files.
main.tf,variables.tf, andoutputs.tfis the minimum clean structure. - Use data sources over hardcoded values. Fetching the AZ dynamically means the config works across regions without changes.
- Variables make configs reusable. Parameterise anything environment-specific from the start.
- The private subnet is intentional. Even if nothing lives there yet, having it ready follows the principle of least privilege — internal resources should never be internet-facing by default.
This was Week 1 of my IaC journey. The goal was to understand the building blocks — VPC, subnets, routing, security groups, compute — and how Terraform ties them together declaratively. Everything from here builds on this foundation.
Have questions or feedback? Drop them in the comments below. 👇
Top comments (0)