AWS Three-Tier App with Terraform
Build a production-style three-tier VPC on AWS using Terraform as code.
Introduction
30 Second Summary
Think about how a bank separates its lobby (where anyone can walk in), its back offices (where employees work), and its vault (where money is stored). Each area has different access rules, and no one from the lobby can walk straight into the vault.
In this project, you will build a three-tier network architecture on AWS using Terraform. You'll create isolated layers for public access, application logic, and database storage, all defined as code.
What You'll Build
A production-style three-tier VPC on AWS with an Application Load Balancer, Auto Scaling Group, and RDS MySQL database, written entirely in Terraform.
By the end of this project, you'll have:
- A custom VPC with 6 subnets spanning 2 Availability Zones, each tier properly isolated with its own routing rules.
- Security group chaining that enforces traffic flow from the internet through the ALB, to the app tier, to the database tier.
- A running PHP application behind an Application Load Balancer that displays instance metadata and reads/writes to an RDS MySQL database.
- Secret Mission: Add CloudWatch alarms with SNS notifications and Auto Scaling policies that dynamically scale your app tier based on CPU load.
Are there any prerequisites?
No prior experience with Terraform or AWS networking is required. This guide walks you through installing all required tools from scratch on a Windows device.
Define the VPC and Subnet Architecture
Your environment is configured and ready to go. Now it's time to define the foundation of your three-tier architecture.
Every production network needs isolated layers to separate public-facing traffic from application logic and sensitive data. In this step, you'll write Terraform code that creates a VPC with six subnets organized into three distinct tiers across two Availability Zones.
In this step, get ready to:
- Create main.tf with a VPC and six subnets spanning two Availability Zones.
- Define input variables, outputs, and variable values in supporting files.
- Initialize Terraform and deploy the VPC infrastructure to AWS.
Create main.tf with the VPC and subnets
The VPC is the container for your entire network. Inside it, subnets divide the address space into isolated segments for each tier.
- In your text editor, create a new file called main.tf inside your C:\projects\three-tier-vpc folder.
- Add the Terraform configuration and AWS provider block by pasting this code:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
The terraform block pins the Terraform version to 1.15.x and declares the AWS provider from HashiCorp's registry at version 6.x.
The provider block configures AWS to deploy resources in the region you'll define as a variable. Using variables instead of hardcoded values makes your configuration reusable.
- Below the provider block, add the VPC resource:
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
What does this code do?
The aws_vpc resource creates your Virtual Private Cloud with a 10.0.0.0/16 address range. That gives you 65,536 IP addresses to carve into subnets.
Enabling enable_dns_support and enable_dns_hostnames allows instances inside the VPC to resolve DNS names. This is required for services like RDS to provide connection endpoints.
- Below the VPC resource, add the two public subnets:
# --- 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 = "${var.project_name}-public-1"
Tier = "public"
}
}
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 = "${var.project_name}-public-2"
Tier = "public"
}
}
Why two public subnets?
The Application Load Balancer you'll create later requires subnets in at least two Availability Zones for high availability. Each public subnet gets map_public_ip_on_launch = true so resources inside them can be reached from the internet.
Spreading across us-east-1a and us-east-1b means your infrastructure stays available even if one data center has issues.
- Below the public subnets, add the two application subnets:
resource "aws_subnet" "app_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-app-1"
Tier = "application"
}
}
resource "aws_subnet" "app_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-app-2"
Tier = "application"
}
}
What makes these subnets different?
Notice there is no map_public_ip_on_launch here. Application subnets are private. Your EC2 instances will live in these subnets, reachable only through the load balancer in the public tier.
- Below the application subnets, add the two database subnets:
resource "aws_subnet" "db_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.21.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-db-1"
Tier = "database"
}
}
resource "aws_subnet" "db_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.22.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-db-2"
Tier = "database"
}
}
Why isolate database subnets?
Database subnets are also private and will have no route to the internet at all. Only the application tier will be allowed to talk to them. This is the core principle of three-tier architecture: each tier only accepts traffic from the tier directly above it.
- Save main.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
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# --- 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 = "${var.project_name}-public-1"
Tier = "public"
}
}
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 = "${var.project_name}-public-2"
Tier = "public"
}
}
resource "aws_subnet" "app_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-app-1"
Tier = "application"
}
}
resource "aws_subnet" "app_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-app-2"
Tier = "application"
}
}
resource "aws_subnet" "db_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.21.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-db-1"
Tier = "database"
}
}
resource "aws_subnet" "db_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.22.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-db-2"
Tier = "database"
}
}
Define variables and outputs
Terraform uses variables to make configurations flexible and outputs to surface key information after deployment.
- Create a new file called variables.tf in the same folder and paste this code:
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "three-tier"
}
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "db_username" {
description = "Database administrator username"
type = string
default = "admin"
sensitive = true
}
variable "db_password" {
description = "Database administrator password"
type = string
sensitive = true
}
What does this code do?
Each variable block declares an input your configuration can reference with var.<name>. Variables with default values are optional at runtime. The db_password variable has no default, so Terraform will require you to provide it.
The sensitive = true flag tells Terraform to redact these values from plan and apply output, keeping credentials out of your terminal logs.
- Save variables.tf.
- Create a new file called terraform.tfvars and paste this code to provide values for your variables:
project_name = "three-tier"
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
db_username = "admin"
db_password = "your-secure-password-here"
What does this file do?
Terraform automatically loads terraform.tfvars and uses the values inside to populate your variables. Replace your-secure-password-here with a password of your choice (at least 8 characters). This password will be used for your RDS database later.
- Save terraform.tfvars.
- Create a new file called outputs.tf and paste this code to define what Terraform will display after deployment:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}
output "app_subnet_ids" {
description = "IDs of the application subnets"
value = [aws_subnet.app_1.id, aws_subnet.app_2.id]
}
output "db_subnet_ids" {
description = "IDs of the database subnets"
value = [aws_subnet.db_1.id, aws_subnet.db_2.id]
}
What does this code do?
Output blocks tell Terraform to print specific resource attributes after terraform apply completes. You'll use these IDs to verify your resources were created correctly. You'll add more outputs (like the ALB DNS name and RDS endpoint) as you build those resources in later steps.
- Save outputs.tf.
Now initialize Terraform so it can download the AWS provider plugin.
- Open a Command Prompt in your C:\projects\three-tier-vpc directory and run this command:
terraform init
- You should see a message ending with Terraform has been successfully initialized! confirming the AWS provider was downloaded.
Seeing an error during init?
- Check that main.tf has the required_providers block with source = "hashicorp/aws". A typo in the provider name prevents Terraform from finding it.
- Make sure your Command Prompt is inside C:\projects\three-tier-vpc where your .tf files are saved.
Help me troubleshoot my terraform init error.
- Preview your infrastructure plan by running:
terraform plan
- You should see a summary ending with Plan: 7 to add, 0 to change, 0 to destroy. confirming 1 VPC and 6 subnets will be created.
Plan shows errors instead of resources?
- If you see a missing variable error, make sure terraform.tfvars exists and has a value for db_password.
- If you see a syntax error, check for missing closing braces or quotes in main.tf. Terraform will point to the line number where it found the issue.
Help me fix my terraform plan error.
Deploy the VPC and subnets
Your plan looks good. Time to create the actual resources in AWS.
- Deploy your infrastructure by running:
terraform apply
- Terraform will show the same plan and prompt you for confirmation. Type yes and press Enter.
- Wait for Terraform to finish. You should see Apply complete! Resources: 7 added, 0 changed, 0 destroyed. followed by your output values.
What just happened?
Terraform called the AWS API to create your VPC and all six subnets. It also saved the current state of these resources in a local terraform.tfstate file. This state file is how Terraform tracks what exists in AWS and determines what changes to make on future runs.
- Verify your resources in the AWS Console. Open the VPC service.
- Click Subnets in the left sidebar.
- You should see 6 subnets tagged with names starting with three-tier-. Each one shows its tier in the Tier tag column (public, application, or database).
Don't see your subnets?
- Make sure you're looking at the us-east-1 region in the top-right corner of the AWS Console. Your resources only exist in the region you deployed to.
- If terraform apply showed any errors, re-run it and check for typos in your CIDR blocks or availability zone names.
Help me find my VPC subnets in the AWS Console.
Your network foundation is in place. Next up, you'll configure routing and security groups to control how traffic flows between these tiers.
Configure Routing and Security Groups
Your VPC has 6 subnets, but none of them can actually send or receive traffic yet. Without route tables, your subnets are like rooms in a building with no hallways connecting them to the outside world.
In this step, you will build the routing layer that controls how traffic flows between tiers and the internet. You will also create security groups that enforce a strict chain: traffic can only flow from the internet to the ALB, from the ALB to the app tier, and from the app tier to the database.
In this step, get ready to:
- Add an Internet Gateway, NAT Gateway, and route tables to control traffic flow.
- Create three security groups with tier chaining to enforce access control.
- Deploy and verify the full routing and security configuration.
Add the Internet Gateway and NAT Gateway
The public subnets need a way to reach the internet directly. The private application subnets need outbound internet access (for updates and patches) without being directly reachable from the outside.
- In your text editor, open main.tf inside the three-tier-vpc folder.
- Find the last subnet resource (aws_subnet.db_2) and add the following code below it to create the Internet Gateway, Elastic IP, and NAT Gateway:
# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-nat"
}
depends_on = [aws_internet_gateway.main]
}
What does this code do?
The Internet Gateway attaches to your VPC and gives public subnets a path to the internet.
The Elastic IP (aws_eip) allocates a static public IP address. The NAT Gateway sits in the first public subnet and uses this Elastic IP to let private subnets make outbound connections without being directly reachable from the internet.
The depends_on line tells Terraform to create the Internet Gateway before the NAT Gateway. Without it, the NAT Gateway creation can fail because AWS requires the Internet Gateway to exist first.
- Now add the route tables and their subnet associations directly below the NAT Gateway resource:
# --- Route Tables ---
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_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
}
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" "app_1" {
subnet_id = aws_subnet.app_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "app_2" {
subnet_id = aws_subnet.app_2.id
route_table_id = aws_route_table.private.id
}
What does this code do?
The public route table sends all outbound traffic (0.0.0.0/0) to the Internet Gateway. Both public subnets are associated with this route table, giving them direct internet access.
The private route table sends all outbound traffic to the NAT Gateway instead. Both application subnets are associated with it, so app instances can download packages but cannot be reached directly from the internet.
Notice the database subnets have NO route table association here. They only get the default local VPC route, making them completely isolated from the internet.
- Save main.tf.
Seeing a syntax error?
Make sure each resource block has matching opening and closing braces. Check that the route block inside each route table also has its own pair of braces.
help me fix a syntax error in my Terraform route table or gateway code
Create security groups with tier chaining
Security groups act as firewalls for your resources. Tier chaining means each security group only accepts traffic from the tier directly above it, preventing anything from skipping a tier.
- In main.tf, add the ALB security group below your route table associations. This group allows HTTP traffic from anywhere on the internet:
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
The ALB security group is the entry point to your architecture. The ingress rule allows HTTP (port 80) from any IP address (0.0.0.0/0). The egress rule allows all outbound traffic so the ALB can forward requests to the app tier.
The aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources are standalone rules. This is the current best practice in the AWS provider, replacing the older inline ingress and egress blocks.
- Next, add the application tier security group below the ALB rules. This is where tier chaining begins:
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for application tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Why referenced_security_group_id instead of a CIDR block?
The referenced_security_group_id attribute tells AWS to only allow traffic from resources that belong to the ALB security group. This is more secure than using a CIDR block because it ties access to identity, not IP addresses.
Even if someone launches an instance in the same subnet, it cannot reach the app tier unless it is attached to the ALB security group.
- Finally, add the database tier security group. It only allows MySQL traffic (port 3306) from the application security group:
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does the full chaining pattern look like?
The three security groups form a strict chain:
- Internet (0.0.0.0/0) can reach the ALB on port 80.
- The ALB security group can reach the app tier on port 80.
- The app security group can reach the database on port 3306.
Nothing can skip a tier. The internet cannot reach the app tier directly, and neither the internet nor the ALB can reach the database directly.
- Save main.tf.
Seeing a reference error about security groups?
Make sure aws_security_group.alb is defined before aws_security_group.app in the file, and aws_security_group.app before aws_security_group.db. Terraform resolves references regardless of order in the file, but the referenced_security_group_id must point to a valid resource name.
help me fix a security group reference error in my Terraform code
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf with all the gateway, route table, and security group resources.
ⓧ I'd like to double check the full code
Here is the complete main.tf file at this point in the project:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# --- 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 = "${var.project_name}-public-1"
Tier = "public"
}
}
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 = "${var.project_name}-public-2"
Tier = "public"
}
}
resource "aws_subnet" "app_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-app-1"
Tier = "application"
}
}
resource "aws_subnet" "app_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-app-2"
Tier = "application"
}
}
resource "aws_subnet" "db_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.21.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-db-1"
Tier = "database"
}
}
resource "aws_subnet" "db_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.22.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-db-2"
Tier = "database"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-nat"
}
depends_on = [aws_internet_gateway.main]
}
# --- Route Tables ---
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_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
}
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" "app_1" {
subnet_id = aws_subnet.app_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "app_2" {
subnet_id = aws_subnet.app_2.id
route_table_id = aws_route_table.private.id
}
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for application tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Deploy and verify the routing and security configuration
- Deploy all the new resources by running this command:
terraform apply
- Terraform will show a plan to add 13 new resources. Type yes and press Enter to confirm.
What should I see?
Terraform creates 1 Internet Gateway, 1 Elastic IP, 1 NAT Gateway, 2 route tables, 4 route table associations, and 3 security groups with their chained rules. The NAT Gateway takes about 1-2 minutes to provision.
💰 Cost warning
The NAT Gateway costs $0.045/hour from this point forward. Complete the remaining steps and run terraform destroy when you are finished with the project.
- Open the AWS Console and navigate to VPC > Route tables.
- Click on the three-tier-public-rt route table and check the Routes tab to confirm a route to 0.0.0.0/0 targeting the Internet Gateway.
- Check the Subnet associations tab to confirm both public subnets are associated.
- Click on the three-tier-private-rt route table and verify its route targets the NAT Gateway, with both app subnets associated.
- Navigate to VPC > Security groups.
- Click on three-tier-app-sg and check the Inbound rules tab. The source should show the ALB security group ID, not a CIDR block.
- Click on three-tier-db-sg and verify its inbound rule references the app security group ID on port 3306.
NAT Gateway showing as failed?
This usually means the Internet Gateway was not fully created before the NAT Gateway. Make sure your aws_nat_gateway.main resource includes the line depends_on = [aws_internet_gateway.main].
Run terraform destroy to clean up, fix the code, then run terraform apply again.
help me fix a NAT Gateway failure in my Terraform deployment
Your network is now fully wired. Traffic can flow in through the public tier, get processed in the application tier, and read from the database tier. Next up, you will deploy the RDS database into the isolated database subnets.
Deploy the RDS Database
Your networking and security layers are locked in. The public subnets route to the internet, the app subnets route outbound through the NAT Gateway, and the database subnets are fully isolated.
Now it is time to put something in that isolated database tier. You will deploy an RDS MySQL instance that only the application tier can reach. This is the whole point of the security group chaining you built in the last step.
In this step, get ready to:
- Create a DB subnet group that places the database in your isolated subnets.
- Deploy an RDS MySQL instance with no public internet access.
- Verify the database is locked behind the app tier security group.
Create the DB subnet group
RDS requires a DB subnet group that tells AWS which subnets the database can be placed in. This group must span at least two Availability Zones for redundancy.
- In main.tf, scroll to the bottom of the file (after all the security group rules).
- Add the DB subnet group resource by pasting this code:
# --- RDS Database (Data Tier) ---
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.db_1.id, aws_subnet.db_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
What does this code do?
- aws_db_subnet_group bundles your two database subnets (db_1 and db_2) into a named group that RDS can use for placement.
- Because these subnets have no internet route, the database will be fully isolated from public traffic.
Create the RDS MySQL instance
The RDS instance is the actual MySQL database. It uses the subnet group for network placement and the database security group to control who can connect on port 3306.
- Directly below the DB subnet group resource in main.tf, add the RDS instance:
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
skip_final_snapshot = true
tags = {
Name = "${var.project_name}-db"
Tier = "database"
}
}
What does this code do?
- engine = "mysql" and engine_version = "8.0" select MySQL 8.0 as the database engine.
- instance_class = "db.t3.micro" picks a small, affordable instance size suitable for development.
- db_subnet_group_name places the database into your isolated subnets. vpc_security_group_ids attaches the database security group that only allows port 3306 from the app tier.
- publicly_accessible = false ensures no public IP is assigned. skip_final_snapshot = true lets Terraform destroy the instance cleanly without requiring a final backup.
- The username and password reference the sensitive variables you defined in variables.tf and set in terraform.tfvars.
- Save main.tf.
Seeing red squiggles in your editor?
If your editor shows warnings about var.db_password or var.db_username, double check that both variables are defined in variables.tf and have corresponding values in terraform.tfvars.
Help me debug variable reference errors in my Terraform RDS configuration.
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.tf up to this point. Compare your file against it:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# --- 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 = "${var.project_name}-public-1"
Tier = "public"
}
}
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 = "${var.project_name}-public-2"
Tier = "public"
}
}
resource "aws_subnet" "app_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-app-1"
Tier = "application"
}
}
resource "aws_subnet" "app_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-app-2"
Tier = "application"
}
}
resource "aws_subnet" "db_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.21.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-db-1"
Tier = "database"
}
}
resource "aws_subnet" "db_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.22.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-db-2"
Tier = "database"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-nat"
}
depends_on = [aws_internet_gateway.main]
}
# --- Route Tables ---
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_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
}
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" "app_1" {
subnet_id = aws_subnet.app_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "app_2" {
subnet_id = aws_subnet.app_2.id
route_table_id = aws_route_table.private.id
}
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for application tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- RDS Database (Data Tier) ---
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.db_1.id, aws_subnet.db_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
skip_final_snapshot = true
tags = {
Name = "${var.project_name}-db"
Tier = "database"
}
}
Deploy the RDS instance with Terraform
RDS provisioning takes longer than the networking resources you have deployed so far. Expect this apply to take 5-8 minutes while AWS creates the MySQL instance.
- Deploy the database by running this command in your Command Prompt:
terraform apply
- Terraform will show a plan adding 2 new resources (the DB subnet group and the RDS instance).
- Type yes and press Enter to confirm.
Why does this take so long?
RDS is provisioning a managed database server behind the scenes. AWS is creating the instance, configuring MySQL 8.0, attaching it to your subnet group, and running health checks. This is a one-time wait.
- Wait for Terraform to complete. You should see a success message ending with Apply complete! Resources: 2 added, 0 changed, 0 destroyed. (or similar).
Apply failed or timed out?
If you see an error mentioning the DB subnet group, confirm that both aws_subnet.db_1 and aws_subnet.db_2 exist in your main.tf and are spelled exactly as referenced in the subnet group's subnet_ids.
If the error mentions the password variable, make sure db_password in terraform.tfvars is not empty. It needs a value of at least 8 characters.
Help me debug a terraform apply error when creating my RDS database instance.
Verify the RDS instance in the AWS Console
- Open the AWS Console in your browser and navigate to the RDS service.
- Click Databases in the left sidebar.
- Click on the three-tier-db instance to open its details.
On the details page, confirm two things:
- Under Connectivity & security, verify that Publicly accessible shows No.
- Under the VPC security groups section, click on the security group link. The inbound rules should show port 3306 allowed only from the app tier security group (not a CIDR block).
- Note the Endpoint address shown on the details page. You will use this in the next step when the application connects to the database.
Why does the security group reference another group instead of a CIDR?
Security group chaining means each tier only accepts traffic from the tier directly above it. The database security group references the app security group by ID, so only instances attached to that specific group can connect. If you added more app servers later, they would automatically gain database access without changing any rules.
Your database tier is live and fully isolated. In the next step, you will build the application tier with a load balancer and auto scaling group that connects to this database through the security group chain.
Deploy the ALB, Auto Scaling Group, and PHP Frontend
Your network is built. Your database is running. Now it is time to bring the application tier to life by deploying the compute resources that actually serve traffic to users.
In this step, you will create an Application Load Balancer in the public subnets, a launch template that defines how each EC2 instance boots with a PHP application, and an Auto Scaling Group that keeps multiple instances running across both Availability Zones.
In this step, get ready to:
- Create the Application Load Balancer with a target group and HTTP listener.
- Build a launch template with a PHP application that connects to RDS.
- Deploy an Auto Scaling Group that distributes instances across both app subnets.
Create the Application Load Balancer
The ALB sits in the public subnets and acts as the single entry point for all user traffic. It distributes incoming HTTP requests to healthy instances in the application tier.
- In your text editor, open main.tf in the C:\projects\three-tier-vpc folder.
- Scroll to the bottom of the file, below the RDS instance resource. Add the following ALB, target group, and listener resources:
# --- Application Load Balancer ---
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_1.id, aws_subnet.public_2.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 = 2
timeout = 5
interval = 10
}
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?
- aws_lb.main creates an internet-facing Application Load Balancer in both public subnets, attached to the ALB security group you created earlier.
- aws_lb_target_group.app defines a target group that the ALB routes traffic to on port 80. The health check pings / every 10 seconds and requires only 2 consecutive successes or failures to flip a target's status.
- aws_lb_listener.http tells the ALB to listen on port 80 and forward all requests to the target group.
💡 Why aggressive health checks?
The defaults for a target group health check are a 30-second interval and a threshold of 3. This configuration uses a 10-second interval with a threshold of 2 so that unhealthy instances are detected in 20 seconds instead of 90. For a learning project this makes it easier to observe the ALB rotating traffic away from a failing instance.
- Save main.tf.
Create the launch template and PHP user data script
A launch template tells the Auto Scaling Group exactly how to create each EC2 instance. Your template will reference an Amazon Linux 2023 AMI, attach the app security group, and inject a user data script that installs Apache, PHP, and a full CRUD application.
- Still in main.tf, add the AMI data source and launch template below the listener resource:
# --- Launch Template and Auto Scaling Group (App Tier) ---
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_launch_template" "app" {
name_prefix = "${var.project_name}-app-"
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.app.id]
user_data = base64encode(templatefile("${path.module}/user_data.sh", {
db_host = aws_db_instance.main.address
db_user = var.db_username
db_password = var.db_password
db_name = "appdb"
}))
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app-instance"
Tier = "application"
}
}
}
What does this code do?
- data.aws_ami.amazon_linux looks up the most recent Amazon Linux 2023 AMI owned by Amazon. The filter ensures you always get the latest patched version without hardcoding an AMI ID.
- aws_launch_template.app defines a t3.micro instance using that AMI, attached to the app security group.
- The templatefile function reads user_data.sh from disk and injects the RDS endpoint, username, password, and database name into the script before base64-encoding it.
- Save main.tf.
- Create a new file called user_data.sh in the same C:\projects\three-tier-vpc folder.
- Paste the following bash script into user_data.sh:
#!/bin/bash
dnf install -y httpd php php-mysqlnd
systemctl start httpd
systemctl enable httpd
# Write instance metadata
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
PRIVATE_IP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4)
AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
# Create PHP application
cat > /var/www/html/index.php << 'PHPEOF'
<?php
$instance_id = "<?php echo '$${INSTANCE_ID}'; ?>";
$private_ip = "<?php echo '$${PRIVATE_IP}'; ?>";
$az = "<?php echo '$${AZ}'; ?>";
PHPEOF
# Overwrite with actual PHP file
cat > /var/www/html/index.php << PHPEOF
<?php
\$instance_id = "$INSTANCE_ID";
\$private_ip = "$PRIVATE_IP";
\$az = "$AZ";
\$db_host = "${db_host}";
\$db_user = "${db_user}";
\$db_pass = "${db_password}";
\$db_name = "${db_name}";
\$conn = new mysqli(\$db_host, \$db_user, \$db_pass, \$db_name);
\$db_connected = !\$conn->connect_error;
\$db_status = \$db_connected ? "Connected" : "Disconnected: " . \$conn->connect_error;
if (\$db_connected) {
\$conn->query("CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
\$result = \$conn->query("SELECT COUNT(*) as cnt FROM messages");
\$row = \$result->fetch_assoc();
if (\$row['cnt'] == 0) {
\$conn->query("INSERT INTO messages (name, message) VALUES
('Alice', 'Hello from the three-tier architecture!'),
('Bob', 'This data lives in the isolated RDS database tier.'),
('Charlie', 'Security groups ensure only the app tier can reach me.')");
}
if (\$_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset(\$_POST['action'])) {
if (\$_POST['action'] === 'add') {
\$stmt = \$conn->prepare("INSERT INTO messages (name, message) VALUES (?, ?)");
\$stmt->bind_param("ss", \$_POST['name'], \$_POST['message']);
\$stmt->execute();
} elseif (\$_POST['action'] === 'edit') {
\$stmt = \$conn->prepare("UPDATE messages SET name = ?, message = ? WHERE id = ?");
\$stmt->bind_param("ssi", \$_POST['name'], \$_POST['message'], \$_POST['id']);
\$stmt->execute();
} elseif (\$_POST['action'] === 'delete') {
\$stmt = \$conn->prepare("DELETE FROM messages WHERE id = ?");
\$stmt->bind_param("i", \$_POST['id']);
\$stmt->execute();
}
}
header("Location: /");
exit;
}
\$messages = \$conn->query("SELECT * FROM messages ORDER BY created_at DESC");
}
\$edit_id = isset(\$_GET['edit']) ? (int)\$_GET['edit'] : 0;
\$edit_row = null;
if (\$edit_id > 0 && \$db_connected) {
\$stmt = \$conn->prepare("SELECT * FROM messages WHERE id = ?");
\$stmt->bind_param("i", \$edit_id);
\$stmt->execute();
\$edit_row = \$stmt->get_result()->fetch_assoc();
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Three-Tier App</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 30px; }
.info-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; }
.info-card h3 { margin: 0 0 5px 0; font-size: 14px; color: #6c757d; }
.info-card p { margin: 0; font-size: 16px; font-weight: bold; }
.status-connected { color: #28a745; }
.status-disconnected { color: #dc3545; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #dee2e6; }
th { background: #f8f9fa; }
form { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
input, textarea { width: 100%; padding: 8px; margin: 5px 0 15px 0; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; }
button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #007bff; color: white; }
.btn-danger { background: #dc3545; color: white; }
.btn-sm { padding: 4px 10px; font-size: 13px; }
</style>
</head>
<body>
<h1>Three-Tier Architecture Dashboard</h1>
<div class="info-grid">
<div class="info-card">
<h3>Instance ID</h3>
<p><?= htmlspecialchars(\$instance_id) ?></p>
</div>
<div class="info-card">
<h3>Private IP</h3>
<p><?= htmlspecialchars(\$private_ip) ?></p>
</div>
<div class="info-card">
<h3>Availability Zone</h3>
<p><?= htmlspecialchars(\$az) ?></p>
</div>
<div class="info-card">
<h3>RDS Connection</h3>
<p class="<?= \$db_connected ? 'status-connected' : 'status-disconnected' ?>"><?= htmlspecialchars(\$db_status) ?></p>
</div>
</div>
<?php if (\$db_connected): ?>
<h2><?= \$edit_row ? 'Edit Record' : 'Add New Record' ?></h2>
<form method="POST">
<input type="hidden" name="action" value="<?= \$edit_row ? 'edit' : 'add' ?>">
<?php if (\$edit_row): ?>
<input type="hidden" name="id" value="<?= \$edit_row['id'] ?>">
<?php endif; ?>
<label>Name:</label>
<input type="text" name="name" value="<?= htmlspecialchars(\$edit_row['name'] ?? '') ?>" required>
<label>Message:</label>
<textarea name="message" rows="3" required><?= htmlspecialchars(\$edit_row['message'] ?? '') ?></textarea>
<button type="submit" class="btn-primary"><?= \$edit_row ? 'Update' : 'Add' ?> Record</button>
<?php if (\$edit_row): ?>
<a href="/" style="margin-left:10px;">Cancel</a>
<?php endif; ?>
</form>
<h2>Messages (from RDS)</h2>
<table>
<tr><th>ID</th><th>Name</th><th>Message</th><th>Created</th><th>Actions</th></tr>
<?php while(\$row = \$messages->fetch_assoc()): ?>
<tr>
<td><?= \$row['id'] ?></td>
<td><?= htmlspecialchars(\$row['name']) ?></td>
<td><?= htmlspecialchars(\$row['message']) ?></td>
<td><?= \$row['created_at'] ?></td>
<td>
<a href="?edit=<?= \$row['id'] ?>"><button type="button" class="btn-primary btn-sm">Edit</button></a>
<form method="POST" style="display:inline;background:none;padding:0;margin:0;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= \$row['id'] ?>">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this record?')">Delete</button>
</form>
</td>
</tr>
<?php endwhile; ?>
</table>
<?php endif; ?>
</body>
</html>
PHPEOF
# Remove default Apache welcome page
rm -f /var/www/html/index.html
# Restart Apache to load PHP
systemctl restart httpd
What does this script do?
This script runs automatically when each EC2 instance boots. It performs four jobs:
- Installs Apache, PHP, and the php-mysqlnd driver so the instance can serve PHP pages and connect to MySQL.
- Retrieves instance metadata (Instance ID, private IP, Availability Zone) using IMDSv2 tokens.
- Generates a full PHP application (index.php) that displays the metadata, connects to RDS, creates the messages table if missing, seeds sample data, and provides a form to add, edit, and delete records.
- The ${db_host}, ${db_user}, ${db_password}, and ${db_name} placeholders are replaced by Terraform's templatefile function with the real RDS values at deploy time.
- Save user_data.sh.
File not found errors later?
- Make sure user_data.sh is in the same directory as main.tf. The ${path.module} reference in the launch template looks for it there.
- Confirm the filename is exactly user_data.sh with no extra extension like .txt.
Help me troubleshoot a file not found error in my Terraform templatefile function.
Create the Auto Scaling Group and deploy
The Auto Scaling Group launches instances from your template and registers them with the ALB target group. It spans both private app subnets so that if one Availability Zone goes down, the other continues serving traffic.
- Back in main.tf, add the Auto Scaling Group below the launch template resource:
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-app-asg"
desired_capacity = 2
max_size = 3
min_size = 1
vpc_zone_identifier = [aws_subnet.app_1.id, aws_subnet.app_2.id]
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
health_check_grace_period = 300
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-app-asg"
propagate_at_launch = true
}
}
What does this code do?
- desired_capacity = 2 launches 2 instances immediately. The ASG can scale between 1 and 3 instances based on demand.
- vpc_zone_identifier places instances in both private app subnets (across two AZs), giving you high availability.
- target_group_arns automatically registers each new instance with the ALB target group so it receives traffic.
- health_check_type = "ELB" means the ASG uses the ALB's health check (not just EC2 status checks) to decide whether to replace an instance. The 300-second grace period gives instances time to boot and pass health checks before being evaluated.
- Save main.tf.
- Deploy the new resources by running:
terraform apply
- When prompted, type yes and press Enter to confirm.
How long does this take?
The ALB and Auto Scaling Group take 2-3 minutes to provision. Terraform will show progress as each resource is created. Once complete, it prints the outputs including the ALB DNS name.
- Copy the alb_dns_name value from the Terraform output.
- Paste it into your browser's address bar and press Enter.
- Wait 1-2 minutes for the instances to pass their health checks, then refresh the page.
You should see the Three-Tier Architecture Dashboard showing an Instance ID, a private IP address, an Availability Zone, and a green "Connected" status for RDS.
Refresh the page several times. You should see different Instance IDs and IP addresses as the ALB distributes requests between your two instances.
What proves all three tiers are working?
- The page loads at all, proving the public tier (ALB) is reachable from the internet.
- Instance metadata appears, proving the private application tier (EC2 in private subnets) is serving responses through the ALB.
- The green "Connected" status and sample data prove the application tier can reach the isolated database tier through the security group chain.
- Refreshing shows different Instance IDs, proving the ALB is load-balancing across both AZs.
- Try adding a new record using the form on the page. Enter a name and message, then click Add Record.
- Refresh the page. Your new record should still appear, even if you land on a different instance.
- Click Edit on any record, change the message, and click Update Record. The change persists because both instances share the same RDS database.
Page not loading or showing 502/503?
- Wait 2-3 minutes after terraform apply completes. The instances need time to boot, install packages, and pass health checks.
- In the AWS Console, go to EC2 > Target Groups > select your target group > Targets tab. Check that at least one target shows "healthy" status.
- If targets show "unhealthy", verify that your user_data.sh file is saved in the same directory as main.tf and has no copy-paste formatting errors.
Help me debug why my ALB target group shows unhealthy targets in my three-tier VPC project.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and user_data.sh before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.tf at the end of this step:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# --- 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 = "${var.project_name}-public-1"
Tier = "public"
}
}
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 = "${var.project_name}-public-2"
Tier = "public"
}
}
resource "aws_subnet" "app_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-app-1"
Tier = "application"
}
}
resource "aws_subnet" "app_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-app-2"
Tier = "application"
}
}
resource "aws_subnet" "db_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.21.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-db-1"
Tier = "database"
}
}
resource "aws_subnet" "db_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.22.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-db-2"
Tier = "database"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-nat"
}
depends_on = [aws_internet_gateway.main]
}
# --- Route Tables ---
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_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
}
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" "app_1" {
subnet_id = aws_subnet.app_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "app_2" {
subnet_id = aws_subnet.app_2.id
route_table_id = aws_route_table.private.id
}
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for application tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- RDS Database (Data Tier) ---
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.db_1.id, aws_subnet.db_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false
skip_final_snapshot = true
tags = {
Name = "${var.project_name}-db"
Tier = "database"
}
}
# --- Application Load Balancer ---
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_1.id, aws_subnet.public_2.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 = 2
timeout = 5
interval = 10
}
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
}
}
# --- Launch Template and Auto Scaling Group (App Tier) ---
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_launch_template" "app" {
name_prefix = "${var.project_name}-app-"
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.app.id]
user_data = base64encode(templatefile("${path.module}/user_data.sh", {
db_host = aws_db_instance.main.address
db_user = var.db_username
db_password = var.db_password
db_name = "appdb"
}))
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app-instance"
Tier = "application"
}
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-app-asg"
desired_capacity = 2
max_size = 3
min_size = 1
vpc_zone_identifier = [aws_subnet.app_1.id, aws_subnet.app_2.id]
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
health_check_grace_period = 300
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-app-asg"
propagate_at_launch = true
}
}
Here is the complete user_data.sh:
#!/bin/bash
dnf install -y httpd php php-mysqlnd
systemctl start httpd
systemctl enable httpd
# Write instance metadata
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
PRIVATE_IP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4)
AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
# Create PHP application
cat > /var/www/html/index.php << 'PHPEOF'
<?php
$instance_id = "<?php echo '$${INSTANCE_ID}'; ?>";
$private_ip = "<?php echo '$${PRIVATE_IP}'; ?>";
$az = "<?php echo '$${AZ}'; ?>";
PHPEOF
# Overwrite with actual PHP file
cat > /var/www/html/index.php << PHPEOF
<?php
\$instance_id = "$INSTANCE_ID";
\$private_ip = "$PRIVATE_IP";
\$az = "$AZ";
\$db_host = "${db_host}";
\$db_user = "${db_user}";
\$db_pass = "${db_password}";
\$db_name = "${db_name}";
\$conn = new mysqli(\$db_host, \$db_user, \$db_pass, \$db_name);
\$db_connected = !\$conn->connect_error;
\$db_status = \$db_connected ? "Connected" : "Disconnected: " . \$conn->connect_error;
if (\$db_connected) {
\$conn->query("CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
\$result = \$conn->query("SELECT COUNT(*) as cnt FROM messages");
\$row = \$result->fetch_assoc();
if (\$row['cnt'] == 0) {
\$conn->query("INSERT INTO messages (name, message) VALUES
('Alice', 'Hello from the three-tier architecture!'),
('Bob', 'This data lives in the isolated RDS database tier.'),
('Charlie', 'Security groups ensure only the app tier can reach me.')");
}
if (\$_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset(\$_POST['action'])) {
if (\$_POST['action'] === 'add') {
\$stmt = \$conn->prepare("INSERT INTO messages (name, message) VALUES (?, ?)");
\$stmt->bind_param("ss", \$_POST['name'], \$_POST['message']);
\$stmt->execute();
} elseif (\$_POST['action'] === 'edit') {
\$stmt = \$conn->prepare("UPDATE messages SET name = ?, message = ? WHERE id = ?");
\$stmt->bind_param("ssi", \$_POST['name'], \$_POST['message'], \$_POST['id']);
\$stmt->execute();
} elseif (\$_POST['action'] === 'delete') {
\$stmt = \$conn->prepare("DELETE FROM messages WHERE id = ?");
\$stmt->bind_param("i", \$_POST['id']);
\$stmt->execute();
}
}
header("Location: /");
exit;
}
\$messages = \$conn->query("SELECT * FROM messages ORDER BY created_at DESC");
}
\$edit_id = isset(\$_GET['edit']) ? (int)\$_GET['edit'] : 0;
\$edit_row = null;
if (\$edit_id > 0 && \$db_connected) {
\$stmt = \$conn->prepare("SELECT * FROM messages WHERE id = ?");
\$stmt->bind_param("i", \$edit_id);
\$stmt->execute();
\$edit_row = \$stmt->get_result()->fetch_assoc();
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Three-Tier App</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 30px; }
.info-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 15px; }
.info-card h3 { margin: 0 0 5px 0; font-size: 14px; color: #6c757d; }
.info-card p { margin: 0; font-size: 16px; font-weight: bold; }
.status-connected { color: #28a745; }
.status-disconnected { color: #dc3545; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #dee2e6; }
th { background: #f8f9fa; }
form { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
input, textarea { width: 100%; padding: 8px; margin: 5px 0 15px 0; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; }
button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #007bff; color: white; }
.btn-danger { background: #dc3545; color: white; }
.btn-sm { padding: 4px 10px; font-size: 13px; }
</style>
</head>
<body>
<h1>Three-Tier Architecture Dashboard</h1>
<div class="info-grid">
<div class="info-card">
<h3>Instance ID</h3>
<p><?= htmlspecialchars(\$instance_id) ?></p>
</div>
<div class="info-card">
<h3>Private IP</h3>
<p><?= htmlspecialchars(\$private_ip) ?></p>
</div>
<div class="info-card">
<h3>Availability Zone</h3>
<p><?= htmlspecialchars(\$az) ?></p>
</div>
<div class="info-card">
<h3>RDS Connection</h3>
<p class="<?= \$db_connected ? 'status-connected' : 'status-disconnected' ?>"><?= htmlspecialchars(\$db_status) ?></p>
</div>
</div>
<?php if (\$db_connected): ?>
<h2><?= \$edit_row ? 'Edit Record' : 'Add New Record' ?></h2>
<form method="POST">
<input type="hidden" name="action" value="<?= \$edit_row ? 'edit' : 'add' ?>">
<?php if (\$edit_row): ?>
<input type="hidden" name="id" value="<?= \$edit_row['id'] ?>">
<?php endif; ?>
<label>Name:</label>
<input type="text" name="name" value="<?= htmlspecialchars(\$edit_row['name'] ?? '') ?>" required>
<label>Message:</label>
<textarea name="message" rows="3" required><?= htmlspecialchars(\$edit_row['message'] ?? '') ?></textarea>
<button type="submit" class="btn-primary"><?= \$edit_row ? 'Update' : 'Add' ?> Record</button>
<?php if (\$edit_row): ?>
<a href="/" style="margin-left:10px;">Cancel</a>
<?php endif; ?>
</form>
<h2>Messages (from RDS)</h2>
<table>
<tr><th>ID</th><th>Name</th><th>Message</th><th>Created</th><th>Actions</th></tr>
<?php while(\$row = \$messages->fetch_assoc()): ?>
<tr>
<td><?= \$row['id'] ?></td>
<td><?= htmlspecialchars(\$row['name']) ?></td>
<td><?= htmlspecialchars(\$row['message']) ?></td>
<td><?= \$row['created_at'] ?></td>
<td>
<a href="?edit=<?= \$row['id'] ?>"><button type="button" class="btn-primary btn-sm">Edit</button></a>
<form method="POST" style="display:inline;background:none;padding:0;margin:0;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= \$row['id'] ?>">
<button type="submit" class="btn-danger btn-sm" onclick="return confirm('Delete this record?')">Delete</button>
</form>
</td>
</tr>
<?php endwhile; ?>
</table>
<?php endif; ?>
</body>
</html>
PHPEOF
# Remove default Apache welcome page
rm -f /var/www/html/index.html
# Restart Apache to load PHP
systemctl restart httpd
Your three-tier architecture is fully operational. Traffic flows from the internet through the ALB, to EC2 instances in private subnets, and into the isolated RDS database. In the next section, you will have the chance to extend this with CloudWatch monitoring.
Secret mission
Auto Scaling with CloudWatch Alarms
Your infrastructure is running, but what happens when traffic spikes? In this bonus challenge, add CloudWatch metric alarms that monitor CPU utilization and healthy host count, wire them to SNS email notifications, then create Auto Scaling policies that automatically add instances when CPU is high and remove them when it drops back down.
Clean Up Your Resources
Clean Up Your Resources
This project provisions AWS resources that cost money while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
Your infrastructure costs approximately $2.50 per day while running. The NAT Gateway alone is $0.045/hour ($1.08/day). If you are done with the project, run terraform destroy as soon as possible to avoid unnecessary charges.
Resources you used:
- NAT Gateway ($0.045/hour, approximately $1.08/day).
- Application Load Balancer ($0.0225/hour, approximately $0.54/day).
- EC2 instances (2x t3.micro at $0.0104/hour each, approximately $0.50/day total).
- RDS instance (db.t3.micro at $0.017/hour, approximately $0.41/day).
- Elastic IP (no additional charge while the NAT Gateway is active).
- CloudWatch alarms (Secret Mission, first 10 alarms free).
- SNS topic and email subscription (Secret Mission, no additional charge at this scale).
- Local project files in C:\projects\three-tier-vpc.
Keep everything running
No action needed. Choose this if you are still actively building on this infrastructure and plan to return within the next few hours.
- Your AWS resources will continue running and incurring charges (approximately $2.50/day).
- Terraform and AWS CLI remain installed on your machine.
- Your project files in C:\projects\three-tier-vpc stay as they are.
Pause - I'll come back to this later
Pausing is not practical for this architecture. AWS resources like NAT Gateways, ALBs, RDS instances, and EC2 instances are either running or destroyed. There is no low-cost "paused" state.
If you want to take a break, your best option is to destroy the infrastructure and recreate it later with a single command.
- Run terraform destroy to remove all AWS resources and stop charges.
- Keep your local project files in C:\projects\three-tier-vpc intact.
- When you are ready to resume, run terraform apply to recreate everything from your saved configuration.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is the recommended option if you are finished with the project.
- Open Command Prompt and navigate to your project directory by running:
cd C:\projects\three-tier-vpc
- Destroy all AWS resources by running:
terraform destroy
- Terraform will show you a plan of all resources to be destroyed. Type yes when prompted to confirm.
- Wait for the destroy to complete. Terraform removes resources in the correct dependency order (EC2 instances first, then the VPC last).
- Once the destroy finishes, delete your local project files by running:
cd C:\
rmdir /s /q C:\projects\three-tier-vpc
- Type Y if prompted to confirm the deletion.
- Verify the folder is gone by running:
dir C:\projects
- You should no longer see three-tier-vpc in the output.
What about Terraform and AWS CLI?
Terraform and AWS CLI remain installed on your machine. They take minimal disk space and are useful for future projects. If you want to remove AWS CLI, open Settings then Apps then Installed apps and search for "AWS CLI" to uninstall it.
For Terraform, delete the terraform.exe file from the folder you placed it in (e.g., C:\terraform) and remove that folder from your system PATH.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-style three-tier network architecture on AWS, defined entirely as Terraform code.
What you learned:
- Defined a custom VPC with 6 subnets across 2 Availability Zones, isolated into public, app, and database tiers using security group chaining.
- Deployed an Application Load Balancer and Auto Scaling Group serving a PHP frontend that reads and writes to an RDS MySQL database.
- Wrote the entire infrastructure as Terraform code, making it reproducible with a single terraform apply command.
- Secret Mission: Added CloudWatch alarms with SNS notifications and Auto Scaling policies that dynamically scale instances based on CPU load.
Ready to quiz yourself?