Building cloud infrastructure used to mean spending hours clicking through the AWS console and trying to remember which settings you used last time. I’ve been there – frantically taking screenshots of configurations and keeping messy notes just to recreate environments.
That’s where Terraform comes in. Instead of all that manual work, you write a few configuration files that describe exactly what you want your infrastructure to look like, and Terraform handles the rest.
Here’s what I am going to build:
- A VPC
- Public subnets for things that need internet access
- Private subnets for your databases and sensitive stuff
- All the networking pieces to make everything talk to each other
- Security groups to keep the bad guys out
- 3 EC2 instances
What you’ll need before we start:
- An AWS account (the free tier works fine for this / elastic ip is around i think $0.005 per hr / if you have free aws credits, this won’t hurt)
- Terraform installed on your computer
- AWS CLI set up with your credentials
- About an hour of your time
Let’s setup the environment first. Go to IAM > Create a User > Security Credentials Tab > Create Access Key. Create an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
AWS_ACCESS_KEY_ID=12345678
AWS_SECRET_ACCESS_KEY=12345678Create a docker-compose file that creates a containerized Terraform environment with AWS CLI. The latest image of terraform will be used, make sure to mount the current directory to /workspace, and stdin_open and tty are set to true so that it will enable terminal access.
services:
terraform:
image: hashicorp/terraform:latest
working_dir: /workspace
container_name: terraform-aws
entrypoint: ["sh", "-c", "apk add --no-cache aws-cli && sleep infinity"]
volumes:
- .:/workspace
stdin_open: true
tty: true
environment:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_REGION=us-east-1Run docker-compose up -d to start, then docker exec -it terraform-aws sh to access the container.
docker-compose up -d
docker exec -it terraform-aws shAnd this is the Terraform Project Structure:

