In the previous article we manually built a production VPC, subnets, security groups, NAT Gateway, Application Load Balancer, and EC2 instance through the AWS Console. We deployed our Dockerized Smash Apartment application and made it accessible to via the ALB's DNS.
It worked perfectly. But it took a lot of time, was tedious and error prone, and tearing it down involved a tedious 12-step manual process that easily failed if you got the dependency order wrong.
Enter Terraform, it's the perfect solution to our problem, we can scaffold the entire architecture, plan to see changes to our architecture and destroy it easily.
In this article, we are going to rebuild the exact same infrastructure from Part 1, but this time we'll use Infrastructure as Code (IaC). The entire architecture will live in a few code files. One command to create it. One command to destroy it.
Table of Contents
- Introduction: Why Terraform?
- Pre-requisites
- Step 1: Project Structure and Foundation
- Step 2: Networking — VPC and Subnets
- Step 3: Security Groups
- Step 4: Application Load Balancer
- Step 5: EC2 Instance and Bootstrap
- Step 6: Outputs and Deployment
- Step 7: The Cleanup — One Command
- Conclusion & Next Steps
Introduction: Why Terraform?
Terraform is an open-source tool that lets you define cloud infrastructure using a declarative configuration language (HCL). Instead of clicking through the AWS Console, you write code that says "I want a VPC with this CIDR block."
Why does this beat the manual console method?
- Repeatable: The same configuration produces the exact same infrastructure every time. Need a staging environment? Just copy the folder, change the environment variable, and deploy.
- Version Controlled: Infrastructure changes go through git, pull requests, and code reviews, just like application code.
-
Self-Documenting: The
.tffiles act as the ultimate source of truth. No more guessing which security group allows what traffic. - Automated Dependency Management: Terraform knows the NAT Gateway needs the Elastic IP, and it knows the subnets need the VPC. It creates them in the exact right order.
Pre-requisites
Before we begin, ensure you have the following ready:
- AWS CLI configured: Configured with Administrator access.
- Terraform installed: Download and install Terraform (v1.5+).
-
EC2 Key Pair: A key pair created in AWS (e.g.,
apartment-key), same as Part 1. - The Application Code:
git clone https://github.com/Israel-dot-com/apartment-deployment-main
Step 1: Project Structure and Foundation
Let's look at the structure of our Terraform configuration. Splitting the configuration by concern makes it much easier to maintain.
terraform/
├── main.tf # AWS Provider config and default tags
├── variables.tf # All configurable inputs
├── vpc.tf # VPC, subnets, gateways, route tables
├── security_groups.tf # Security groups for ALB and EC2
├── alb.tf # Load balancer, target group, listeners
├── ec2.tf # EC2 instance, AMI lookup, IAM role
├── user_data.sh # Bash script that installs Docker on boot
├── outputs.tf # Prints the ALB URL and SSH commands after deploy
└── terraform.tfvars.example # Example variable values
1.1 Provider Configuration (main.tf)
First, we tell Terraform we are working with AWS and define our default tags.
# main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
Why default tags? The default_tags block automatically applies these tags to every resource Terraform creates. This is incredibly useful for billing and resource tracking.
1.2 Input Variables (variables.tf)
Next, we define the inputs. These are the exact same CIDR blocks and values we typed into the console in Part 1.
# variables.tf (abridged for length)
variable "aws_region" {
description = "AWS region to deploy into"
default = "us-east-1"
}
variable "project_name" {
default = "apartment"
}
variable "vpc_cidr" {
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
default = ["10.0.11.0/24", "10.0.12.0/24"]
}
variable "availability_zones" {
default = ["us-east-1a", "us-east-1b"]
}
variable "key_pair_name" {
description = "Name of an existing EC2 key pair for SSH access"
type = string
}
variable "domain_name" {
description = "Domain name for the ALB HTTPS listener (leave empty to skip HTTPS)"
default = ""
}
Step 2: Networking — VPC and Subnets
Remember the 10+ manual console steps from Part 1? Creating the VPC, enabling DNS hostnames, creating four subnets, allocating an EIP, creating the NAT Gateway, and setting up route tables? Here is all of that, codified.
2.1 Defining the Network (vpc.tf)
# vpc.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project_name}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project_name}-igw" }
}
# Creates 2 Public Subnets dynamically using count
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project_name}-public-${count.index + 1}" }
}
# Creates 2 Private Subnets dynamically using count
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = { Name = "${var.project_name}-private-${count.index + 1}" }
}
resource "aws_eip" "nat" {
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.main]
}
Notice the depends_on block in the NAT Gateway. This tells Terraform it must wait for the Internet Gateway to exist before creating the NAT Gateway. Terraform maps out a dependency graph for everything automatically, but occasionally explicit dependencies are helpful.
Step 3: Security Groups
In Part 1, we made sure the EC2 instance only accepted traffic from the ALB. Let's replicate that.
3.1 Firewall Rules (security_groups.tf)
# security_groups.tf
resource "aws_security_group" "alb" {
name = "${var.project_name}-sg-alb"
description = "Allow HTTP/HTTPS inbound to ALB"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
cidr_ipv4 = "0.0.0.0/0"
}
resource "aws_security_group" "ec2" {
name = "${var.project_name}-sg-ec2"
description = "Allow traffic from ALB and SSH"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "ec2_from_alb" {
security_group_id = aws_security_group.ec2.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
referenced_security_group_id = aws_security_group.alb.id
}
Look at referenced_security_group_id = aws_security_group.alb.id. This is the exact Terraform equivalent of selecting the ALB security group as the source in the AWS Console.
Step 4: Application Load Balancer
Our ALB configuration includes a powerful feature: conditional HTTPS.
4.1 ALB Configuration (alb.tf)
# alb.tf
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
}
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
matcher = "200-399"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = var.domain_name != "" ? "redirect" : "forward"
# Forward to EC2 if no domain is provided
dynamic "forward" {
for_each = var.domain_name == "" ? [1] : []
content {
target_group {
arn = aws_lb_target_group.app.arn
}
}
}
# Redirect to HTTPS if domain is provided
dynamic "redirect" {
for_each = var.domain_name != "" ? [1] : []
content {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
}
If you leave domain_name = "" in your variables, it provisions a standard HTTP load balancer. Once you are ready for production, you just update that variable to "yourdomain.com". Run terraform apply again, and Terraform will dynamically generate an ACM certificate and update the ALB to redirect HTTP to HTTPS.
Step 5: EC2 Instance and Bootstrap
In Part 1, we manually SSH'd into the instance to install Docker and Git. Let's automate that using an EC2 user_data script.
5.1 User Data Script (user_data.sh)
This script runs automatically the very first time the EC2 instance boots up.
#!/bin/bash
set -euo pipefail
exec > /var/log/user-data.log 2>&1
apt-get update -y && apt-get upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker ubuntu
# Install Git
apt-get install -y git
# Clone application repository
sudo -u ubuntu git clone "${app_repo_url}" /home/ubuntu/apartment-deployment
5.2 EC2 Configuration (ec2.tf)
# ec2.tf
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = var.key_pair_name
subnet_id = aws_subnet.private[0].id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = base64encode(templatefile("${path.module}/user_data.sh", {
app_repo_url = var.app_repo_url
}))
tags = { Name = "${var.project_name}-server" }
}
# Instance Connect Endpoint for secure, bastion-less SSH
resource "aws_ec2_instance_connect_endpoint" "main" {
subnet_id = aws_subnet.private[0].id
security_group_ids = [aws_security_group.ec2.id]
}
The data "aws_ami" block dynamically fetches the latest Ubuntu 24.04 AMI ID. You no longer have to hardcode AMI IDs that break when you switch AWS regions.
Step 6: Outputs and Deployment
6.1 Outputs Configuration (outputs.tf)
Outputs print useful information to your terminal after the deployment finishes.
# outputs.tf
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "ssh_command" {
description = "Command to SSH into the EC2 instance"
value = "aws ec2-instance-connect ssh --instance-id ${aws_instance.app.id} --os-user ubuntu"
}
6.2 Variable Assignment (terraform.tfvars)
Copy the example file to .tfvars (which is ignored by Git, keeping your secrets safe):
cp terraform.tfvars.example terraform.tfvars
nano terraform.tfvars
Update it with your values:
aws_region = "us-east-1"
key_pair_name = "apartment-key"
app_repo_url = "https://github.com/your-repo/apartment-deployment.git"
# domain_name = "yourdomain.com"
6.3 Deploying the Infrastructure
Initialize Terraform. This downloads the necessary AWS provider plugins.
terraform init
Run a plan to see exactly what Terraform is going to create.
terraform plan
You'll see a list of 31 resources that Terraform plans to create (VPC, Subnets, ALB, EC2, etc.).
Finally, apply the configuration:
terraform apply
Type yes when prompted.
Terraform will create everything in the right order. It automatically waits the 2 minutes required for the NAT Gateway to provision before attaching routes to it.
When it finishes, it will print your outputs:
Outputs:
alb_dns_name = "apartment-alb-xxxxxxxxxx.us-east-1.elb.amazonaws.com"
ssh_command = "aws ec2-instance-connect ssh --instance-id i-0123456789abcdef0 --os-user ubuntu"
Copy that SSH command, connect to your server, configure your .env files (just like we did in Part 1), and run docker compose up -d --build.
Everything we spent an entire article building manually was just created in about 2 minutes.
Step 7: The Cleanup — One Command
In Part 1, tearing down this infrastructure required manually deleting 12 different resources in exactly the right order, waiting for timeouts, and dealing with DependencyViolation errors.
Here is the Terraform version:
terraform destroy
Type yes.
Terraform looks at its state file, maps the reverse dependencies, and destroys everything flawlessly. It detaches the IGW before deleting the VPC. It waits for the NAT Gateway to delete before releasing the EIP.
Conclusion & Next Steps
This setup gets you a very solid production foundation. But cloud architecture never stops evolving. Our next steps for this platform would include:
Auto-Scaling (ASG): Moving from a single EC2 instance to an Auto Scaling Group behind the ALB to handle traffic spikes.
CI/CD Pipeline: Using GitHub Actions to automatically build the Docker images and trigger rolling updates on EC2 via AWS CodeDeploy.
If you found this helpful, drop a like! You can find the full source code (including all the Terraform files) on the terraform directory of Github.



Top comments (0)