3-Tier E-Commerce Infrastructure
Deploy a scalable 3-tier AWS infrastructure with Terraform and CloudWatch.
Introduction
30 Second Summary
Online stores rely on multiple layers of infrastructure working together. Each layer handles a distinct job, from accepting connections to processing requests to storing product data.
In this project, you will use Terraform to deploy a 3-tier AWS infrastructure for an e-commerce product catalog backend. Your infrastructure routes internet traffic through an Application Load Balancer to private EC2 instances that connect to an RDS MySQL database with zero internet access.
What You'll Build
A production-pattern 3-tier e-commerce infrastructure on AWS, deployed and monitored entirely with Terraform.
By the end of this project, you'll have:
- A fully provisioned VPC with 6 subnets across 2 Availability Zones, separating public, private, and isolated network tiers.
- Secure tier-to-tier traffic flow from an Application Load Balancer through EC2 instances to an isolated RDS database, enforced by security group chaining.
- CloudWatch alarms monitoring all three tiers with SNS email notifications.
- Secret Mission: Replace the single EC2 instance with an Auto Scaling Group for automatic horizontal scaling.
Are there any prerequisites?
This project assumes basic Terraform knowledge, including commands like terraform init, terraform plan, terraform apply, and terraform destroy. You also need an AWS account with the AWS CLI configured.
Before We Start
Before We Start...
Set Up Your Terraform Project Structure
A well-organized Terraform project splits each concern into its own file. Instead of cramming everything into a single main.tf, you create a dedicated file for networking, security, compute, and so on.
In this step, you'll create a project folder with nine Terraform files. You'll configure the AWS provider, define the variables every other file will reference, and run terraform init to download the provider plugin.
In this step, get ready to:
- Create the project directory and Terraform file structure.
- Configure the AWS provider and define project variables.
- Initialize Terraform and download the AWS provider plugin.
Create the project directory and file structure
Each .tf file you create maps to one layer of the 3-tier architecture. This keeps each file short and makes it easy for a teammate to find the resource they need.
- Open your terminal.
How do I open a terminal?
Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or PowerShell (Windows) and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ecommerce-3tier
cd ecommerce-3tier
- Create the nine Terraform files by running this command:
touch providers.tf variables.tf outputs.tf networking.tf security.tf alb.tf compute.tf database.tf monitoring.tf
- Confirm the files exist by running:
ls
You should see all nine .tf files listed: alb.tf, compute.tf, database.tf, monitoring.tf, networking.tf, outputs.tf, providers.tf, security.tf, and variables.tf.
Command not found: touch?
If you are on Windows and see this error, open Git Bash instead of PowerShell. Git Bash supports Unix commands like touch. Alternatively, you can create the files manually in VS Code in the next bullet.
Help me create Terraform files on Windows
Why one file per concern?
Terraform treats all .tf files in a directory as one configuration. Splitting by concern is purely for human readability. When a teammate needs to adjust a security group, they go straight to security.tf instead of scrolling through a 500-line monolith.
- Now open the project in your text editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the ecommerce-3tier folder on your Desktop, then click Open.
You should see all nine .tf files listed in the Explorer sidebar on the left.
Configure the AWS provider and project variables
Two files need content before you can initialize the project. providers.tf tells Terraform which cloud provider to use and which version to download. variables.tf defines the inputs that every other file can reference.
- Open providers.tf in the Explorer sidebar and paste the following code:
# Pin Terraform CLI and AWS provider versions
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# Deploy resources into the region set by the aws_region variable
provider "aws" {
region = var.aws_region
}
What does this code do?
- required_version = ">= 1.5.0" ensures anyone running this project has Terraform 1.5.0 or newer.
- version = "~> 6.0" locks the AWS provider to the 6.x line. Minor updates are picked up automatically, but breaking major-version changes are blocked.
- var.aws_region reads the region from a variable you will define next in variables.tf.
- Save providers.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Now you need to define the variables that providers.tf (and every file after it) will reference.
- Open variables.tf and paste the following code:
# General project settings
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "ecommerce-3tier"
}
# Network configuration
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
# Database credentials
variable "db_username" {
description = "Master username for the RDS instance"
type = string
default = "admin"
}
# Marked sensitive so Terraform hides the value in plan/apply output
variable "db_password" {
description = "Master password for the RDS instance"
type = string
sensitive = true
}
# Monitoring notifications
variable "alert_email" {
description = "Email address for CloudWatch alarm notifications"
type = string
}
What does this code do?
- aws_region defaults to us-east-1. Every resource you create lands in this region.
- project_name is a naming prefix so all resources are easy to identify in the AWS console.
- vpc_cidr sets the IP range for your entire VPC. 10.0.0.0/16 gives you 65,536 addresses to carve into subnets later.
- db_password is marked sensitive = true, so Terraform never prints it in plan or apply output.
- alert_email and db_password have no default. Terraform will prompt you for these values when you run apply.
- Save variables.tf.
Seeing red squiggles in VS Code?
If your editor highlights syntax errors, make sure every opening brace { has a matching closing brace } and every string value is wrapped in double quotes.
Help me fix syntax errors in my Terraform variables file
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "ecommerce-3tier"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "db_username" {
description = "Master username for the RDS instance"
type = string
default = "admin"
}
variable "db_password" {
description = "Master password for the RDS instance"
type = string
sensitive = true
}
variable "alert_email" {
description = "Email address for CloudWatch alarm notifications"
type = string
}
Initialize Terraform and verify the provider
Running terraform init reads your provider block and downloads the matching plugin. It also creates a .terraform directory and a lock file that pins the exact provider version for your project.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Make sure the terminal is in your ecommerce-3tier folder. If VS Code opened the folder correctly, it will be there by default.
- Initialize Terraform by running this command:
terraform init
You should see output ending with Terraform has been successfully initialized! along with a line showing it installed the hashicorp/aws provider at a version in the 6.x range.
What just happened?
Terraform downloaded the AWS provider plugin into a hidden .terraform directory inside your project folder. It also created .terraform.lock.hcl, which records the exact provider version so that every team member uses the same one.
Seeing a provider error?
If you see Failed to query available provider packages, check that you have an active internet connection. Terraform needs to reach the Terraform Registry to download the AWS provider.
If you see Error: Inconsistent dependency lock file, delete the .terraform directory and .terraform.lock.hcl file, then run terraform init again.
Help me fix a terraform init error
Your project skeleton is in place and the AWS provider is ready. Next up, you'll fill in networking.tf to build the VPC and subnet foundation that the entire 3-tier architecture sits on.
Build the VPC and Networking Foundation
Your Terraform project is initialized and the provider is ready. The next job is creating the networking foundation that every other resource in this project depends on.
A 3-tier architecture separates resources into three network layers with different levels of internet access. Public subnets face the internet, private subnets can reach the internet outbound through a gateway, and isolated subnets have zero internet connectivity.
In this step, get ready to:
- Create a custom VPC with six subnets spread across two Availability Zones.
- Configure gateways and route tables that control traffic flow for each tier.
- Run terraform plan to verify the networking resources before deploying.
Create the VPC, subnets, and Internet Gateway
Every AWS resource in this project lives inside a VPC. You need DNS support so resources can resolve each other by hostname, subnets in multiple Availability Zones for high availability, and an Internet Gateway so the public tier can communicate with the internet.
- Open networking.tf in your text editor and add the following code to create the VPC, six subnets, and an Internet Gateway:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Public subnets (ALB)
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
}
}
# Private subnets (App tier)
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
}
}
# Isolated subnets (Database)
resource "aws_subnet" "isolated" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-isolated-${count.index + 1}"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
What does this code do?
- The data "aws_availability_zones" block looks up which Availability Zones are available in your region. This means the code works in any region without hardcoding zone names.
- The VPC uses your vpc_cidr variable (10.0.0.0/16) and enables DNS so resources can find each other by hostname.
- cidrsubnet(var.vpc_cidr, 8, count.index) splits the /16 block into smaller /24 subnets. Public subnets get 10.0.0.0/24 and 10.0.1.0/24, private subnets get 10.0.10.0/24 and 10.0.11.0/24, and isolated subnets get 10.0.20.0/24 and 10.0.21.0/24.
- map_public_ip_on_launch = true on the public subnets gives resources a public IP automatically. Private and isolated subnets skip this so their resources stay hidden.
- The Internet Gateway attaches to the VPC and enables internet access for the public tier.
- Save networking.tf.
- You won't see a change yet. The next chunk adds the NAT Gateway and route tables, then you'll verify everything with terraform plan.
Set up the NAT Gateway and route tables
Subnets alone don't control where traffic goes. Route tables define where each subnet sends outbound traffic. Public subnets route to the Internet Gateway, private subnets route through a NAT Gateway for outbound-only access, and isolated subnets have no internet route at all.
- Still in networking.tf, add the following code below the Internet Gateway block:
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
# NAT Gateway
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.project_name}-nat-gw"
}
depends_on = [aws_internet_gateway.main]
}
# Public 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 = "${var.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route table
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-private-rt"
}
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
# Isolated route table (no internet route)
resource "aws_route_table" "isolated" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-isolated-rt"
}
}
resource "aws_route_table_association" "isolated" {
count = 2
subnet_id = aws_subnet.isolated[count.index].id
route_table_id = aws_route_table.isolated.id
}
What does this code do?
- The Elastic IP gives the NAT Gateway a fixed public IP address. The NAT Gateway sits in a public subnet and lets resources in private subnets make outbound internet requests (like downloading packages) without being directly reachable from the internet.
- depends_on = [aws_internet_gateway.main] tells Terraform to create the Internet Gateway before the NAT Gateway. The NAT Gateway needs internet access to function, so the IGW must exist first.
- The public route table sends all outbound traffic (0.0.0.0/0) to the Internet Gateway. The private route table sends it to the NAT Gateway instead. The isolated route table has no route at all, which completely blocks internet access.
- Each aws_route_table_association links a route table to its subnets. Without these associations, subnets use the VPC's default route table and your traffic rules would have no effect.
- Save networking.tf.
Why a single NAT Gateway?
In production, you would place a NAT Gateway in each Availability Zone for redundancy. For this learning project, a single NAT Gateway keeps costs low. It costs about $0.045 per hour, and that concept transfers directly when you scale up later.
✔️ Awesome, I've got everything!
Double check you've saved networking.tf before running the plan in the next substep.
ⓧ I'd like to double check the full code
Here is the complete networking.tf file. Compare it against yours and fix any differences.
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Public subnets (ALB)
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
}
}
# Private subnets (App tier)
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-${count.index + 1}"
}
}
# Isolated subnets (Database)
resource "aws_subnet" "isolated" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-isolated-${count.index + 1}"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
# NAT Gateway
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.project_name}-nat-gw"
}
depends_on = [aws_internet_gateway.main]
}
# Public 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 = "${var.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route table
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-private-rt"
}
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
# Isolated route table (no internet route)
resource "aws_route_table" "isolated" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-isolated-rt"
}
}
resource "aws_route_table_association" "isolated" {
count = 2
subnet_id = aws_subnet.isolated[count.index].id
route_table_id = aws_route_table.isolated.id
}
Preview the networking resources
Before deploying anything, run terraform plan to see exactly what Terraform will create. This lets you catch mistakes before any resources are provisioned.
Why pass -var flags?
The db_password and alert_email variables have no default values. Terraform will block the plan unless you provide them. The values below are temporary placeholders just for previewing the plan. You will set real values when you run terraform apply later.
- Preview the networking resources by running this command:
terraform plan -var="db_password=TempPass123" -var="alert_email=test@example.com"
Terraform outputs a summary of every resource it will create. Look for a line near the bottom that says something like Plan: 21 to add, 0 to change, 0 to destroy.
What should I see in the plan?
Scroll through the plan output and confirm you see these resource types:
- aws_vpc.main (1 VPC).
- aws_subnet.public[0], aws_subnet.public[1], aws_subnet.private[0], aws_subnet.private[1], aws_subnet.isolated[0], aws_subnet.isolated[1] (6 subnets).
- aws_internet_gateway.main, aws_eip.nat, and aws_nat_gateway.main (gateways).
- 3 route tables and 6 route table associations.
The exact count may vary slightly depending on your Terraform and provider version. The key thing is that you see all the networking resource types listed above with no errors.
Seeing an error in the plan?
- If you see Error: Reference to undeclared resource, check that resource names in your code match exactly. For example, aws_vpc.main must be spelled the same everywhere it appears.
- If you see Error: Missing required argument, compare your code against the double-check tab above. A missing field like vpc_id or cidr_block is the most common cause.
- If you see Error: No valid credential sources found, your AWS CLI is not configured. Run aws configure to set up your credentials.
Help me debug my terraform plan error
Your networking foundation is in place. You have a VPC with six subnets, gateways for controlled internet access, and route tables that enforce the traffic rules for each tier. Next up, you'll lock down communication between tiers with security groups.
Define Security Groups with Tier-to-Tier Chaining
Your VPC now has six subnets, an Internet Gateway, a NAT Gateway, and route tables that control where traffic can flow.
Subnets and route tables control network-level access, but they don't control which specific resources can talk to each other. That's the job of security groups. In this step, you'll create three security groups that chain together, so each tier only accepts traffic from the tier directly above it.
In this step, get ready to:
- Create three security groups in security.tf with strict inbound and outbound rules.
- Chain each security group so it only accepts traffic from its adjacent tier.
- Run terraform plan to verify the new resources.
Create the ALB security group
The Application Load Balancer is the entry point for all internet traffic. Its security group needs to accept HTTP requests from anywhere on the internet and forward them onward.
- Open security.tf in your editor. This file is currently empty.
- Add the ALB security group by pasting the following code:
# ALB Security Group
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Allow HTTP from internet"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
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 = "${var.project_name}-alb-sg"
}
}
What does this code do?
- The ingress block opens port 80 (HTTP) to all IP addresses (0.0.0.0/0). This is the only security group that allows traffic from the open internet.
- The egress block allows all outbound traffic (protocol = "-1" means all protocols). The ALB needs this to forward requests to the app tier.
- vpc_id = aws_vpc.main.id attaches this security group to the VPC you created in the previous step.
You won't see a change yet. The next two security groups complete the chain.
Create the app tier and database security groups
The app tier should only receive traffic from the ALB. The database should only receive traffic from the app tier. Each security group references the one above it by ID. This is the security group chaining pattern.
- In security.tf, add the app tier security group below the ALB security group you just created:
# App Tier Security Group
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Allow HTTP from ALB only"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
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"]
}
tags = {
Name = "${var.project_name}-app-sg"
}
}
What's different about this security group?
Instead of cidr_blocks (IP ranges), the ingress rule uses security_groups = [aws_security_group.alb.id]. This means only resources attached to the ALB security group can send traffic to port 80 on these instances.
The egress rule allows all outbound traffic. The app tier EC2 instances need outbound access so they can reach the NAT Gateway to download software packages.
- Add the database security group below the app tier security group:
# Database Security Group
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Allow MySQL from app tier only"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from app tier"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-db-sg"
}
}
What does this code do?
- The ingress rule opens port 3306 (the default MySQL port) only to resources in the app tier security group.
- No one on the internet, and no resource in the ALB tier, can reach the database directly. Only the app tier EC2 instances can.
What is security group chaining?
Each security group references the one in the tier above it: the app SG references the ALB SG, and the database SG references the app SG. Traffic can only flow from one tier to the next. A request from the internet can never skip the ALB and reach the database directly.
If you ever need to add a new service to the app tier, you attach the app security group to it and it automatically gains the right access. No IP addresses to manage.
- Save security.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete security.tf file. Make sure yours matches exactly.
# ALB Security Group
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Allow HTTP from internet"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
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 = "${var.project_name}-alb-sg"
}
}
# App Tier Security Group
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Allow HTTP from ALB only"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
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"]
}
tags = {
Name = "${var.project_name}-app-sg"
}
}
# Database Security Group
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Allow MySQL from app tier only"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from app tier"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-db-sg"
}
}
Preview the security groups with terraform plan
Time to verify that Terraform recognizes the three new security groups alongside the networking resources you created earlier.
- Run terraform plan to preview all planned resources:
terraform plan
Terraform will prompt you for the db_password and alert_email variables since they have no defaults. Enter any placeholder value for now (e.g. tempPass123 and you@example.com). You'll set real values when you apply later.
- Look at the plan summary at the bottom of the output. You should see the three new security groups listed alongside the networking resources from Step 2.
What should I see?
The plan output should include aws_security_group.alb, aws_security_group.app, and aws_security_group.db in addition to all the VPC, subnet, route table, and gateway resources from before.
Notice how the app SG's ingress references aws_security_group.alb.id, and the database SG's ingress references aws_security_group.app.id. Terraform resolves these at apply time, creating the groups in dependency order.
Seeing an error in the plan?
- If you see a reference error mentioning aws_vpc.main, make sure your networking.tf file is saved and still contains the VPC resource.
- If you see a reference error mentioning aws_security_group.alb or aws_security_group.app, check that all three security groups are in security.tf and that the resource names match exactly ("alb", "app", "db").
- If you see a syntax error, check for missing closing braces. Each security group resource has nested ingress and egress blocks, so count your braces carefully.
Help me debug my terraform plan error for security groups.
Your security layer is locked down. Each tier can only talk to its neighbor, and the database is completely sealed off from the internet. Next up, you'll deploy the Application Load Balancer and the EC2 instance that serves your product catalog.
Deploy the Application Load Balancer and App Tier
Your security groups are locked down and enforcing strict tier-to-tier access. Now you need the resources those security groups are protecting.
In this step, you will create an Application Load Balancer to receive internet traffic and an EC2 instance running a simple web server to handle requests. By the end, you will run terraform apply for the first time and see real AWS resources come to life.
In this step, get ready to:
- Create the ALB, target group, and listener in alb.tf.
- Deploy an EC2 instance running a web server in compute.tf.
- Wire the instance to the ALB and run terraform apply to provision everything.
Create the load balancer and routing rules
An Application Load Balancer sits in your public subnets and is the only entry point for internet traffic into your infrastructure. It decides which backend instance should handle each request.
- Open alb.tf in your editor and add the ALB resource by pasting the following code:
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
tags = {
Name = "${var.project_name}-alb"
}
}
What does this code do?
internal = false makes this an internet-facing load balancer, meaning it gets a public DNS name that anyone can reach.
security_groups attaches the ALB security group you created in the previous step, which only allows inbound HTTP on port 80. subnets places the ALB across both public subnets for high availability.
The ALB needs to know where to send incoming traffic. A target group defines the destination, and a listener tells the ALB which port to watch.
- Add the target group and listener below the ALB resource in alb.tf by pasting this code:
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-app-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
tags = {
Name = "${var.project_name}-app-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
What does this code do?
The target group defines where the ALB sends traffic. The health_check block tells the ALB to ping / every 30 seconds and expect a 200 response. If 3 consecutive checks fail, the target is marked unhealthy.
The listener watches port 80 for incoming HTTP requests and forwards them to the target group.
- Save alb.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved alb.tf before moving on.
ⓧ I'd like to double check the full code
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
tags = {
Name = "${var.project_name}-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-app-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
tags = {
Name = "${var.project_name}-app-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Deploy the EC2 app instance
The ALB now knows how to route traffic, but there is nothing running behind it yet. You need an EC2 instance in a private subnet that runs a web server to respond to the ALB's health checks.
First, Terraform needs to know which AMI (Amazon Machine Image) to use for the instance. An AMI is the operating system template your instance boots from.
- Open compute.tf and add the AMI data source by pasting the following code:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
What does this code do?
This is a data source, not a resource. It queries AWS to find the latest Amazon Linux 2023 AMI without hardcoding an AMI ID that could go stale.
The filters narrow the search to 64-bit HVM images published by Amazon. most_recent = true ensures you always get the newest patch.
Now you can create the EC2 instance itself. This instance sits in a private subnet, meaning it has no public IP address. The only way traffic reaches it is through the ALB.
- Add the EC2 instance resource below the data source in compute.tf by pasting this code:
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.private[0].id
vpc_security_group_ids = [aws_security_group.app.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
HOSTNAME=$(hostname)
cat > /var/www/html/index.html <<HTML
<!DOCTYPE html>
<html>
<head><title>Product Catalog API</title></head>
<body>
<h1>Product Catalog API</h1>
<p>Status: Running</p>
<p>Served from: $HOSTNAME</p>
<p>Database endpoint: configured</p>
</body>
</html>
HTML
systemctl start httpd
systemctl enable httpd
EOF
tags = {
Name = "${var.project_name}-app-instance"
}
}
What does this code do?
subnet_id = aws_subnet.private[0].id places the instance in the first private subnet. The app security group only allows inbound traffic from the ALB security group, so no direct internet access is possible.
The user_data block is a bootstrap script that runs when the instance first launches. It installs Apache (httpd), creates a simple HTML page with the instance hostname, and starts the web server. This gives the ALB something to health check against.
- Save compute.tf.
Why does the EC2 instance need a NAT Gateway?
The user_data script runs yum update and yum install, which need internet access to download packages. Since the instance is in a private subnet with no public IP, outbound traffic routes through the NAT Gateway you created in the networking step.
✔️ Awesome, I've got everything!
Great. Double check you have saved compute.tf before moving on.
ⓧ I'd like to double check the full code
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.private[0].id
vpc_security_group_ids = [aws_security_group.app.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
HOSTNAME=$(hostname)
cat > /var/www/html/index.html <<HTML
<!DOCTYPE html>
<html>
<head><title>Product Catalog API</title></head>
<body>
<h1>Product Catalog API</h1>
<p>Status: Running</p>
<p>Served from: $HOSTNAME</p>
<p>Database endpoint: configured</p>
</body>
</html>
HTML
systemctl start httpd
systemctl enable httpd
EOF
tags = {
Name = "${var.project_name}-app-instance"
}
}
resource "aws_lb_target_group_attachment" "app" {
target_group_arn = aws_lb_target_group.app.arn
target_id = aws_instance.app.id
port = 80
}
Register the instance and deploy
The ALB and the EC2 instance exist in separate files. You need one more resource to connect them, plus a Terraform output so you can easily grab the ALB's public URL after deployment.
- Add the target group attachment at the bottom of compute.tf by pasting this code:
resource "aws_lb_target_group_attachment" "app" {
target_group_arn = aws_lb_target_group.app.arn
target_id = aws_instance.app.id
port = 80
}
What does this code do?
This resource registers the EC2 instance as a target in the ALB's target group. Without it, the ALB would have nowhere to send traffic.
- Save compute.tf.
- Open outputs.tf and add the ALB DNS output by pasting this code:
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
What does this output do?
After terraform apply finishes, Terraform prints all outputs to your terminal. This gives you the ALB's public DNS name so you can paste it into a browser and verify your infrastructure works.
- Save outputs.tf.
Everything is wired up. Time to deploy all your resources for the first time.
What will Terraform create?
This is your first terraform apply, so Terraform will create everything you have defined so far: the VPC, 6 subnets, Internet Gateway, NAT Gateway, Elastic IP, route tables, 3 security groups, the ALB, target group, listener, EC2 instance, and target group attachment.
- Deploy your infrastructure by running this command:
terraform apply -var="db_password=your-db-password-here" -var="alert_email=[[ALERT_EMAIL="your email address"]]"
Why do I need to pass these variables?
Your variables.tf declares db_password and alert_email without default values. Terraform requires a value for every declared variable, even if the resources using them have not been added yet.
Use any password you like for db_password (at least 8 characters). Use your real email for alert_email. You will use both again in later steps.
- Terraform shows a plan of all the resources it will create. Type yes and press Enter to confirm.
The apply takes 2-3 minutes. The NAT Gateway and ALB are the slowest resources to provision.
- Once the apply completes, look for the alb_dns_name output in your terminal. It looks like ecommerce-3tier-alb-XXXXXXXXX.us-east-1.elb.amazonaws.com.
- Copy the ALB DNS name and paste it into your browser's address bar.
Give the instance a moment to boot
Even after terraform apply finishes, the EC2 instance still needs 1-2 minutes to run its user_data script, install Apache, and pass the ALB health check. If you see a 503 Service Temporarily Unavailable error, wait a minute and refresh.
You should see a page displaying Product Catalog API with a status of Running and a hostname. This confirms traffic is flowing from the internet, through the ALB, into your private subnet, and reaching the EC2 instance.
Still seeing a 503 error after a few minutes?
- Check that the security group for the app tier allows inbound HTTP on port 80 from the ALB security group. If you accidentally used a CIDR block instead of a security group reference, the ALB cannot reach the instance.
- Verify the EC2 instance is in a private subnet that routes through the NAT Gateway. Without outbound internet access, yum install httpd in the user_data script fails silently.
- Make sure the target group health check path is / and the matcher is 200. A mismatch means the ALB thinks the instance is unhealthy and stops routing traffic.
Help me debug why my ALB is returning 503 errors.
Your web tier and app tier are live and serving traffic. Next up, you will add the database tier to complete the 3-tier architecture.
Add the Database Tier and Verify End-to-End
Your ALB and EC2 app tier are live, but the architecture is only two-thirds complete. The third tier, the database, stores the data your application depends on.
In this step, you'll create an RDS MySQL instance inside the isolated subnets that have zero internet access. Then you'll verify end-to-end traffic by hitting the ALB in your browser.
In this step, get ready to:
- Create an RDS MySQL database in isolated subnets with no internet access.
- Verify end-to-end traffic from the internet through the ALB to the EC2 instance.
- Add a Terraform output for the database endpoint.
Create the database subnet group
RDS needs to know which subnets it can deploy into. A DB subnet group bundles your isolated subnets together so RDS can place the database in either Availability Zone.
- In database.tf, add the DB subnet group resource by copying this code:
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = aws_subnet.isolated[*].id
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
What does this code do?
The aws_db_subnet_group resource groups your two isolated subnets into a single unit that RDS can reference. The isolated[*] splat expression passes both isolated subnets at once.
RDS requires subnets in at least two Availability Zones for high availability, even for a single-AZ deployment.
You won't see a change yet. The next chunk uses this subnet group for the RDS instance.
Create the RDS MySQL instance
The RDS instance is the actual MySQL database that the app tier will connect to. It sits in the isolated subnets with the database security group, so only the app tier can reach it on port 3306.
- Below the subnet group in database.tf, add the RDS instance resource by copying this code:
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
allocated_storage = 20
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
db_name = "ecommerce"
username = var.db_username
password = var.db_password
parameter_group_name = "default.mysql8.0"
skip_final_snapshot = true
publicly_accessible = false
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
tags = {
Name = "${var.project_name}-db"
}
}
What does this code do?
- engine = "mysql" and engine_version = "8.0" specify a MySQL 8.0 database with 20 GB of storage on a db.t3.micro instance.
- username and password pull from the variables you defined earlier. The password is marked sensitive, so Terraform hides it from plan and apply output.
- publicly_accessible = false ensures the database has no public IP. Only resources in the VPC can reach it.
- skip_final_snapshot = true lets Terraform destroy the database later without creating a final backup snapshot. This is fine for a learning project.
- vpc_security_group_ids references the database security group, which only allows inbound traffic from the app tier security group on port 3306.
- Save database.tf.
You won't see a change yet. You'll deploy this database in the next substep.
✔️ Awesome, I've got everything!
Double check you've saved database.tf.
ⓧ I'd like to double check the full code
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = aws_subnet.isolated[*].id
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
allocated_storage = 20
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
db_name = "ecommerce"
username = var.db_username
password = var.db_password
parameter_group_name = "default.mysql8.0"
skip_final_snapshot = true
publicly_accessible = false
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
tags = {
Name = "${var.project_name}-db"
}
}
Deploy the database with Terraform
RDS instances take longer to provision than most AWS resources. Expect this apply to take about 5-8 minutes while AWS sets up the MySQL database.
- Deploy the database by running this command:
terraform apply
- Terraform prompts you for variable values. Enter your database password when prompted for var.db_password. Choose something simple for this learning project (e.g. MyPassword123).
- Enter your email address when prompted for var.alert_email. You'll use this in the next step for alarm notifications.
- Review the plan and type yes to confirm.
What should I see?
Terraform shows 2 resources to add: the DB subnet group and the RDS instance. The plan output shows the database configuration including engine mysql, instance class db.t3.micro, and publicly_accessible = false. The password value shows as (sensitive value) because you marked it sensitive in variables.tf.
Use the wait time wisely
While the RDS instance provisions, trace how each security group chains to the next. The ALB security group allows HTTP from the internet. The app security group allows HTTP only from the ALB security group. The database security group allows MySQL only from the app security group.
No tier can bypass its neighbor. That is the security group chaining pattern in action.
- Wait for the apply to complete. You should see Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Seeing an error during apply?
- If you see InvalidParameterValue for the password, make sure your password is at least 8 characters long. RDS requires a minimum length.
- If you see a subnet group error, check that your aws_db_subnet_group references aws_subnet.isolated[*].id (not private or public).
- Having trouble with a different error? Help me debug my terraform apply error for the RDS instance.
Verify end-to-end traffic and add the RDS output
All three tiers are now deployed. Time to prove that traffic flows from the internet through the ALB to the private EC2 instance.
- Look at the Terraform output in your terminal. Find the alb_dns_name value and copy it.
- Open a new browser tab and paste the ALB DNS name into the address bar.
- Press Enter to load the page.
You should see the Product Catalog API page with a status of Running and a hostname. This confirms that internet traffic is flowing through the public ALB to the EC2 instance in a private subnet.
Page not loading?
- Make sure you are using http:// and not https://. The ALB listener only handles HTTP on port 80.
- If the page times out, the EC2 instance may still be starting up. Wait 2-3 minutes and refresh.
- Check the ALB target group health in the AWS Console under EC2 > Target Groups. The target should show as healthy.
- Still stuck? Help me debug why my ALB page is not loading.
Now add a Terraform output to confirm the RDS endpoint was created in the isolated subnet.
- Open outputs.tf and find the existing alb_dns_name output block.
- Below it, add the RDS endpoint output by copying this code:
output "rds_endpoint" {
description = "Endpoint of the RDS MySQL instance"
value = aws_db_instance.main.endpoint
}
What does this output do?
This output prints the RDS endpoint (hostname and port) after each apply. It confirms the database was successfully created and gives you the connection string the app tier would use in a real application.
- Save outputs.tf.
- Apply the updated output by running this command:
terraform apply
- Enter your variable values when prompted and type yes to confirm.
- In the Terraform output, you should see both alb_dns_name and rds_endpoint printed. The RDS endpoint looks like ecommerce-3tier-db.xxxxxxxxxxxx.us-east-1.rds.amazonaws.com:3306.
Not seeing the rds_endpoint output?
- Make sure you saved outputs.tf before running the apply.
- Check that the output references aws_db_instance.main.endpoint with no typos.
- Need help? Help me debug my Terraform outputs for the RDS endpoint.
✔️ Awesome, I've got everything!
Double check you've saved outputs.tf.
ⓧ I'd like to double check the full code
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "rds_endpoint" {
description = "Endpoint of the RDS MySQL instance"
value = aws_db_instance.main.endpoint
}
Your complete 3-tier architecture is live. Internet traffic flows through the ALB, reaches the EC2 instance in a private subnet, and the database sits safely in an isolated subnet with no internet access. Next, you'll add CloudWatch monitoring to watch over all three tiers.
Add CloudWatch Monitoring Across All Tiers
Your 3-tier infrastructure is fully deployed and serving traffic. You confirmed the end-to-end flow from the internet through the ALB, to the EC2 instance, with the RDS database sitting safely in isolated subnets.
There is one gap: you have zero visibility into what is happening inside each tier. If the EC2 instance crashes or the database spikes to 100% CPU, nobody gets notified. In this step, you will create CloudWatch metric alarms for all three tiers and wire them to SNS email notifications so you get alerted when something goes wrong.
In this step, get ready to:
- Create an SNS topic and email subscription for alarm notifications.
- Define CloudWatch metric alarms for the ALB, EC2, and RDS tiers.
- Deploy the monitoring stack and verify alarms in the AWS Console.
Create the SNS notification channel
CloudWatch alarms can detect problems, but on their own they just flip a state in the console. To make alerts actionable, you need a notification channel that delivers the alarm to a real person.
SNS topics act as message routers. An alarm publishes to the topic, and the topic fans the message out to every subscriber. You will create one topic and subscribe your email address to it.
- Open monitoring.tf and add the SNS topic and email subscription by pasting this code:
# SNS Topic for alarm notifications
resource "aws_sns_topic" "alarms" {
name = "${var.project_name}-alarms"
tags = {
Name = "${var.project_name}-alarms"
}
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alarms.arn
protocol = "email"
endpoint = var.alert_email
}
What does this code do?
The aws_sns_topic resource creates a named topic that acts as a central mailbox for alarm messages. Any alarm you wire to this topic publishes its state change here.
The aws_sns_topic_subscription resource subscribes your email (from the alert_email variable) to that topic using the email protocol. When an alarm fires, SNS sends the alert straight to your inbox.
- Save monitoring.tf.
You will not see a change yet. The next chunk adds the alarms that publish to this topic.
Why SNS instead of just CloudWatch?
CloudWatch alarms alone just change state in the console. Adding SNS makes alerts actionable by routing them to a real destination. In production, that destination could be an engineer's inbox, a Slack channel, or a PagerDuty integration.
Create CloudWatch alarms for all three tiers
Each tier in your architecture has a different health signal. The ALB tier cares about whether healthy targets exist. The EC2 and RDS tiers care about CPU utilization. You will create one alarm per tier, each watching the metric that matters most.
- In monitoring.tf, add the ALB unhealthy hosts alarm below the SNS subscription by pasting this code:
# ALB Unhealthy Hosts Alarm
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
alarm_name = "${var.project_name}-alb-unhealthy-hosts"
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "HealthyHostCount"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Average"
threshold = 1
alarm_description = "ALB has no healthy targets in the app tier"
treat_missing_data = "breaching"
dimensions = {
TargetGroup = aws_lb_target_group.app.arn_suffix
LoadBalancer = aws_lb.main.arn_suffix
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-alb-unhealthy-hosts"
}
}
What does this alarm do?
This alarm watches the HealthyHostCount metric on your ALB target group. If the count drops below 1 (meaning zero healthy instances), the alarm fires and sends an email via the SNS topic.
The treat_missing_data = "breaching" setting is important. If CloudWatch cannot get data about healthy hosts, it likely means the target group has no registered targets. That should trigger the alarm, not be silently ignored.
The dimensions block uses arn_suffix attributes (not the full ARN). CloudWatch expects just the resource-specific portion of the ARN for ALB dimensions.
- Save the file.
You will not see a change yet. The next chunk adds the EC2 and RDS alarms to complete the monitoring stack.
The EC2 and RDS tiers both use the same health signal: CPU utilization. The pattern is the same for both, just pointing at different namespaces and dimensions.
- Add the EC2 and RDS CPU alarms below the ALB alarm in monitoring.tf by pasting this code:
# EC2 CPU Utilization Alarm
resource "aws_cloudwatch_metric_alarm" "ec2_cpu_high" {
alarm_name = "${var.project_name}-ec2-cpu-high"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 80
alarm_description = "EC2 app instance CPU utilization exceeds 80%"
dimensions = {
InstanceId = aws_instance.app.id
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-ec2-cpu-high"
}
}
# RDS CPU Utilization Alarm
resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" {
alarm_name = "${var.project_name}-rds-cpu-high"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "RDS database CPU utilization exceeds 80%"
dimensions = {
DBInstanceIdentifier = aws_db_instance.main.identifier
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-rds-cpu-high"
}
}
What do these alarms do?
Both alarms watch CPUUtilization and fire when average CPU exceeds 80%. The difference is what they point at.
- The EC2 alarm uses the AWS/EC2 namespace with an InstanceId dimension. It checks every 2 minutes (period = 120) and needs 2 consecutive breaches before firing.
- The RDS alarm uses the AWS/RDS namespace with a DBInstanceIdentifier dimension. It checks every 5 minutes (period = 300) since database metrics report less frequently.
- Save monitoring.tf.
- Preview the monitoring resources by running:
terraform plan
You should see 5 new resources to add: 1 SNS topic, 1 SNS subscription, and 3 CloudWatch alarms.
Seeing errors in the plan?
- If you see a reference error for aws_sns_topic.alarms, make sure the SNS topic resource block is above the alarm blocks in monitoring.tf.
- If you see a reference error for aws_lb_target_group.app or aws_instance.app, double check that alb.tf and compute.tf are saved and contain the resources from the previous steps.
Help me debug my Terraform plan errors for the CloudWatch monitoring resources.
✔️ Awesome, I've got everything!
Double check you have saved monitoring.tf.
ⓧ I'd like to double check the full code
# SNS Topic for alarm notifications
resource "aws_sns_topic" "alarms" {
name = "${var.project_name}-alarms"
tags = {
Name = "${var.project_name}-alarms"
}
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alarms.arn
protocol = "email"
endpoint = var.alert_email
}
# ALB Unhealthy Hosts Alarm
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
alarm_name = "${var.project_name}-alb-unhealthy-hosts"
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "HealthyHostCount"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Average"
threshold = 1
alarm_description = "ALB has no healthy targets in the app tier"
treat_missing_data = "breaching"
dimensions = {
TargetGroup = aws_lb_target_group.app.arn_suffix
LoadBalancer = aws_lb.main.arn_suffix
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-alb-unhealthy-hosts"
}
}
# EC2 CPU Utilization Alarm
resource "aws_cloudwatch_metric_alarm" "ec2_cpu_high" {
alarm_name = "${var.project_name}-ec2-cpu-high"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 80
alarm_description = "EC2 app instance CPU utilization exceeds 80%"
dimensions = {
InstanceId = aws_instance.app.id
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-ec2-cpu-high"
}
}
# RDS CPU Utilization Alarm
resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" {
alarm_name = "${var.project_name}-rds-cpu-high"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "RDS database CPU utilization exceeds 80%"
dimensions = {
DBInstanceIdentifier = aws_db_instance.main.identifier
}
alarm_actions = [aws_sns_topic.alarms.arn]
ok_actions = [aws_sns_topic.alarms.arn]
tags = {
Name = "${var.project_name}-rds-cpu-high"
}
}
Deploy the monitoring stack and verify
Before applying, you need to add outputs so Terraform reports the new resource names after deployment. You will add the SNS topic ARN and the CloudWatch alarm names to outputs.tf.
- Open outputs.tf and add the following outputs below the existing rds_endpoint output:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "sns_topic_arn" {
description = "ARN of the SNS topic for alarm notifications"
value = aws_sns_topic.alarms.arn
}
output "cloudwatch_alarms" {
description = "Names of the CloudWatch alarms created"
value = [
aws_cloudwatch_metric_alarm.alb_unhealthy_hosts.alarm_name,
aws_cloudwatch_metric_alarm.ec2_cpu_high.alarm_name,
aws_cloudwatch_metric_alarm.rds_cpu_high.alarm_name,
]
}
What do these outputs do?
- vpc_id prints the VPC identifier for quick reference.
- sns_topic_arn prints the full ARN of the SNS topic so you can verify it was created.
- cloudwatch_alarms prints a list of all three alarm names, confirming the monitoring stack deployed successfully.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Double check you have saved outputs.tf.
ⓧ I'd like to double check the full code
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "rds_endpoint" {
description = "Endpoint of the RDS MySQL instance"
value = aws_db_instance.main.endpoint
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "sns_topic_arn" {
description = "ARN of the SNS topic for alarm notifications"
value = aws_sns_topic.alarms.arn
}
output "cloudwatch_alarms" {
description = "Names of the CloudWatch alarms created"
value = [
aws_cloudwatch_metric_alarm.alb_unhealthy_hosts.alarm_name,
aws_cloudwatch_metric_alarm.ec2_cpu_high.alarm_name,
aws_cloudwatch_metric_alarm.rds_cpu_high.alarm_name,
]
}
Now deploy the entire monitoring stack. Terraform will create the SNS topic, the email subscription, and all three CloudWatch alarms in one apply.
- Deploy the monitoring resources by running:
terraform apply
- When Terraform shows the plan, type yes and press Enter to confirm.
Once the apply completes, you should see the new outputs printed in the terminal. Look for sns_topic_arn and cloudwatch_alarms listing all three alarm names.
Check your email for a confirmation from AWS
AWS SNS email subscriptions require manual confirmation. Check the inbox of the email address you provided for the alert_email variable. You should have an email from AWS with the subject "AWS Notification - Subscription Confirmation".
Click the Confirm subscription link in that email. Without this step, alarm notifications will not reach your inbox.
Now verify the alarms exist in the AWS Console.
- Open the AWS Console in your browser.
- Search for CloudWatch in the top search bar and open it.
- In the left sidebar, click Alarms, then All alarms.
You should see three alarms listed: ecommerce-3tier-alb-unhealthy-hosts, ecommerce-3tier-ec2-cpu-high, and ecommerce-3tier-rds-cpu-high. Each alarm will show a state of either "OK" or "Insufficient data".
Why does my alarm say "Insufficient data"?
This is normal. CloudWatch needs time to collect enough data points before it can evaluate the alarm condition. The ALB alarm may flip to "OK" within a minute since it checks every 60 seconds. The EC2 and RDS alarms take longer because they use 2-minute and 5-minute periods with 2 evaluation periods.
Don't see all three alarms?
- Make sure you are in the same AWS region that your Terraform project uses. Check the region dropdown in the top-right corner of the console. It should match your aws_region variable (default us-east-1).
- If the terraform apply succeeded but alarms are missing, try refreshing the CloudWatch page. New alarms can take a few seconds to appear.
Help me find my CloudWatch alarms in the AWS Console after running terraform apply.
Secret mission
Add Auto Scaling to the App Tier
Your current app tier runs on a single EC2 instance. If that instance crashes, the entire application goes offline. In this mission, replace it with an Auto Scaling Group that automatically manages multiple instances based on demand.
Before you go
Clean Up Your Resources
This project deployed several AWS resources that incur per-hour charges. Running costs add up to roughly $2-3 per day if left running.
Cost warning
The NAT Gateway alone costs ~$0.045/hr, and the ALB adds ~$0.0225/hr. Combined with the EC2 and RDS instances, idle costs reach $2-3/day.
These resources cannot be paused. The only way to stop charges is to destroy them with terraform destroy. Run this as soon as you finish verifying your deployment.
Resources you used:
- VPC with 6 subnets across 2 Availability Zones (free).
- Internet Gateway (free).
- NAT Gateway with Elastic IP (~$0.045/hr).
- Application Load Balancer (~$0.0225/hr).
- EC2 instance, t3.micro (~$0.0104/hr).
- RDS MySQL instance, db.t3.micro (~$0.017/hr).
- 3 Security groups, 3 route tables, and route table associations (free).
- 3 CloudWatch alarms (free for up to 10 standard alarms per account).
- SNS topic with email subscription (free for up to 1,000 emails/month).
- Local Terraform project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively testing your infrastructure or plan to extend it with the Secret Mission.
- Your resources will continue running and incurring charges (~$2-3/day).
- Monitor your AWS billing dashboard to track costs.
- When you are done, come back and run terraform destroy to clean up.
Pause - I'll come back to this later
Most of the resources in this project cannot be paused. NAT Gateways, Application Load Balancers, and Elastic IPs have no pause or stop functionality.
The recommended approach is to destroy everything and re-create it later. Terraform makes this straightforward.
- Keep your local project folder (ecommerce-3tier-terraform) on your Desktop. Your .tf files are your blueprint.
- Run terraform destroy to remove all AWS resources and stop all charges.
- Type yes when prompted to confirm.
- When you are ready to resume, run terraform apply from the same folder to re-create everything.
Delete - I don't want to use this again
Remove all project resources and start fresh. This stops all AWS charges immediately.
- Open your terminal and navigate to your project folder.
- Destroy all AWS resources by running:
terraform destroy
- Terraform lists every resource it will destroy. Type yes and press Enter to confirm.
- Wait for the destroy to complete. The RDS instance takes a few minutes to delete.
- You should see Destroy complete! with a count of all resources removed.
- Delete the local project folder from your Desktop if you no longer need the code.
Verify in the AWS Console
After the destroy completes, check your AWS Console to confirm. Look in the VPC, EC2, and RDS dashboards to confirm no resources remain from this project.
Mission accomplished
That's a Wrap!
Nice work! You just deployed a production-pattern 3-tier AWS infrastructure with built-in monitoring, entirely with Terraform.
You've learned how to:
- Built a complete 3-tier VPC with public, private, and isolated subnets across two Availability Zones, enforcing strict tier-to-tier isolation through security group chaining.
- Deployed an end-to-end traffic flow from an Application Load Balancer through private EC2 instances to an RDS MySQL database in isolated subnets with zero internet access.
- Set up CloudWatch alarms monitoring all three tiers with SNS email notifications, matching how production teams get alerted when something goes wrong.
- Secret Mission: Added an Auto Scaling Group to make the app tier horizontally scalable under load.
Ready to quiz yourself?