Key Pairs
Key Pairs are cryptographic SSH keys used to securely authenticate and access EC2 instances without passwords. Key Pairs are like the master key and ID badges that let you securely access your employees or EC2 instances in your office building. Even if someone breaks into your building, they can’t access your employee’s work stations without the specific private badge reader. It’s important to note if you lose the private key file, you lose ssh access to your EC2 instance permanently or you can setup alternative access methods.
Generates a 4096-bit RSA private/public key pair in memory.
resource "tls_private_key" "private" {
algorithm = "RSA"
rsa_bits = 4096
}Registers the public key with AWS as an EC2 key pair named “terraform-key-pair”.
resource "aws_key_pair" "generated_key" {
key_name = "terraform-key-pair"
public_key = tls_private_key.private.public_key_openssh
}Saves the private key as a .pem file to your local Terraform directory.
resource "local_file" "private_key" {
content = tls_private_key.private.private_key_pem
filename = "${path.root}/terraform-key-pair.pem"
}Expose the return values of the above code to be used by other modules.
output "key_pair_name" {
value = aws_key_pair.generated_key.key_name
}
output "tls_private_key_pem" {
value = tls_private_key.private.private_key_pem
}Create the keypair module.
module "keypair" {
source = "../../modules/keypair"
}You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.
Networking
VPC
A VPC is a logically isolated section of a cloud provider’s network where you can launch and manage your cloud resources in a virtual network that you define and control. They are fundamental to cloud architecture because they can give you the network foundation needed to build a secure and scalable applications while maintaining control over your network environment. Like an office building floor, you can rent an entire floor of a sky scraper, you can decide how to divide it into rooms or subnets, who can access each rooms or security groups and weather some rooms have windows to the outside world or internet access. While others are interior offices or private subnets. It’s essentially cloud providers saying “here’s your own private piece of the internet where you can build whatever you need.
Create a VPC with 10.0.0.0/16 CIDR block.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "VPC"
}
}Expose the return values of the above code to be used by other modules.
output "vpc_id" {
value = aws_vpc.main.id
}
output "vpc_cidr_block" {
value = aws_vpc.main.cidr_block
}
Public Subnets
A Public Subnet has a route to an Internet Gateway. Think of it as a building’s main entrance that connects your floor directly to the street. Any server you put in a public subnet can receive traffic directly from the internet. Just like how people on the street can see and access those street-facing conference rooms. You have to note that just because a room has windows, doesn’t mean anyone can just walk in. You still have security or security groups, firewalls controlling exactly who can enter and what they can do.
Defines an input variable that accepts a list of subnet CIDR blocks, defaulting to one subnet (10.0.1.0/24).
variable "public_subnet_cidrs" {
type = list(string)
default = [ "10.0.1.0/24" ]
}Defines an input variable that accepts a list of availability zones, defaulting to us-east-1a and us-east-1b.
variable "azs" {
type = list(string)
default = [ "us-east-1a", "us-east-1b" ]
}Creates multiple public subnets in the VPC, one for each CIDR block in the variable, distributing them across the specified availability zones.
resource "aws_subnet" "public_subnets" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = element(var.public_subnet_cidrs, count.index)
availability_zone = element(var.azs, count.index)
tags = {
Name = "Public Subnet ${count.index+1}"
}
}Expose the return values of the above code to be used by other modules.
output "public_subnets" {
value = aws_subnet.public_subnets
}
output public_subnet_cidrs {
value = var.public_subnet_cidrs
}
Private Subnets
A Private Subnet is like the interior offices and back rooms. They have no windows to the street and can’t be accessed directly from the outside world. It has no direct route to the internet gateway. There’s no street entrance to these rooms. These servers can’t receive traffic directly from the internet just like how people on the street cant walk directly in to your back offices. To be able for private subnet to access the outside world, it must go through NAT Gateway which we will not cover. Even if someone breaks through your perimeter security, your most critical systems: databases, internal applications, are in these window-less backrooms. Where they are much harder to reach and attack directly.
Defines an input variable for private subnet CIDR blocks, defaulting to two subnets (10.0.2.0/24 and 10.0.3.0/24).
variable "private_subnet_cidrs" {
type = list(string)
default = [ "10.0.2.0/24", "10.0.3.0/24" ]
}Creates private subnets using the CIDR blocks and availability zones from variables, limited by whichever list is shorter.
resource "aws_subnet" "private_subnets" {
count = min(length(var.private_subnet_cidrs), length(var.azs))
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = {
Name = "Private Subnet ${count.index + 1}"
}
}Expose the return values of the above code to be used by other modules.
output "private_subnets" {
value = aws_subnet.private_subnets
}
You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.
Internet Gateway
An Internet Gateway is like the main building entrance and lobby. Its a single point where your office floors connect to the outside world or the internet. Simply put, its an aws-managed component that connects your VPC to the internet. Without Internet Gateway, your VPC has no internet connectivity at all – completely isolated network.
Creates an internet gateway and attaches it to the VPC to enable internet access.
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "Internet Gateway"
}
}
Route Tables
Route Tables are network routing rules that determine where to send traffic based on destination IP addresses. When your web servers wants to download updates from the internet, it checks it’s route table. To reach 0.0.0.0/0, go to the Internet Gateway and sends the traffic there.
Creates a public route table in the VPC.
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "Public Route Table"
}
}
Adds a route in the public route table that sends all traffic (0.0.0.0/0) to the internet 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.igw.id
}
Associates the first public subnet with the public route table.
resource "aws_route_table_association" "public_first_subnet" {
subnet_id = aws_subnet.public_subnets[0].id
route_table_id = aws_route_table.public.id
}
Creates a private route table in the VPC.
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "Private Route Table"
}
}
Associates all private subnets with the private route table.
resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = element(aws_subnet.private_subnets[*].id, count.index)
route_table_id = aws_route_table.private.id
}
Create the networking module.
module "networking" {
source = "../../modules/networking"
}You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.
Security Groups
Security Groups are virtual firewalls that control inbound and outbound traffic at the instance level like EC2 and RDS. Unlike the building’s main security that controls who can enter each room or area, security groups are like individual bodyguards that stick with specific employees wherever they go. For example, if someone approaches your employee, start a conversation which is called the inbound request and you bodyguard approves it, they automatically allow that person to respond back which is called the outbound response.
The Security Groups module will be accepting returned values from the Networking module which are the vpc_id and vpc_cidr_block. Create a variable for them.
variable "vpc_id" {
type = string
}
variable "vpc_cidr_block" {
type = string
}Creates a public security group in the VPC.
resource "aws_security_group" "public" {
name = "public-sg"
vpc_id = var.vpc_id
tags = {
Name = "Public SG"
}
}
Creates a private security group in the VPC.
resource "aws_security_group" "private" {
name = "private-sg"
vpc_id = var.vpc_id
tags = {
Name = "Private SG"
}
}
Allows SSH outbound traffic from the public security group to the private security group.
resource "aws_vpc_security_group_egress_rule" "public_egress" {
security_group_id = aws_security_group.public.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.private.id
tags = {
Name = "SSH Outgoing - Public SG -> Private SG"
}
}
Allows SSH inbound traffic to the private security group from the public security group.
resource "aws_vpc_security_group_ingress_rule" "private_ingress" {
security_group_id = aws_security_group.private.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.public.id
tags = {
Name = "SSH Incoming - Private SG <- Public SG"
}
}
Allows SSH inbound traffic to the public security group from anywhere on the internet.
resource "aws_vpc_security_group_ingress_rule" "public_ssh_anywhere" {
security_group_id = aws_security_group.public.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
tags = {
Name = "Public SG SSH Anywhere"
}
}
Create the security_groups module, passing the VPC ID and CIDR block from the networking module, and waits for networking to complete first.
module security_groups {
source = "../../modules/security_groups"
vpc_id = module.networking.vpc_id
vpc_cidr_block = module.networking.vpc_cidr_block
depends_on = [ module.networking ]
}You can see the full code here https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.
EC2 Instances
Just like you hire different types of employees with different skills and assign them to different rooms in your office, EC2 instances are virtual computers that you hire from AWS that performs specific tasks. EC2 instances are your actual workforce. The virtual computers doing their real work in your cloud infrastructure just like employees doing real work in your office building.
Defines the private IP address for the public instance, defaulting to 10.0.1.10.
variable "public_instance_private_ip" {
type = string
default = "10.0.1.10"
}Defines the private IP addresses for private instances, defaulting to 10.0.2.10 and 10.0.3.10.
variable "private_instance_private_ips" {
type = list(string)
default = [ "10.0.2.10", "10.0.3.10" ]
}The compute module will be accepting returned values from the other modules. Create variables for them.
variable "vpc_id" {
type = string
}
variable "private_subnets" {
type = any
}
variable "public_subnets" {
type = any
}
variable "public_security_group_id" {
type = string
}
variable "private_security_group_id" {
type = string
}
variable "tls_private_key_pem" {
type = string
sensitive = true
}
variable "key_pair_name" {
type = string
}
variable "public_subnet_cidrs" {
type = list(string)
}Creates public EC2 instances in each public subnet with the specified AMI, instance type, and security group.
resource "aws_instance" "public" {
count = length(var.public_subnet_cidrs)
ami = "ami-084568db4383264d4"
instance_type = "t3.micro"
key_name = var.key_pair_name
vpc_security_group_ids = [var.public_security_group_id]
subnet_id = var.public_subnets[count.index].id
private_ip = var.public_instance_private_ip
tags = {
Name = "Public Instance"
}
}
Creates Elastic IP addresses for each public instance.
resource "aws_eip" "public_eip" {
count = length(var.public_subnet_cidrs)
domain = "vpc"
}
Associates each Elastic IP with its corresponding public instance.
resource "aws_eip_association" "public_eip_assoc" {
count = length(var.public_subnet_cidrs)
instance_id = aws_instance.public[count.index].id
allocation_id = aws_eip.public_eip[count.index].id
}
Creates private EC2 instances in private subnets with custom root volumes and assigned private IPs.
resource "aws_instance" "private_instances" {
for_each = { for index, ip in var.private_instance_private_ips : index => ip }
ami = "ami-084568db4383264d4"
instance_type = "t3.micro"
key_name = var.key_pair_name
vpc_security_group_ids = [var.private_security_group_id]
subnet_id = var.private_subnets[each.key].id
private_ip = var.private_instance_private_ips[each.key]
root_block_device {
volume_size = 20
volume_type = "gp3"
delete_on_termination = true
}
tags = {
Name = "Private Instance ${each.key + 1}"
}
}
Creates the compute module, passing networking details, security group IDs, and key pair information from other modules, and waits for all dependencies to complete first.
module "compute" {
source = "../../modules/compute"
vpc_id = module.networking.vpc_id
public_subnet_cidrs = module.networking.public_subnet_cidrs
private_subnets = module.networking.private_subnets
public_subnets = module.networking.public_subnets
public_security_group_id = module.security_groups.public_security_group_id
private_security_group_id = module.security_groups.private_security_group_id
key_pair_name = module.keypair.key_pair_name
tls_private_key_pem = module.keypair.tls_private_key_pem
depends_on = [ module.keypair, module.networking, module.security_groups ]
}Policies
In aws, create a terraform-user and add these policies:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeVpcAttribute",
"ec2:DescribeInstanceTypes",
"ec2:DescribeAddressesAttribute",
"ec2:CreateVpc",
"ec2:DeleteVpc",
"ec2:DescribeVpcs",
"ec2:ModifyVpcAttribute",
"ec2:CreateSubnet",
"ec2:DeleteSubnet",
"ec2:DescribeSubnets",
"ec2:CreateInternetGateway",
"ec2:DeleteInternetGateway",
"ec2:DescribeInternetGateways",
"ec2:AttachInternetGateway",
"ec2:DetachInternetGateway",
"ec2:CreateRouteTable",
"ec2:DeleteRouteTable",
"ec2:DescribeRouteTables",
"ec2:AssociateRouteTable",
"ec2:DisassociateRouteTable",
"ec2:CreateRoute",
"ec2:DeleteRoute",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSecurityGroupRules",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:RevokeSecurityGroupEgress",
"ec2:CreateKeyPair",
"ec2:DeleteKeyPair",
"ec2:DescribeKeyPairs",
"ec2:ImportKeyPair",
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:DescribeInstanceAttribute",
"ec2:AllocateAddress",
"ec2:ReleaseAddress",
"ec2:DescribeAddresses",
"ec2:AssociateAddress",
"ec2:DisassociateAddress",
"ec2:CreateTags",
"ec2:DescribeTags",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeImages",
"ec2:DescribeVolumes",
"ec2:DescribeInstanceCreditSpecifications",
"ec2:DescribeNetworkInterfaces"
],
"Resource": "*"
}
]
}Run the following to see the changes.
terraform init
terraform plan -out=aws.tfplan
terraform apply aws.tfplanIf you want to destroy everything, run
terraform destroy --auto-approveYou can see this on Github https://github.com/rinavillaruz/easy-aws-infrastructure-terraform.
