Deploying a Scalable Web Application on AWS with Terraform
Introduction:
In today’s cloud-native world, automating infrastructure deployment is essential for scalability, reliability, and efficiency. Terraform, an Infrastructure as Code (IaC) tool, enables developers and DevOps engineers to define and manage AWS resources seamlessly. In this blog, we will walk through deploying a scalable web application using Terraform on AWS. We’ll provision a Virtual Private Cloud (VPC), subnets, an Application Load Balancer (ALB), security groups, and EC2 instances to ensure a highly available setup. By the end, you’ll have a running infrastructure that can handle traffic efficiently while leveraging Terraform’s automation capabilities.
- So, let’s start by configuring AWS credentials first. Also, make sure the latest version of Terraform is installed on your system.
- To configure AWS credentials in Visual Studio Code (VS Code), you can use the AWS Command Line Interface (CLI) command
aws configure
3. Enter Your Credentials:
You will be prompted to enter the following information:
- AWS Access Key ID: Your access key.
- AWS Secret Access Key: Your secret key.
- Default region name: The AWS region you want to use (e.g.,
us-east-1
). - Default output format: The format for command output (e.g.,
json
).
This command will create a configuration file at ~/.aws/config
and a credentials file at ~/.aws/credentials
, storing your AWS credentials securely.
aws configure list
4. Now that our AWS account is connected, let’s take a look at the Terraform file structure and how it is organized.
5. Now, I will explain why these files are imported, along with a simple code explanation in easy-to-understand language.
6. First, I will provide the full code, and then I will break it down for explanation. The main code will be highlighted in bold.
A. alb.tf
# Application Load Balancer
resource "aws_lb" "web" {
name = "web-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}
# Target Group
resource "aws_lb_target_group" "web" {
name = "web-target-group"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
healthy_threshold = 2
unhealthy_threshold = 10
}
}
# Target Group Attachment
resource "aws_lb_target_group_attachment" "web_1" {
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web_1.id
port = 80
}
resource "aws_lb_target_group_attachment" "web_2" {
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web_2.id
port = 80
}
# Listener
resource "aws_lb_listener" "web" {
load_balancer_arn = aws_lb.web.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
- > Code Explanation:
The alb.tf
file is responsible for setting up an Application Load Balancer (ALB) in AWS. An ALB helps distribute incoming traffic across multiple EC2 instances, ensuring high availability and fault tolerance. It also performs health checks to make sure the instances are running properly before sending traffic to them
Creating the Application Load Balancer (ALB)
resource "aws_lb" "web" {
name = "web-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}
- This block creates an ALB named
web-alb
. - It is public (
internal = false
), meaning it can be accessed from the internet. - The ALB is associated with security groups to control network access.
- It is deployed in two public subnets, so it can handle traffic in multiple availability zones.
Creating the Target Group
resource "aws_lb_target_group" "web" {
name = "web-target-group"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
healthy_threshold = 2
unhealthy_threshold = 10
}
}
- A target group is created, which defines where the ALB should send the traffic.
- It listens on port 80 (HTTP) and is linked to the VPC.
- The health check ensures that the instances are working correctly:
- If an instance responds successfully 2 times, it is marked healthy.
- If it fails 10 times, it is marked unhealthy
Attaching EC2 Instances to the Target Group
resource "aws_lb_target_group_attachment" "web_1" {
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web_1.id
port = 80
}
resource "aws_lb_target_group_attachment" "web_2" {
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.web_2.id
port = 80
}
- These blocks attach two EC2 instances (
web_1
andweb_2
) to the target group. - The instances will receive traffic from the load balancer on port 80
Creating the Listener
resource "aws_lb_listener" "web" {
load_balancer_arn = aws_lb.web.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
- The listener allows the ALB to receive traffic on port 80 (HTTP).
- It forwards all requests to the target group, which then directs them to the EC2 instances.
B. instances.tf
# EC2 Instance 1
resource "aws_instance" "web_1" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
subnet_id = aws_subnet.public_1.id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from EC2 Instance 1</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "WebServer-1"
}
}
# EC2 Instance 2
resource "aws_instance" "web_2" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
subnet_id = aws_subnet.public_2.id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from EC2 Instance 2</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "WebServer-2"
}
}
- > Code Explanation:
The instances.tf
file provisions two EC2 instances in different public subnets, ensuring high availability and scalability for the web application.
- EC2 Instances:
web_1
andweb_2
are deployed in separate subnets for redundancy. - AMI & Instance Type: Uses Amazon Linux 2 and an instance type defined by a variable (
var.instance_type
). - Security Group: Both instances use the same security group (
aws_security_group.ec2.id
) for controlled access. - User Data Script: Installs and starts Apache (httpd), serves an HTML page (
index.html
), and enables the service on boot. - Tags: Assigns unique names (
WebServer-1
andWebServer-2
) for easy identification.
This setup ensures that the Application Load Balancer (ALB) can distribute traffic efficiently, making the application highly available and fault-tolerant.
C. main.tf
provider "aws" {
region = var.aws_region
}
# Get latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main"
}
}
# Public Subnets
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "Public Subnet 1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}b"
map_public_ip_on_launch = true
tags = {
Name = "Public Subnet 2"
}
}
# Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "Public Route Table"
}
}
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
- > Code Explanation:
Provider Block:
provider "aws" {
region = var.aws_region
}
This block tells Terraform which cloud provider to use. In this case, it’s AWS, and it specifies the region where the resources will be created (from a variable aws_region
).
Get Latest Amazon Linux 2 AMI:
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
This block fetches the latest Amazon Linux 2 AMI (Amazon Machine Image), which is a pre-configured OS image used to launch EC2 instances. The most_recent
flag ensures you get the latest available version.
VPC (Virtual Private Cloud):
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main"
}
}
This creates a VPC, which is a logically isolated network in AWS. The cidr_block
is a range of IP addresses for the VPC (specified via vpc_cidr
). The enable_dns_hostnames
and enable_dns_support
flags allow DNS resolution within the VPC.
Internet Gateway:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main"
}
}
The internet gateway is attached to the VPC, allowing resources within the VPC (e.g., EC2 instances) to access the internet.
Public Subnets:
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "Public Subnet 1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}b"
map_public_ip_on_launch = true
tags = {
Name = "Public Subnet 2"
}
}
These blocks create two public subnets in the VPC, each in different availability zones. Public subnets are where resources like EC2 instances with public IPs will be launched. The map_public_ip_on_launch = true
flag ensures that any EC2 instance launched in these subnets will automatically get a public IP.
Route Table:
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "Public Route Table"
}
}
This creates a route table for the public subnets. The route allows traffic to flow to the internet (0.0.0.0/0
is a default route) through the internet gateway.
Route Table Associations:
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
These blocks associate the route table with the two public subnets. This ensures that any traffic from these subnets will follow the defined routes, allowing them to access the internet.
D. outputs.tf
output "alb_dns_name" {
value = aws_lb.web.dns_name
description = "The DNS name of the load balancer"
}
- > Code Explanation:
output
: This block is used to define output variables in Terraform. These variables are values that Terraform will display after the apply process finishes. Outputs are helpful to see important information or to pass data to other configurations or scripts."alb_dns_name"
: This is the name of the output variable. You can use this name to reference the output in other parts of your Terraform configuration, or simply use it to display the DNS name.value = aws_lb.web.dns_name
: This specifies that the value of the output variable is the DNS name of the load balancer.aws_lb.web.dns_name
is the DNS name attribute of the AWS Application Load Balancer resource (which is assumed to be namedweb
in this case). When Terraform runs, it will fetch the DNS name of theweb
load balancer and assign it to the output variable.description = "The DNS name of the load balancer"
: This is a human-readable description that helps explain what the output variable represents. It will appear in the Terraform output along with the actual value when the infrastructure is applied
E. security.tf
# Security Group for EC2 instances
resource "aws_security_group" "ec2" {
name = "ec2-security-group"
description = "Security group for EC2 instances"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Security Group for ALB
resource "aws_security_group" "alb" {
name = "alb-security-group"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
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"]
}
}
-> Code Explanation
Security Group for EC2 Instances (aws_security_group.ec2
)
resource "aws_security_group" "ec2" {
name = "ec2-security-group"
description = "Security group for EC2 instances"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
- Name and Description: The security group is named
"ec2-security-group"
, and the description is"Security group for EC2 instances"
. - VPC Association: This security group is associated with the VPC created earlier (
aws_vpc.main.id
). - Ingress Rule:
- From Port: 80 (HTTP)
- To Port: 80 (HTTP)
- Protocol: TCP
- Security Groups: Only allows inbound traffic from instances that belong to the security group
aws_security_group.alb
. This means the EC2 instances can only receive traffic on port 80 from the ALB. - Egress Rule:
- From Port: 0
- To Port: 0
- Protocol:
"-1"
(which means any protocol) - CIDR Blocks: Allows all outbound traffic (
0.0.0.0/0
), meaning the EC2 instances can connect to any destination on the internet.
Security Group for ALB (aws_security_group.alb
)
resource "aws_security_group" "alb" {
name = "alb-security-group"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
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"]
}
}
- Name and Description: The security group is named
"alb-security-group"
, and the description is"Security group for Application Load Balancer"
. - VPC Association: This security group is also associated with the same VPC as the EC2 instances (
aws_vpc.main.id
). - Ingress Rule:
- From Port: 80 (HTTP)
- To Port: 80 (HTTP)
- Protocol: TCP
- CIDR Blocks: Allows inbound HTTP traffic from any source (
0.0.0.0/0
). This means the ALB can accept HTTP traffic from anywhere. - Egress Rule:
- From Port: 0
- To Port: 0
- Protocol:
"-1"
(any protocol) - CIDR Blocks: Allows all outbound traffic (
0.0.0.0/0
), meaning the ALB can communicate with any destination.
F. variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "instance_type" {
default = "t2.micro"
}
variable "vpc_cidr" {
default = "10.0.0.0/16"
}
- > Code Explanation
- These variables provide flexibility and allow for easy customization of the Terraform configuration. You can override these default values by specifying them in a
terraform.tfvars
file or by passing them as command-line inputs during the Terraform plan or apply process. - The
aws_region
sets the region for AWS resources. - The
instance_type
defines the type of EC2 instances to be created. - The
vpc_cidr
specifies the IP range for the VPC.
7. So here are all the code files and their explanations. I hope you understand, and now let’s move on to running the Terraform init
, plan
, and apply
commands to see whether it's working or not.
8. We initialize Terraform and run the terraform plan
command to observe what kind of resources will be created. Finally, we run the terraform apply
command and wait until you see the ELB URL. Wait for the resources to be fully applied during this time.
9. Before accessing the URL, log in to your AWS account to check whether the resources have been created. Specifically, verify the ELB to ensure it is healthy. Only then will you be able to access the URL.
10. Yes, our ELB is healthy now. I am accessing the ELB URL to check if it works. I will also keep refreshing the page continuously so that I can see responses from both servers.
Conclusion:
By using Terraform, we successfully automated the provisioning of a scalable AWS infrastructure, including networking components, security groups, and EC2 instances behind an Application Load Balancer. This approach not only simplifies deployment but also ensures consistency and repeatability across environments. With Terraform, managing cloud resources becomes efficient, reducing manual effort and the risk of configuration errors. You can now extend this setup by integrating monitoring, auto-scaling, or containerized applications for even greater flexibility.