Three-Tier Architecture with Terraform
Deploy a production-style three-tier AWS architecture using Terraform.
Introduction
30 Second Summary
Every time you open a website, your request travels through multiple layers of infrastructure before you see a response. Load balancers route traffic, application servers process requests, and databases store data.
In this project, you will use Terraform to deploy a production-style three-tier architecture on AWS. You'll define every resource as code and deploy the entire stack with a single command.
What You'll Build
A production-style three-tier AWS architecture defined entirely in Terraform configuration files, spanning networking, compute, database, and storage layers.
By the end of this project, you'll have:
- A VPC with public and private subnets across 2 Availability Zones, connected by an Internet Gateway and NAT Gateway.
- An Application Load Balancer distributing traffic to an Auto Scaling Group of EC2 instances, with security groups enforcing least-privilege access between tiers.
- A managed RDS MySQL database and S3 bucket, provisioned and verified through the full Terraform lifecycle: init, plan, apply, and destroy.
- Secret Mission: Add CloudWatch alarms and scaling policies to automatically scale EC2 instances based on CPU utilization.
Are there any prerequisites?
You'll need an AWS account (free tier eligible). No prior Terraform or AWS CLI experience is required.
Before We Start
Building cloud infrastructure by clicking through the AWS Console works for small experiments, but it falls apart as your architecture grows. Terraform lets you define your entire infrastructure in code files that you can version, review, and reuse across projects.
In this step, you'll install two CLI tools: Terraform (which reads your configuration files and creates AWS resources) and the AWS CLI (which connects Terraform to your AWS account). You'll also create an IAM user with programmatic access so Terraform can authenticate.
In this step, get ready to:
- Install Terraform CLI.
- Install AWS CLI v2.
- Configure AWS credentials for programmatic access.
Install and verify Terraform
Terraform is the infrastructure as code tool you'll use throughout this project. It reads .tf configuration files and translates them into API calls that provision real AWS resources.
- Check if Terraform is already installed by running this command in your terminal:
terraform version
✔️ I see version 1.15 or higher
Your Terraform install is ready. The project requires version 1.15.0 or higher, and you have it. Skip ahead to the next substep.
ⓧ I see an older version
This project requires Terraform 1.15.0 or higher. You need to upgrade your existing installation.
macOS
- Upgrade Terraform by running these commands:
brew tap hashicorp/tap
brew upgrade hashicorp/tap/terraform
- Verify the upgrade by running:
terraform version
You should see a version number of 1.15.0 or higher.
Windows
- Open the official Terraform install page: https://developer.hashicorp.com/terraform/install.
- Under Windows, click the AMD64 binary download link.
- Extract the downloaded zip file.
- Replace the old terraform.exe in your PATH folder (e.g., C:\terraform) with the new one.
- Open a new terminal in VS Code and verify by running:
terraform version
You should see a version number of 1.15.0 or higher.
ⓧ Command not found
Terraform is not installed yet. Follow the instructions for your operating system below.
macOS
- Install Terraform using Homebrew by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
- Verify the installation by running:
terraform version
You should see a version number starting with 1.15 or higher.
Windows
- Open the official Terraform install page: https://developer.hashicorp.com/terraform/install.
- Under Windows, click the AMD64 binary download link to download the zip file.
- Extract the zip file to get terraform.exe.
- Create a folder at C:\terraform and move terraform.exe into it.
- Search for Environment Variables in the Start menu.
- Click Edit the system environment variables.
- Click Environment Variables.
- Under System variables, select Path and click Edit.
- Click New and add C:\terraform.
- Click OK on all open dialogs to save.
- Open a new terminal in VS Code (the old terminal will not pick up PATH changes) and verify by running:
terraform version
You should see a version number starting with 1.15 or higher.
What is Terraform?
Terraform is an open-source infrastructure as code tool made by HashiCorp. You write configuration files that describe the resources you want (VPCs, EC2 instances, databases), and Terraform figures out the right order to create them.
When you need to change your infrastructure, you edit the files and run terraform apply again. Terraform calculates the difference and only changes what is necessary.
Install and verify the AWS CLI
The AWS CLI is the command-line tool that lets you interact with AWS services from your terminal. Terraform uses the credentials stored by the AWS CLI to authenticate when creating resources in your account.
- Check if the AWS CLI is already installed by running:
aws --version
✔️ I see version 2.x
Your AWS CLI v2 is ready. Skip ahead to the next substep.
ⓧ I see version 1.x
This project requires AWS CLI version 2. Version 1 is missing features that Terraform relies on. You need to upgrade.
macOS
- Download the official macOS installer: https://awscli.amazonaws.com/AWSCLIV2.pkg.
- Run the downloaded .pkg file and follow the on-screen instructions.
- Open a new terminal and verify by running:
aws --version
You should see output starting with aws-cli/2. followed by a version number.
Windows
- Download the official Windows MSI installer: https://awscli.amazonaws.com/AWSCLIV2.msi.
- Run the downloaded .msi file and follow the installer prompts.
- Open a new terminal in VS Code and verify by running:
aws --version
You should see output starting with aws-cli/2. followed by a version number.
ⓧ Command not found
The AWS CLI is not installed yet. Follow the instructions for your operating system below.
macOS
- Download the official macOS installer: https://awscli.amazonaws.com/AWSCLIV2.pkg.
- Run the downloaded .pkg file and follow the on-screen instructions.
- Open a new terminal and verify by running:
aws --version
You should see output starting with aws-cli/2. followed by a version number.
Windows
- Download the official Windows MSI installer: https://awscli.amazonaws.com/AWSCLIV2.msi.
- Run the downloaded .msi file and follow the installer prompts.
- Open a new terminal in VS Code and verify by running:
aws --version
You should see output starting with aws-cli/2. followed by a version number.
What is the AWS CLI?
The AWS CLI is a command-line tool that lets you manage AWS services without using the web console. More importantly for this project, it stores your AWS credentials in a local configuration file that Terraform reads automatically when provisioning resources.
Create an IAM user with programmatic access
Terraform needs credentials to authenticate with your AWS account. You'll create a dedicated IAM user with an access key that the AWS CLI stores locally.
- Open the AWS Console in your browser and sign in to your account.
- In the search bar at the top, type IAM and select IAM from the results.
- In the left sidebar, click Users.
- Click Create user.
- Enter a user name (e.g., terraform-user) and click Next.
- Select Attach policies directly.
- Search for AdministratorAccess and check the box next to it.
- Click Next.
- Click Create user.
Why AdministratorAccess?
For a learning project, AdministratorAccess keeps things simple by granting full permissions. In production, you would follow the principle of least privilege and grant only the specific permissions your infrastructure needs.
Now you need to create an access key for this user.
- Click on your newly created user's name to open their details.
- Select the Security credentials tab.
- Scroll down to Access keys and click Create access key.
- Select Command Line Interface (CLI) as the use case.
- Check the confirmation box at the bottom.
- Click Next.
- Click Create access key.
- Copy the Access key ID and Secret access key to a safe temporary location (you will need both in the next step).
Keep your keys safe
This is the only time the Secret access key will be shown. Copy it now and paste it somewhere secure. Never commit access keys to version control or share them publicly.
Now configure the AWS CLI with your new credentials.
- Configure the AWS CLI by running this command:
aws configure
What will this command ask?
The aws configure command prompts you for four values one at a time. Enter them as follows:
- AWS Access Key ID: paste your Access Key ID.
- AWS Secret Access Key: paste your Secret Access Key.
- Default region name: enter us-east-1.
- Default output format: enter json.
- Verify your credentials are working by running:
aws sts get-caller-identity
You should see JSON output showing your Account ID, User ARN, and the IAM user name you just created.
Not seeing your account details?
If you see an error about invalid credentials, double-check that you pasted the Access Key ID and Secret Access Key correctly when running aws configure. Run aws configure again to re-enter them.
If you see a "command not found" error, make sure you opened a new terminal after installing the AWS CLI.
Help me troubleshoot my aws sts get-caller-identity error
Your environment is ready to go. Next up, you'll start writing Terraform configuration files to build a complete VPC and networking layer.
Install Terraform and AWS CLI
With Terraform and AWS CLI configured on your machine, you're ready to start writing infrastructure as code. Every piece of your three-tier architecture will be defined in Terraform configuration files.
In this step, you'll create your project folder and write three foundational files. These files tell Terraform which cloud provider to use, what input values your project accepts, and what those values are set to.
In this step, get ready to:
- Create a project folder on your Desktop and open it in VS Code.
- Write Terraform configuration files for the AWS provider and input variables.
- Run terraform init to download the required provider plugins.
Create your project folder
Terraform needs a dedicated folder to store its configuration files and track state. You'll create this folder on your Desktop so it's easy to find.
- 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 to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir three-tier-terraform
cd three-tier-terraform
- Open this folder in VS Code by running:
code .
What does code . do?
The code . command launches VS Code with the current folder as your workspace. The dot (.) means "this directory." VS Code adds this command to your system PATH during installation.
- Confirm the folder opened in VS Code by checking the Explorer sidebar on the left. It should show the folder name three-tier-terraform at the top.
code . not recognized?
If your terminal says the code command is not found, VS Code may not be on your PATH. Close the terminal, reopen it, and try again. If it still fails, open VS Code manually, then use File > Open Folder and select the three-tier-terraform folder from your Desktop.
Need more help? Why can't my terminal find the code command?
Write the provider and variable configuration
Terraform configuration files use the .tf extension and are written in HCL (HashiCorp Configuration Language). You'll create three files that form the foundation of every Terraform project.
- In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Create a new file called main.tf by clicking the New File icon in the Explorer sidebar (or press Ctrl+N, then Ctrl+S and save as main.tf).
- Paste the following code into main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
What does this code do?
The terraform block declares which providers your project needs. Here you're requiring the AWS provider (version ~> 6.0) from HashiCorp's registry.
The provider "aws" block configures the AWS provider with a region. Instead of hardcoding the region, it references a variable (var.aws_region) that you'll define next.
- Save main.tf with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.tf before moving on.
ⓧ I'd like to double check the full code
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
- Create a new file called variables.tf in the same folder.
- Paste the following code into variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "three-tier"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
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 defines an input your project accepts. The default value is used if no override is provided. The type enforces what kind of value is allowed.
The sensitive = true flag on db_username and db_password tells Terraform to redact these values from command output. This prevents credentials from appearing in logs.
- Save variables.tf.
✔️ Awesome, I've got everything!
Great. Make sure you've saved variables.tf before moving on.
ⓧ I'd like to double check the full code
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "three-tier"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "db_username" {
description = "Database administrator username"
type = string
default = "admin"
sensitive = true
}
variable "db_password" {
description = "Database administrator password"
type = string
sensitive = true
}
- Create a new file called terraform.tfvars in the same folder.
- Paste the following code into terraform.tfvars:
aws_region = "us-east-1"
project_name = "three-tier"
environment = "dev"
db_username = "admin"
db_password = "ChangeMe123!"
What is terraform.tfvars?
Terraform automatically loads any file named terraform.tfvars and uses its values to populate the variables you defined in variables.tf. This separates your variable definitions (structure) from their actual values (data).
The db_password here is a placeholder for this learning project. In production, you would never commit credentials to a file. You would use environment variables or a secrets manager instead.
- Save terraform.tfvars.
- Confirm your Explorer sidebar now shows three files: main.tf, variables.tf, and terraform.tfvars.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
aws_region = "us-east-1"
project_name = "three-tier"
environment = "dev"
db_username = "admin"
db_password = "ChangeMe123!"
Seeing red squiggly lines in your .tf files?
VS Code may not recognize .tf files by default. This is purely cosmetic and won't affect Terraform. If you want syntax highlighting, install the HashiCorp Terraform extension from the VS Code marketplace.
Need help? How do I get Terraform syntax highlighting in VS Code?
Initialize Terraform
Before Terraform can manage any resources, it needs to download the provider plugins specified in your configuration. The terraform init command handles this.
- In the VS Code integrated terminal, initialize your project by running:
terraform init
- Wait for the command to complete. You should see a message that says Terraform has been successfully initialized! in your terminal output.
What did terraform init do?
Terraform downloaded the AWS provider plugin and stored it in a hidden .terraform directory inside your project folder. It also created a .terraform.lock.hcl file that pins the exact provider version for consistency across runs.
You only need to run terraform init once per project, unless you add new providers or change backend configuration.
🙋♀️ Seeing an error during terraform init?
Check that your main.tf file is saved and has no typos in the provider block. The source must be exactly hashicorp/aws and the version must be ~> 6.0.
Also confirm your terminal is inside the three-tier-terraform folder. Run ls (macOS/Linux) or dir (Windows) to verify you can see main.tf in the output.
Still stuck? Help me debug my terraform init error
Your Terraform project is initialized and ready to define real AWS resources. Next up, you'll build the entire networking layer including a VPC, subnets, gateways, and route tables.
Build the VPC and Networking Layer
Your tools are installed and your Terraform project is initialized. Now it's time to build the foundation every other resource will live on: the network.
A three-tier architecture separates your application into distinct layers (web, app, database), each in its own set of subnets with strict traffic rules. In this step, you'll define the entire networking layer in a single Terraform file.
In this step, get ready to:
- Create a VPC with 6 subnets spanning 2 Availability Zones.
- Set up an Internet Gateway and NAT Gateway for routing.
- Run terraform plan to preview the networking resources.
Create the VPC and subnet resources
Every AWS resource needs a network to live in. A VPC (Virtual Private Cloud) is your isolated network inside AWS, and subnets divide it into smaller segments for each tier.
- In VS Code, create a new file called vpc.tf inside your three-tier-terraform folder.
- Add the data source and VPC resource by pasting this code:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
What does this code do?
The data "aws_availability_zones" block queries AWS for all available AZs in your region. This lets Terraform automatically spread subnets across zones without hardcoding zone names.
The aws_vpc resource creates your isolated network with a /16 CIDR block, giving you 65,536 IP addresses to carve into subnets. DNS settings are enabled so resources inside the VPC can resolve hostnames.
- Save vpc.tf.
You won't see a change yet. The next chunk builds on top of this VPC.
- Add the subnet resources below the VPC resource in vpc.tf:
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
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}"
}
}
resource "aws_subnet" "private_app" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 3}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-app-${count.index + 1}"
}
}
resource "aws_subnet" "private_db" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 5}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-db-${count.index + 1}"
}
}
What does this code do?
You're creating 6 subnets total, organized into 3 tiers across 2 Availability Zones:
- 2 public subnets (10.0.1.0/24, 10.0.2.0/24) for the load balancer. map_public_ip_on_launch = true gives resources here public IPs automatically.
- 2 private app subnets (10.0.3.0/24, 10.0.4.0/24) for EC2 instances. No public IPs here.
- 2 private database subnets (10.0.5.0/24, 10.0.6.0/24) for the RDS instance. Fully isolated from the internet.
The count = 2 parameter creates two copies of each resource, one per AZ. This gives your architecture high availability.
- Save vpc.tf.
You won't see a change yet. The next chunk connects these subnets to the internet.
- Add the Internet Gateway, Elastic IP, and NAT Gateway below the subnets in vpc.tf:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
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[0].id
tags = {
Name = "${var.project_name}-nat-gw"
}
depends_on = [aws_internet_gateway.main]
}
What do these gateways do?
The Internet Gateway connects your public subnets to the internet. Resources in public subnets can send and receive traffic directly.
The NAT Gateway lets private subnets reach the internet (for software updates) without being reachable from the internet. It sits in a public subnet and uses an Elastic IP as its stable public address.
- Save vpc.tf.
You won't see a change yet. The final chunk tells traffic where to go.
- Add the route tables and associations at the bottom of vpc.tf:
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
}
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_app" {
count = 2
subnet_id = aws_subnet.private_app[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_db" {
count = 2
subnet_id = aws_subnet.private_db[count.index].id
route_table_id = aws_route_table.private.id
}
What do route tables do?
Route tables are like signposts telling traffic where to go. The public route table sends all outbound traffic (0.0.0.0/0) through the Internet Gateway.
The private route table sends outbound traffic through the NAT Gateway instead. The associations link each subnet to the correct route table.
- Save vpc.tf.
Seeing red squiggly lines in VS Code?
If VS Code shows syntax warnings, make sure each resource block has matching opening and closing braces. Also check that string interpolations like ${var.project_name} are inside double quotes, not single quotes.
help me fix syntax errors in my vpc.tf file
✔️ Awesome, I've got everything!
Great. Make sure you've saved vpc.tf with all four sections: data source + VPC, subnets, gateways, and route tables.
ⓧ I'd like to double check the full code
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
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}"
}
}
resource "aws_subnet" "private_app" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 3}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-app-${count.index + 1}"
}
}
resource "aws_subnet" "private_db" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 5}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-private-db-${count.index + 1}"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
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[0].id
tags = {
Name = "${var.project_name}-nat-gw"
}
depends_on = [aws_internet_gateway.main]
}
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
}
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_app" {
count = 2
subnet_id = aws_subnet.private_app[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_db" {
count = 2
subnet_id = aws_subnet.private_db[count.index].id
route_table_id = aws_route_table.private.id
}
Preview the infrastructure with terraform plan
Before deploying anything to AWS, Terraform lets you preview exactly what it will create. This is your safety check to catch misconfigurations before they cost money.
- Run the plan command in your terminal:
terraform plan
- Wait for the plan to finish. You should see output ending with a summary line like Plan: X to add, 0 to change, 0 to destroy where X is the number of resources.
- Confirm the plan lists the VPC, 6 subnets (2 public, 2 private app, 2 private db), an Internet Gateway, an Elastic IP, a NAT Gateway, 2 route tables, and 6 route table associations.
What should I see?
The plan output shows every resource Terraform will create, each prefixed with a + sign. Scroll through to see entries like aws_vpc.main, aws_subnet.public[0], aws_nat_gateway.main, and so on.
No real resources are created yet. This is a dry run that validates your configuration syntax and logic.
🙋♀️ Seeing errors in the plan output?
If you see a message about missing references, check that resource names in your route table associations match the exact resource names above (aws_subnet.public, aws_subnet.private_app, aws_subnet.private_db).
If you see a provider error, make sure you ran terraform init in the previous step.
help me debug my terraform plan errors for the VPC networking layer
Your entire networking layer is defined and validated. Next up, you'll add security groups and IAM roles to control who can talk to what inside this network.
Define Security Groups and IAM
Your VPC network is in place with subnets, gateways, and route tables ready to move traffic. Now you need to control who can talk to whom inside that network.
In a three-tier architecture, each layer should only accept traffic from the layer directly above it. Security groups act as virtual firewalls for each tier, and IAM roles grant your compute resources permission to access other AWS services.
In this step, get ready to:
- Create three security groups that chain access between the ALB, EC2, and RDS tiers.
- Define an IAM role and instance profile granting EC2 instances read access to S3.
- Run terraform plan to verify the new resources.
Create security groups for the three-tier access chain
The security group chain enforces least-privilege access between tiers. The ALB accepts traffic from the internet, EC2 instances accept traffic only from the ALB, and the RDS database accepts traffic only from EC2.
- Create a new file called security.tf in your three-tier-terraform folder.
- Add the three security group resources by pasting this code:
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from internet"
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"
}
}
resource "aws_security_group" "ec2" {
name = "${var.project_name}-ec2-sg"
description = "Security group for EC2 instances"
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}-ec2-sg"
}
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
description = "Security group for RDS instance"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from EC2"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ec2.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-rds-sg"
}
}
What does this code do?
These three security groups form a chain where each tier only accepts traffic from the tier above it:
- alb allows inbound HTTP (port 80) from anywhere on the internet. This is the only group exposed to the public.
- ec2 allows inbound port 80 only from the ALB security group. No direct internet access reaches your app servers.
- rds allows inbound port 3306 (MySQL) only from the EC2 security group. The database is completely isolated from the internet and the ALB.
All three groups allow unrestricted outbound traffic (protocol = "-1") so each tier can reach the internet for updates and AWS API calls.
💡 Why chain security groups instead of using IP ranges?
Referencing another security group (like security_groups = [aws_security_group.alb.id]) means the rule automatically applies to any resource in that group, even as instances scale up or down.
If you used CIDR blocks instead, you would need to update the rules every time an IP address changes. Security group chaining keeps your rules dynamic and maintainable.
Define the IAM role and instance profile
Your EC2 instances will need permission to read objects from S3. Instead of embedding credentials on each instance, you create an IAM role and attach it through an instance profile.
- Add the following IAM resources to the bottom of security.tf, below the RDS security group:
resource "aws_iam_role" "ec2_role" {
name = "${var.project_name}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-ec2-role"
}
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
resource "aws_iam_instance_profile" "ec2_profile" {
name = "${var.project_name}-ec2-profile"
role = aws_iam_role.ec2_role.name
}
What does this code do?
- aws_iam_role creates a role that the EC2 service can assume. The assume_role_policy tells AWS which service is allowed to use this role.
- aws_iam_role_policy_attachment attaches the AWS-managed AmazonS3ReadOnlyAccess policy. This gives the role permission to read from any S3 bucket in your account.
- aws_iam_instance_profile wraps the role so it can be attached to EC2 instances. EC2 instances cannot use IAM roles directly. They need an instance profile as a container.
- Save security.tf.
Seeing a syntax error?
Make sure the jsonencode block in assume_role_policy uses curly braces {} and square brackets [] correctly. A missing bracket inside the Statement array is the most common mistake.
help me fix a syntax error in my Terraform IAM assume_role_policy jsonencode block
Run terraform plan to verify the security resources
- Run terraform plan to preview the updated resource count:
terraform plan
- Scroll to the bottom of the output. You should see a summary line showing the new security and IAM resources added alongside your existing networking resources.
What should I see?
The plan output should end with a line like Plan: X to add, 0 to change, 0 to destroy where X includes your 3 security groups, 1 IAM role, 1 policy attachment, and 1 instance profile in addition to the networking resources from the previous step.
🙋♀️ Plan showing errors?
If you see a reference error mentioning aws_vpc.main.id, confirm that vpc.tf is still in the same folder as security.tf. Terraform reads all .tf files in the directory together.
help me debug why terraform plan fails after adding my security.tf file
✔️ Awesome, I've got everything!
Great. Make sure you have saved security.tf with all three security groups and the IAM resources.
ⓧ I'd like to double check the full code
Here is the complete security.tf file:
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from internet"
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"
}
}
resource "aws_security_group" "ec2" {
name = "${var.project_name}-ec2-sg"
description = "Security group for EC2 instances"
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}-ec2-sg"
}
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
description = "Security group for RDS instance"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from EC2"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ec2.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-rds-sg"
}
}
resource "aws_iam_role" "ec2_role" {
name = "${var.project_name}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-ec2-role"
}
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
resource "aws_iam_instance_profile" "ec2_profile" {
name = "${var.project_name}-ec2-profile"
role = aws_iam_role.ec2_role.name
}
Your security layer is locked down. Next up, you will create the Application Load Balancer, Auto Scaling Group, RDS database, and S3 bucket that make up the three tiers themselves.
Create the ALB, Auto Scaling, RDS, and S3
Your networking foundation and security rules are in place. Now it is time to build the actual services that sit on top of this infrastructure.
In this step, you will create the Application Load Balancer that receives user traffic, the Auto Scaling Group that manages your EC2 fleet, an RDS MySQL database, and an S3 bucket for static assets.
In this step, get ready to:
- Create the ALB, target group, and listener that route traffic into your private app tier.
- Define a launch template and Auto Scaling Group that keeps 2 EC2 instances running.
- Provision an RDS MySQL database, S3 bucket, and Terraform outputs.
Create the load balancer and compute layer
The ALB sits in your public subnets and distributes incoming HTTP requests across EC2 instances in the private app subnets. The compute layer uses a launch template to define what each instance looks like, and an Auto Scaling Group to keep the desired number of instances running.
- Create a new file called alb.tf in your three-tier-terraform folder and paste 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"
}
}
resource "aws_lb_target_group" "main" {
name = "${var.project_name}-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
}
tags = {
Name = "${var.project_name}-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.main.arn
}
}
What does this code do?
This file defines three tightly connected resources that form the traffic entry point for your architecture.
- aws_lb.main creates an internet-facing Application Load Balancer in your public subnets, attached to the ALB security group you defined earlier.
- aws_lb_target_group.main tells the ALB where to send traffic (port 80) and how to check if instances are healthy (HTTP request to /).
- aws_lb_listener.http listens on port 80 and forwards every request to the target group.
- Save alb.tf.
You will not see a change yet because the ALB needs a compute layer behind it. That is what you will build next.
- Create a new file called compute.tf and paste the following code:
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}-lt-"
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.ec2.id]
iam_instance_profile {
name = aws_iam_instance_profile.ec2_profile.name
}
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
INSTANCE_ID=$(ec2-metadata -i | cut -d ' ' -f2)
HOSTNAME=$(ec2-metadata -h | cut -d ' ' -f2)
LOCAL_IP=$(ec2-metadata -o | cut -d ' ' -f2)
echo "<h1>Three-Tier Architecture</h1><p>Instance: $INSTANCE_ID</p><p>Hostname: $HOSTNAME</p><p>IP: $LOCAL_IP</p>" > /var/www/html/index.html
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app-instance"
}
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-asg"
desired_capacity = 2
max_size = 4
min_size = 2
vpc_zone_identifier = aws_subnet.private_app[*].id
target_group_arns = [aws_lb_target_group.main.arn]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
health_check_type = "ELB"
health_check_grace_period = 300
tag {
key = "Name"
value = "${var.project_name}-app-instance"
propagate_at_launch = true
}
}
What does this code do?
This file builds the compute tier in three parts.
- data "aws_ami" looks up the latest Amazon Linux 2023 AMI so you never need to hardcode a region-specific AMI ID.
- aws_launch_template.app defines the blueprint for each EC2 instance: a t3.micro running Apache (httpd), attached to the EC2 security group and IAM instance profile. The user data script installs Apache and writes a page showing the instance ID, hostname, and private IP address. When you refresh the ALB URL, different values confirm traffic is hitting different instances.
- aws_autoscaling_group.app maintains 2 instances (min 2, max 4) in the private app subnets. It registers instances with the ALB target group and uses ELB health checks with a 300-second grace period for startup.
- Save compute.tf.
Seeing a syntax error in the user_data block?
The heredoc (<<-EOF) must have its closing EOF on its own line, followed by the closing parenthesis. Make sure you copied the entire block including those last two lines.
Help me fix this heredoc syntax error
✔️ Awesome, I've got everything!
Great. Make sure both alb.tf and compute.tf are saved.
ⓧ I'd like to double check the full code
Here is the complete alb.tf:
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
tags = {
Name = "${var.project_name}-alb"
}
}
resource "aws_lb_target_group" "main" {
name = "${var.project_name}-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
}
tags = {
Name = "${var.project_name}-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.main.arn
}
}
Here is the complete compute.tf:
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}-lt-"
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.ec2.id]
iam_instance_profile {
name = aws_iam_instance_profile.ec2_profile.name
}
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
INSTANCE_ID=$(ec2-metadata -i | cut -d ' ' -f2)
HOSTNAME=$(ec2-metadata -h | cut -d ' ' -f2)
LOCAL_IP=$(ec2-metadata -o | cut -d ' ' -f2)
echo "<h1>Three-Tier Architecture</h1><p>Instance: $INSTANCE_ID</p><p>Hostname: $HOSTNAME</p><p>IP: $LOCAL_IP</p>" > /var/www/html/index.html
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app-instance"
}
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-asg"
desired_capacity = 2
max_size = 4
min_size = 2
vpc_zone_identifier = aws_subnet.private_app[*].id
target_group_arns = [aws_lb_target_group.main.arn]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
health_check_type = "ELB"
health_check_grace_period = 300
tag {
key = "Name"
value = "${var.project_name}-app-instance"
propagate_at_launch = true
}
}
Create the database and storage layer
The database tier lives in its own isolated private subnets. Only the EC2 instances (via the RDS security group) can reach it on port 3306. The S3 bucket stores static assets that your EC2 instances can read using the IAM instance profile.
- Create a new file called database.tf and paste the following code:
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = aws_subnet.private_db[*].id
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp2"
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.rds.id]
skip_final_snapshot = true
multi_az = false
tags = {
Name = "${var.project_name}-mysql"
}
}
What does this code do?
- aws_db_subnet_group.main groups the two database-tier private subnets so RDS knows where to place the instance.
- aws_db_instance.main creates a MySQL 8.0 instance on the smallest available class (db.t3.micro) with 20 GB of storage. The username and password come from your terraform.tfvars file.
- skip_final_snapshot = true means Terraform can destroy this instance without creating a backup snapshot first. This keeps cleanup simple for a learning project.
- Save database.tf.
- Create a new file called storage.tf and paste the following code:
resource "aws_s3_bucket" "assets" {
bucket_prefix = "${var.project_name}-assets-"
tags = {
Name = "${var.project_name}-assets"
}
}
Why bucket_prefix instead of bucket?
S3 bucket names must be globally unique across all AWS accounts. Using bucket_prefix lets Terraform append a random suffix to your prefix, avoiding name collisions without you needing to invent a unique name.
- Save storage.tf.
✔️ Awesome, I've got everything!
Great. Make sure both database.tf and storage.tf are saved.
ⓧ I'd like to double check the full code
Here is the complete database.tf:
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = aws_subnet.private_db[*].id
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp2"
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.rds.id]
skip_final_snapshot = true
multi_az = false
tags = {
Name = "${var.project_name}-mysql"
}
}
Here is the complete storage.tf:
resource "aws_s3_bucket" "assets" {
bucket_prefix = "${var.project_name}-assets-"
tags = {
Name = "${var.project_name}-assets"
}
}
Create outputs and run terraform plan
Terraform outputs let you surface important values after a deployment. You will output the ALB DNS name (so you can visit your site), the RDS endpoint, and the S3 bucket name.
- Create a new file called outputs.tf and paste the following 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 "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.assets.id
}
What does this code do?
Each output block exports a value that Terraform displays after terraform apply completes. You will use the alb_dns_name in the next step to verify your deployment works.
- Save outputs.tf.
- Preview your full infrastructure plan by running:
terraform plan
- Scroll to the bottom of the output. You should see a summary line that says something like Plan: 30 to add, 0 to change, 0 to destroy. (the exact number may vary slightly).
What should I see?
The plan output lists every resource Terraform will create. At the bottom, the summary confirms approximately 30+ resources to add. This includes everything from your VPC and subnets through to the ALB, Auto Scaling Group, RDS instance, and S3 bucket.
🙋♀️ Seeing errors in the plan?
Check that all your .tf files are saved. Terraform reads every .tf file in the directory, so an unsaved file means missing resources.
If you see a reference error (like "resource not found"), confirm that the resource names in your new files match exactly what the security groups and VPC files export. For example, aws_security_group.alb.id requires the ALB security group to be named alb in security.tf.
Help me debug my terraform plan errors
✔️ Awesome, I've got everything!
Great. Make sure outputs.tf is saved.
ⓧ I'd like to double check the full code
Here is the complete outputs.tf:
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 "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.assets.id
}
Your entire infrastructure is now defined in code. Next up, you will deploy it all to AWS and verify the architecture is working end to end.
Deploy, Verify, and Destroy
Your Terraform configuration is complete. Every file defines a piece of the three-tier puzzle, from networking to compute to database.
Now it's time to bring everything to life. In this step, you'll deploy the full architecture to AWS, confirm it works by hitting the load balancer in your browser, and then tear it all down to avoid ongoing charges.
In this step, get ready to:
- Deploy the full infrastructure with terraform apply.
- Verify the ALB serves web traffic from your EC2 instances.
- Destroy all resources with terraform destroy to prevent charges.
Deploy the infrastructure with terraform apply
The terraform apply command reads all your .tf files, generates an execution plan, and then creates the real AWS resources.
- Deploy your infrastructure by running this command:
terraform apply
- Terraform displays the full plan first. Review it, then type yes and press Enter to confirm.
How long does this take?
The full deployment takes approximately 8-12 minutes. The RDS MySQL instance alone needs 5-8 minutes to provision, and the NAT Gateway takes 1-2 minutes.
You'll see resources being created one by one in your terminal. Let it run until you see the Apply complete! message with the output values.
- Wait for the deployment to complete. When finished, Terraform prints three outputs: alb_dns_name, rds_endpoint, and s3_bucket_name.
- Copy the alb_dns_name value from the output. You'll use this in the next substep.
Seeing an error during apply?
- If you see a naming conflict error, another resource with the same name may already exist in your AWS account. Delete the conflicting resource in the AWS Console, then re-run terraform apply.
- If you see a quota or limit error, your AWS account may have reached a resource limit for the region. Check the error message for which resource is affected.
- If the apply hangs for more than 15 minutes, do not cancel it. Some resources (especially RDS) genuinely take time to provision.
Why is my terraform apply failing?
Verify the deployment in your browser
Your Application Load Balancer is now distributing traffic to EC2 instances running in private subnets. The ALB DNS name is the public entry point to your architecture.
- Open a new browser tab.
- Paste the alb_dns_name value from your Terraform output into the address bar and press Enter.
- You should see a page with the heading Three-Tier Architecture and an instance ID below it.
What does the instance ID prove?
Each EC2 instance writes its own unique instance ID to the web page. If you refresh the browser a few times, you may see the instance ID change. That confirms the ALB is distributing requests across multiple instances in your Auto Scaling Group.
- Open the AWS Console and navigate to EC2 in the search bar.
- In the left sidebar, click Auto Scaling Groups.
- Click on three-tier-asg.
- Click the Instance management tab. You should see 2 instances with a Healthy lifecycle state.
Page not loading yet?
EC2 instances need a minute or two after launch to finish running their user data script (installing and starting the web server). If you see a 502 or 503 error, wait 2-3 minutes and refresh.
Destroy all resources to prevent charges
Your infrastructure is running and incurring charges. The NAT Gateway, ALB, and RDS instance are not free-tier eligible, so it's important to tear everything down once you've verified it works.
- Return to your terminal in VS Code.
- Destroy all resources by running this command:
terraform destroy
- Terraform displays every resource it will delete. Type yes and press Enter to confirm.
- Wait for the destroy to complete. This typically takes 5-8 minutes as some resources (like the NAT Gateway and RDS instance) take time to delete.
What does terraform destroy do?
Terraform reads its state file to identify every resource it created, then deletes them in the correct dependency order. It removes the EC2 instances before the subnets they live in, and the subnets before the VPC.
At the end, you should see a message confirming all resources were destroyed with 0 resources remaining.
- Verify the output shows Destroy complete! Resources: 0 added, 0 changed with the total number of resources destroyed.
Destroy stuck or failing?
- If the destroy hangs on the RDS instance, this is normal. RDS deletion can take several minutes. Let it continue.
- If you see a dependency error, run terraform destroy again. Terraform retries and usually succeeds on the second pass.
- If a specific resource fails to delete, you can manually delete it in the AWS Console, then run terraform destroy once more to clean up the rest.
Why is my terraform destroy failing or stuck?
You've completed the full Terraform lifecycle: writing configuration, planning, applying, verifying, and destroying. Your configuration files are still intact on your machine, ready to deploy again any time with a single terraform apply.
Secret mission
Auto Scaling with CloudWatch Alarms
Your Auto Scaling Group has a fixed capacity of 2 instances. In production, traffic spikes and lulls are constant. Can you add CloudWatch alarms that automatically scale your EC2 fleet based on CPU utilization?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The NAT Gateway, ALB, and RDS instance in this project incur hourly charges when left running.
Cost warning
The NAT Gateway (~$0.045/hr), ALB (~$0.0225/hr), and RDS instance (~$0.017/hr for db.t3.micro) all incur charges while deployed. If you did not run terraform destroy in Step 5, do so immediately to avoid accumulating costs.
Resources you used:
- AWS infrastructure: VPC, subnets, Internet Gateway, NAT Gateway, Elastic IP, security groups, ALB, target group, listener, launch template, Auto Scaling Group, EC2 instances, RDS instance, S3 bucket, IAM role/instance profile, route tables.
- CloudWatch alarms and Auto Scaling policies (if you completed the Secret Mission).
- Local project files: three-tier-terraform folder on your Desktop.
- IAM user with programmatic access (created in the AWS Console).
- AWS CLI credentials stored in your local ~/.aws/ directory.
Keep everything running
Not recommended for this project. If your AWS infrastructure is still deployed, the NAT Gateway, ALB, and RDS instance will continue to incur charges every hour.
- Your local three-tier-terraform folder remains on your Desktop with all your Terraform configuration files.
- Terraform CLI and AWS CLI remain installed on your machine.
- Your IAM user and stored AWS credentials remain active.
- If you plan to keep resources deployed, monitor your AWS billing dashboard closely.
Pause - I'll come back to this later
Pausing individual resources is not practical for this project. The only way to stop charges is to destroy the infrastructure entirely. You can always redeploy later with terraform apply.
- Run terraform destroy from your three-tier-terraform folder to tear down all AWS resources.
- Type yes when prompted to confirm.
- Keep your local three-tier-terraform folder intact so you can redeploy whenever you want.
- Keep your IAM user and AWS CLI credentials in place for future use.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each of these steps to ensure nothing is left behind.
- If you have not already run terraform destroy, open your terminal, navigate to the three-tier-terraform folder, and run it now. Type yes to confirm.
- Verify in the AWS Console that no resources remain in us-east-1. Check the VPC, EC2, RDS, and S3 dashboards.
- Delete the IAM user you created. In the AWS Console, go to the IAM service, select Users in the left navigation pane, select the checkbox next to your user, click Delete, type the username to confirm, then click Delete again.
- Delete your local AWS credentials by deleting the ~/.aws/ folder. On Windows, open File Explorer and navigate to C:\Users\<YourUsername>\.aws and delete the folder.
- Delete your local project files by running this in your terminal:
cd ~/Desktop
rm -rf three-tier-terraform
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force three-tier-terraform
- To uninstall AWS CLI on Windows, open Settings, go to Apps, find AWS Command Line Interface in the list, and click Uninstall.
- To uninstall Terraform on Windows, delete the terraform.exe file from the folder you placed it in (e.g., C:\terraform) and remove that folder from your system PATH.
Nice Work!
Nice Work!
You just designed, codified, and deployed a production-style three-tier architecture on AWS, entirely managed by Terraform configuration files.
You've learned how to:
- Build a complete three-tier VPC architecture with public and private subnets across 2 Availability Zones, including an Internet Gateway, NAT Gateway, and route tables for traffic flow.
- Implement security group chaining enforcing least-privilege access between the ALB, EC2, and RDS tiers, and deploy an Application Load Balancer distributing traffic to an Auto Scaling Group of EC2 instances.
- Practice the full Terraform lifecycle: init, plan, apply, verify, and destroy to manage 30+ cloud resources as code.
- Secret Mission: Added CloudWatch alarms and Auto Scaling policies for dynamic CPU-based scaling.
Ready to quiz yourself?