Three-Tier Architecture with Terraform

Deploy a secure three-tier AWS architecture using Terraform and IaC.

Introduction

30 Second Summary

Every app you use has invisible walls between what the public can see and what stays locked away. Banks, social media, and e-commerce sites all separate their web servers from their databases so that a breach in one layer does not expose everything.

In this project, you will build a three-tier network architecture on AWS using Terraform. You'll create isolated layers for a load balancer, web server, and database, then deploy the entire setup with a single command.

What You'll Build

A production-style three-tier VPC on AWS with public, private, and database subnets, all defined and deployed through Terraform code.

By the end of this project, you'll have:

  • A VPC spanning 2 Availability Zones with strict network isolation between public, private, and database tiers.
  • An Application Load Balancer distributing traffic to an EC2 instance that the public internet cannot reach directly.
  • An RDS MySQL database completely cut off from the internet, accessible only from the app tier.
  • Secret Mission: Add an Auto Scaling Group so the architecture can scale horizontally and self-heal.

Do I need AWS or Terraform experience?

No prior Terraform or AWS experience is required. Basic command-line familiarity is all you need to get started.

Before We Start

Before you can define any infrastructure, you need two tools on your machine: Terraform (which turns your code into real cloud resources) and the AWS CLI (which authenticates your machine with AWS).

In this step, you will install both tools, connect them to your AWS account, and create the project folder where all your Terraform configuration files will live.

In this step, get ready to:
  • Install Terraform and verify it runs.
  • Install and configure the AWS CLI with your credentials.
  • Create your project folder and write the initial provider configuration.
Install Terraform

Terraform is the tool that reads your .tf files and creates the corresponding AWS resources. You need version 1.15 or higher for this project.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
  • Check if Terraform is already installed by running this command:
terraform version

✔️ I see version 1.15 or higher

Terraform is ready to go. Skip ahead to the next substep.

ⓧ I see an older version

You have Terraform installed, but it is below version 1.15. You need to upgrade to the latest version.

macOS

  • If you originally installed Terraform with Homebrew, upgrade it by running these commands:
brew tap hashicorp/tap
brew upgrade hashicorp/tap/terraform
  • After the upgrade completes, verify by running:
terraform version

You should see Terraform v1.15.x in the output.

Windows

  • Visit the official Terraform install page at https://developer.hashicorp.com/terraform/install.
  • Under the Windows section, download the AMD64 binary (zip file).
  • Extract the zip and replace the old terraform.exe in your PATH folder (e.g., C:\terraform) with the new one.
  • Close and reopen PowerShell, then verify by running:
terraform version

You should see Terraform v1.15.x in the output.

ⓧ Command not found

Terraform is not installed yet. Follow the instructions below for your operating system.

macOS

The recommended way to install Terraform on macOS is with Homebrew. If you do not have Homebrew, install it first from https://brew.sh.

  • Install the HashiCorp tap and then Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
  • After installation completes, verify by running:
terraform version

You should see Terraform v1.15.x in the output.

Windows

  • Visit the official Terraform install page at https://developer.hashicorp.com/terraform/install.
  • Under the Windows section, download the AMD64 binary (zip file).
  • Extract the zip file to a folder on your machine (e.g., C:\terraform).
  • Add that folder to your system PATH:
  • Open Settings, search for Environment Variables, and click Edit the system environment variables.
  • Click Environment Variables, select Path under System variables, and click Edit.
  • Click New and add the path to your extracted folder (e.g., C:\terraform).
  • Click OK on all dialogs to save.
  • Close and reopen PowerShell, then verify by running:
terraform version

You should see Terraform v1.15.x in the output.

Still seeing command not found?

On Windows, make sure you restarted PowerShell after updating your PATH. The change only takes effect in new terminal sessions.

On macOS, if Homebrew itself is not found, install it from https://brew.sh first, then retry the Terraform install commands.

Why can't my terminal find terraform after I installed it?

Install and configure the AWS CLI

The AWS CLI lets Terraform authenticate with your AWS account. You need version 2 of the CLI for this project.

  • Check if the AWS CLI is already installed by running this command:
aws --version

✔️ I see version 2.x

The AWS CLI is ready. Skip ahead to the configuration step below.

ⓧ I see version 1.x

You have an older major version installed. AWS recommends uninstalling version 1 and installing version 2 as a replacement.

macOS

  • Download the macOS installer package from https://awscli.amazonaws.com/AWSCLIV2.pkg.
  • Double-click the downloaded .pkg file and follow the on-screen instructions. The v2 installer will replace v1.
  • Once the installer finishes, verify by running:
aws --version

You should see output starting with aws-cli/2. followed by a version number.

Windows

  • Download the AWS CLI MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
  • Run the downloaded .msi file and follow the on-screen prompts. The v2 installer will replace v1.
  • Close and reopen PowerShell after installation, then 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 below for your operating system.

macOS

  • Download the macOS installer package from https://awscli.amazonaws.com/AWSCLIV2.pkg.
  • Double-click the downloaded .pkg file and follow the on-screen instructions.
  • Once the installer finishes, verify by running:
aws --version

You should see output starting with aws-cli/2. followed by a version number.

Windows

  • Download the AWS CLI MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
  • Run the downloaded .msi file and follow the on-screen prompts.
  • Close and reopen PowerShell after installation, then verify by running:
aws --version

You should see output starting with aws-cli/2. followed by a version number.

Now configure the CLI with your AWS credentials. You will need your Access Key ID and Secret Access Key from your AWS account.

Where do I find my AWS Access Key?

Sign in to the AWS Management Console. Click your account name in the top-right corner, then select Security credentials. Scroll down to Access keys and click Create access key.

Copy both the Access Key ID and Secret Access Key. Store them somewhere safe. You will only see the Secret Access Key once.

  • Configure the AWS CLI with your credentials by running:
aws configure

What does this command do?

The aws configure command prompts you for four values. It stores them in a credentials file on your machine so that Terraform can authenticate with AWS automatically.

  • When prompted, enter the following values:
  • AWS Access Key ID: paste your access key.
  • AWS Secret Access Key: paste your secret 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 a JSON response containing your Account ID, User ID, and ARN. This confirms your terminal can talk to AWS.

Seeing an error about invalid credentials?

Double-check that you pasted the full Access Key ID and Secret Access Key without extra spaces. Run aws configure again to re-enter them.

If you see a message about an expired token, your credentials may be from a temporary session. Create a new access key from the AWS Console instead.

Help me fix my AWS CLI credentials error.

Create the project directory

With both tools installed, you can create the folder where your Terraform files will live. This folder becomes your working directory for the rest of the project.

  • 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-aws
cd three-tier-aws
  • Confirm the folder was created by checking your current path:
pwd

You should see a path ending in three-tier-aws, confirming you are inside the new project folder.

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the three-tier-aws folder, then click Open.

Now create the first Terraform configuration file. This file tells Terraform which version to use and which cloud provider to connect to.

  • Create a new file called main.tf in the three-tier-aws folder by clicking the new file icon in the Explorer sidebar of VS Code.
  • Add the Terraform and provider configuration by pasting this code into main.tf:
terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block sets two constraints. required_version ensures anyone running this project has Terraform 1.15.0 or later. required_providers pins the AWS provider to any 6.x release.

The provider block tells Terraform to use the aws_region variable (which you will define next) as the target region for all resources.

  • Below the provider block, add the variable definitions by pasting this code at the bottom of main.tf:
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "db_username" {
  description = "RDS master username"
  type        = string
  default     = "admin"
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true
}

What do these variables do?

Variables make your configuration flexible. aws_region defaults to us-east-1 but can be overridden at runtime.

The db_username and db_password variables store the database credentials you will use later. Marking db_password as sensitive = true prevents Terraform from printing it in logs.

  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf file. Make sure yours matches exactly:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "db_username" {
  description = "RDS master username"
  type        = string
  default     = "admin"
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true
}

VS Code not showing your file?

Make sure you opened the three-tier-aws folder itself (not a parent folder). The Explorer sidebar should show main.tf at the top level.

If you accidentally created the file outside the folder, delete it and create a new one inside three-tier-aws.

Help me find my main.tf file in VS Code.

Your environment is ready. Next up, you will build the VPC and network foundation that all your resources will sit inside.

Install Terraform and Configure AWS

Before you can define any infrastructure as code, you need two tools on your machine: Terraform (which translates your configuration files into real cloud resources) and the AWS CLI (which gives Terraform permission to talk to your AWS account).

In this step, you will install both tools, configure your AWS credentials, and create the project folder with an initial configuration file.

In this step, get ready to:
  • Install Terraform and verify the installation.
  • Configure the AWS CLI with your credentials.
  • Create the project directory and write the provider configuration.
Install Terraform

Terraform is the Infrastructure as Code tool you will use to define and deploy your entire three-tier architecture. You need version 1.15.x or higher.

  • Open a terminal on your machine.
  • 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.

Install Terraform using the instructions for your operating system.

macOS

  • Install Terraform via Homebrew by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

What do these commands do?

The first command adds HashiCorp's official Homebrew repository (called a "tap") to your system. The second command installs Terraform from that repository.

Windows

  • Download the Terraform AMD64 binary from the official install page:

https://developer.hashicorp.com/terraform/install

  • Click the AMD64 link under the Windows section to download the zip file.
  • Extract the zip file to a folder on your machine (for example, C:\terraform).
  • Add the folder to your system PATH by opening Settings, searching for Environment Variables, clicking Edit the system environment variables, then Environment Variables.
  • Under System variables, select Path and click Edit.
  • Click New and add the path to the folder where you extracted Terraform (e.g. C:\terraform).
  • Click OK on all dialogs to save.
  • Close and reopen your PowerShell window for the PATH change to take effect.

Why add to PATH?

Adding the folder to your system PATH lets you run terraform from any directory in your terminal without typing the full file path every time.

  • Verify Terraform is installed by running:
terraform version

You should see output starting with Terraform v1.15 followed by a patch number (for example, v1.15.4).

Command not found?

On macOS, make sure Homebrew is installed first. You can install it from https://brew.sh.

On Windows, make sure you restarted your PowerShell window after adding Terraform to your PATH. The terminal only picks up PATH changes on restart.

help me troubleshoot why terraform version is not working

Install and configure the AWS CLI

The AWS CLI lets Terraform authenticate with your AWS account. You will install it and then run a quick configuration to store your credentials locally.

macOS

  • Download and install the AWS CLI by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

What do these commands do?

The first line downloads the official AWS CLI installer package. The second line runs the macOS installer, which places the aws binary in /usr/local/bin.

Your terminal will ask for your password because sudo requires admin privileges. Enter your Mac login password and press Enter.

Windows

  • Download the AWS CLI MSI installer from this URL:

https://awscli.amazonaws.com/AWSCLIV2.msi

  • Run the downloaded AWSCLIV2.msi file and follow the on-screen prompts to complete the installation.
  • Close and reopen your PowerShell window after installation completes.
  • Verify the AWS CLI is installed by running:
aws --version

You should see output that starts with aws-cli/2 followed by version details.

Now configure your AWS credentials. You will need your AWS Access Key ID and Secret Access Key from your IAM user.

  • Run the configuration wizard:
aws configure

The wizard will prompt you for four values. Enter them one at a time:

  • For AWS Access Key ID: paste your Access Key ID and press Enter.
  • For AWS Secret Access Key: paste your Secret Access Key and press Enter.
  • For Default region name: type us-east-1 and press Enter.
  • For Default output format: type json and press Enter.

Where do I find my Access Key?

Log in to the AWS Management Console, navigate to IAM, select your user, then click the Security credentials tab. Click Create access key to generate a new key pair.

Copy both the Access Key ID and the Secret Access Key before closing the dialog. The Secret Access Key is only shown once.

  • Verify your credentials work by running:
aws sts get-caller-identity

You should see a JSON response containing your Account, UserId, and Arn. This confirms Terraform will be able to authenticate with AWS.

Seeing an authentication error?

Double-check that you pasted the full Access Key ID and Secret Access Key with no trailing spaces.

Make sure your IAM user has programmatic access enabled. If your key was just created, wait 30 seconds and try again.

help me fix my AWS CLI authentication error

Create the project directory

Now you will create a dedicated folder for your Terraform project and set up the initial configuration file that tells Terraform which cloud provider to use.

  • 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-aws
cd three-tier-aws
  • Confirm the folder was created by running:
ls

The folder should be empty for now. You will add files to it next.

  • Open this folder in VS Code.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the three-tier-aws folder, and click Open.

Now create the first Terraform file that defines the provider configuration and project variables.

  • Create a new file called main.tf in the three-tier-aws folder by clicking the New File icon in the VS Code Explorer sidebar.
  • Name it main.tf and press Enter.
  • Add the Terraform and provider configuration by pasting this code into main.tf:
terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block sets the minimum Terraform version to 1.15.0 and specifies the AWS provider from HashiCorp's registry.

The provider block tells Terraform to deploy resources into whichever region the aws_region variable holds. This makes the configuration reusable across regions.

  • Below the provider block, add the project variables:
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "db_username" {
  description = "RDS master username"
  type        = string
  default     = "admin"
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true
}

What do these variables do?

Variables make your configuration flexible. Instead of hardcoding values like the region or database credentials, you define them once and Terraform prompts you for the sensitive ones at runtime.

The sensitive = true flag on db_password tells Terraform to hide this value in terminal output. This keeps your database password from being accidentally exposed in logs.

  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf file. Make sure yours matches exactly:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "db_username" {
  description = "RDS master username"
  type        = string
  default     = "admin"
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true
}

Seeing a syntax error in main.tf?

Make sure you have matching opening and closing braces for every block. HCL requires that each { has a corresponding }.

Check that string values are wrapped in double quotes, not single quotes. HCL only accepts double-quoted strings.

help me fix my main.tf syntax error

Your tools are installed and your project is ready. Next up, you will build the VPC and network foundation that forms the backbone of the three-tier architecture.

Build the VPC and Network Foundation

Your tools are installed and your AWS account is ready to go. Now it's time to define the network that everything else will live on.

A three-tier architecture needs carefully separated network layers. In this step, you'll create a VPC with six subnets spanning two Availability Zones, then wire up the routing so that each tier gets exactly the level of internet access it needs.

In this step, get ready to:
  • Create a VPC and Internet Gateway as the network foundation.
  • Define six subnets across two Availability Zones (public, private app, and database).
  • Configure a NAT Gateway and route tables to control traffic flow per tier.
Create the VPC and Internet Gateway

Every AWS network starts with a VPC. Think of it as a virtual data center that contains all your resources and defines the overall IP address range they can use.

  • In VS Code, create a new file called vpc.tf inside your three-tier-aws folder.
  • Add the VPC and Internet Gateway resources by pasting the following code:
# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "three-tier-vpc"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "three-tier-igw"
  }
}

What does this code do?

The aws_vpc resource creates a Virtual Private Cloud with a /16 CIDR block. That gives you 65,536 IP addresses to divide among your subnets.

The aws_internet_gateway resource is the front door that connects your VPC to the public internet. Without it, nothing inside the VPC can send or receive traffic from outside.

  • Save vpc.tf.
  • You won't see any output yet. The next section adds subnets to this VPC.

Seeing red underlines in VS Code?

If VS Code shows syntax highlighting errors, make sure the file extension is exactly .tf and that you have the HashiCorp Terraform extension installed. Check that every opening brace { has a matching closing brace }.

Help me fix a syntax error in my Terraform VPC configuration.

Create subnets across two Availability Zones

Subnets divide your VPC into smaller network segments. Each tier (public, private app, database) gets its own pair of subnets spread across two Availability Zones for redundancy.

  • In vpc.tf, add the two public subnets below the Internet Gateway resource:
# Public Subnets
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-a"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "${var.aws_region}b"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-b"
  }
}

What does map_public_ip_on_launch do?

Setting map_public_ip_on_launch = true means any resource launched in these subnets automatically gets a public IP address. Only the public subnets get this setting. The private and database subnets intentionally do NOT get public IPs.

  • Below the public subnets, add the private app subnets and database subnets:
# Private App Subnets
resource "aws_subnet" "private_app_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "private-app-subnet-a"
  }
}

resource "aws_subnet" "private_app_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "private-app-subnet-b"
  }
}

# Database Subnets
resource "aws_subnet" "database_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.20.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "database-subnet-a"
  }
}

resource "aws_subnet" "database_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.21.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "database-subnet-b"
  }
}

Why separate CIDR ranges per tier?

Public subnets use 10.0.1.0/24 and 10.0.2.0/24. Private app subnets use 10.0.10.0/24 and 10.0.11.0/24. Database subnets use 10.0.20.0/24 and 10.0.21.0/24.

Spacing CIDR blocks apart (1, 2, then 10, 11, then 20, 21) keeps them logically grouped and leaves room for future expansion. Each /24 block provides 256 IP addresses, which is plenty for a learning project.

  • Save vpc.tf.
  • You won't see any output yet. The next section adds the routing that gives each tier its correct internet access level.
Configure NAT Gateway and route tables

Subnets alone don't control traffic. Route tables tell each subnet where to send outbound traffic, and a NAT Gateway lets private subnets reach the internet (for software updates) without being directly reachable from it.

  • In vpc.tf, add the Elastic IP and NAT Gateway below the database subnets:
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "three-tier-nat-eip"
  }
}

# NAT Gateway
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_a.id

  tags = {
    Name = "three-tier-nat-gw"
  }

  depends_on = [aws_internet_gateway.main]
}

What does this code do?

The aws_eip gives the NAT Gateway a static public IP address. The NAT Gateway itself lives in a public subnet and forwards outbound traffic from private subnets to the internet.

The depends_on line tells Terraform to create the Internet Gateway first. A NAT Gateway cannot function without an Internet Gateway already attached to the VPC.

  • Below the NAT Gateway, add the public route table and its subnet associations:
# Public Route Table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "public-rt"
  }
}

resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

What is a route table?

A route table is a set of rules that decide where network traffic goes. The public route table sends all outbound traffic (0.0.0.0/0) directly to the Internet Gateway. The aws_route_table_association resources link each subnet to the correct route table.

  • Add the private route table, the database route table, and all their subnet associations below the public route table:
# Private Route Table
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "private-rt"
  }
}

resource "aws_route_table_association" "private_app_a" {
  subnet_id      = aws_subnet.private_app_a.id
  route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_app_b" {
  subnet_id      = aws_subnet.private_app_b.id
  route_table_id = aws_route_table.private.id
}

# Database Route Table (no internet access)
resource "aws_route_table" "database" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "database-rt"
  }
}

resource "aws_route_table_association" "database_a" {
  subnet_id      = aws_subnet.database_a.id
  route_table_id = aws_route_table.database.id
}

resource "aws_route_table_association" "database_b" {
  subnet_id      = aws_subnet.database_b.id
  route_table_id = aws_route_table.database.id
}

Why does the database route table have no routes?

The private route table sends traffic through the NAT Gateway so app servers can download updates. The database route table has zero outbound routes. Only the automatic local VPC route exists, which means the database can talk to other resources inside the VPC but can never reach the internet.

This is defense in depth. Even if someone misconfigures a security group, the route table physically prevents the database from making external connections.

  • Save vpc.tf.
  • You won't see any infrastructure created yet. In Step 4, you'll run terraform init and terraform plan to validate this entire configuration.

Worried about typos in all that code?

Double check that every resource reference matches exactly. For example, aws_subnet.public_a.id must match the resource name "public_a" you defined above. A single mismatched character in a resource reference will cause Terraform to fail during plan.

Help me find the typo in my vpc.tf route table configuration.

✔️ Awesome, I've got everything!

Great. Double check you've saved vpc.tf and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete vpc.tf file. Compare yours against it line by line:

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "three-tier-vpc"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "three-tier-igw"
  }
}

# Public Subnets
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-a"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = "${var.aws_region}b"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-b"
  }
}

# Private App Subnets
resource "aws_subnet" "private_app_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "private-app-subnet-a"
  }
}

resource "aws_subnet" "private_app_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "private-app-subnet-b"
  }
}

# Database Subnets
resource "aws_subnet" "database_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.20.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "database-subnet-a"
  }
}

resource "aws_subnet" "database_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.21.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Name = "database-subnet-b"
  }
}

# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "three-tier-nat-eip"
  }
}

# NAT Gateway
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_a.id

  tags = {
    Name = "three-tier-nat-gw"
  }

  depends_on = [aws_internet_gateway.main]
}

# Public Route Table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "public-rt"
  }
}

resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

# Private Route Table
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "private-rt"
  }
}

resource "aws_route_table_association" "private_app_a" {
  subnet_id      = aws_subnet.private_app_a.id
  route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_app_b" {
  subnet_id      = aws_subnet.private_app_b.id
  route_table_id = aws_route_table.private.id
}

# Database Route Table (no internet access)
resource "aws_route_table" "database" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "database-rt"
  }
}

resource "aws_route_table_association" "database_a" {
  subnet_id      = aws_subnet.database_a.id
  route_table_id = aws_route_table.database.id
}

resource "aws_route_table_association" "database_b" {
  subnet_id      = aws_subnet.database_b.id
  route_table_id = aws_route_table.database.id
}

Your entire network foundation is defined. Next up, you'll create security groups that control exactly which tier can talk to which.

Define Security Groups

In the last step, you built the network foundation for your three-tier architecture. Your VPC has public, private, and database subnets with route tables controlling internet access.

Route tables control where traffic can flow at the network level, but they don't control which specific services can talk to each other. That's where security groups come in. Security groups act as virtual firewalls for individual resources, deciding which ports and sources are allowed.

In this step, get ready to:
  • Create a security group for the ALB that accepts HTTP traffic from the internet.
  • Create a security group for the app tier that only accepts traffic from the ALB.
  • Create a security group for the database tier that only accepts traffic from the app tier.
Create the ALB security group

The Application Load Balancer is the only resource that faces the public internet directly. Its security group needs to accept incoming HTTP traffic on port 80 from anywhere.

  • Create a new file called security.tf in your three-tier-aws folder.
  • Add the ALB security group and its rules by pasting this code:
# ALB Security Group
resource "aws_security_group" "alb" {
  name        = "alb-sg"
  description = "Security group for the Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  tags = {
    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?

This defines three resources that work together as the ALB's firewall.

  • aws_security_group.alb creates the security group container and attaches it to your VPC.
  • aws_vpc_security_group_ingress_rule.alb_http allows inbound TCP traffic on port 80 from any IP address (0.0.0.0/0).
  • aws_vpc_security_group_egress_rule.alb_all allows all outbound traffic so the ALB can forward requests to backend targets.
  • Save security.tf.
  • Confirm the file exists by running this in your terminal:
ls

You should see main.tf, vpc.tf, and security.tf listed in the output.

Don't see security.tf?

  • Make sure your terminal is in the three-tier-aws folder. Run pwd to check your current directory.
  • Verify you saved the file inside three-tier-aws and not in a parent or subfolder.

Help me find my security.tf file

Create the app tier security group

The app tier EC2 instances should never be reachable directly from the internet. They should only accept traffic that comes through the ALB. You enforce this by referencing the ALB's security group as the allowed source.

  • In security.tf, add the app tier security group below the ALB egress rule:
# App Tier Security Group
resource "aws_security_group" "app" {
  name        = "app-sg"
  description = "Security group for app tier EC2 instances"
  vpc_id      = aws_vpc.main.id

  tags = {
    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"
}

What does referenced_security_group_id do?

Instead of allowing traffic from a CIDR range like 0.0.0.0/0, referenced_security_group_id allows traffic only from resources that belong to a specific security group.

Here, port 80 traffic is only accepted if it originates from a resource attached to aws_security_group.alb. This means only the ALB can reach your app servers, even if someone knows the server's private IP.

  • Save security.tf.

You won't see a change yet. The next substep adds the final security group, then you'll validate everything together in the next step.

Create the database security group

The database is the most protected resource in the architecture. It should only accept connections on the MySQL port (3306) from the app tier. No other traffic should reach it.

  • In security.tf, add the database security group below the app tier egress rule:
# Database Security Group
resource "aws_security_group" "database" {
  name        = "database-sg"
  description = "Security group for RDS database"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "database-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.database.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.database.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What does this code do?

  • The ingress rule only allows TCP connections on port 3306 (MySQL's default port) from resources in the app security group.
  • Combined with the database route table (which has no internet route), this tier is completely isolated from the outside world.
  • Save security.tf.

How do these security groups chain together?

Traffic flows in one direction through the tiers. The internet can reach the ALB (port 80). The ALB can reach the app tier (port 80). The app tier can reach the database (port 3306). Nothing can skip a tier or go backwards.

This is the core principle of defense in depth. Even if one tier is compromised, the attacker cannot directly reach the database without also compromising the app tier.

Seeing syntax errors in your file?

  • Check that every opening brace { has a matching closing brace }.
  • Make sure all string values are wrapped in double quotes, not single quotes.
  • Verify that referenced_security_group_id is spelled exactly as shown. A typo here will cause a plan error later.

Help me fix a syntax error in my security.tf file

✔️ Awesome, I've got everything!

Great. Double check you've saved security.tf and let's move on to deploying the load balancer.

ⓧ I'd like to double check the full code

Here is the complete security.tf file. Make sure yours matches exactly.

# ALB Security Group
resource "aws_security_group" "alb" {
  name        = "alb-sg"
  description = "Security group for the Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  tags = {
    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"
}

# App Tier Security Group
resource "aws_security_group" "app" {
  name        = "app-sg"
  description = "Security group for app tier EC2 instances"
  vpc_id      = aws_vpc.main.id

  tags = {
    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"
}

# Database Security Group
resource "aws_security_group" "database" {
  name        = "database-sg"
  description = "Security group for RDS database"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "database-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.database.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.database.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

Your firewall rules are locked down. Next up, you'll deploy the Application Load Balancer into the public subnets and point it at your app tier.

Deploy the Application Load Balancer

Your security groups are locked down and ready to enforce strict tier-to-tier communication rules. Now you need the front door to your architecture.

An Application Load Balancer sits in the public subnets and accepts traffic from the internet. It forwards that traffic to healthy targets in the private subnets, so your app servers never need to be directly exposed.

In this step, get ready to:
  • Define an internet-facing ALB in the public subnets.
  • Create a target group with health checks for port 80.
  • Add an HTTP listener and validate the configuration with Terraform.
Create the ALB and target group

The ALB needs to live in the public subnets so it has a public DNS name that browsers can reach. It also needs the ALB security group you created in the last step.

  • In VS Code, create a new file called alb.tf inside your three-tier-aws folder.
  • Add the ALB resource by pasting this code:
resource "aws_lb" "main" {
  name               = "three-tier-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_a.id, aws_subnet.public_b.id]

  tags = {
    Name = "three-tier-alb"
  }
}

What does this code do?

This defines an internet-facing Application Load Balancer.

  • internal = false makes the ALB publicly accessible from the internet.
  • security_groups attaches the ALB security group you defined in security.tf, which allows HTTP traffic on port 80.
  • subnets places the ALB across both public subnets for high availability.

You won't see a change yet. The next chunk adds the target group that the ALB forwards traffic to.

  • Below the ALB resource in alb.tf, add the target group:
resource "aws_lb_target_group" "app" {
  name     = "app-target-group"
  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 = "app-target-group"
  }
}

What does this code do?

A target group defines where the ALB sends traffic and how it checks if those targets are healthy.

  • port = 80 and protocol = "HTTP" tell the ALB to forward traffic to targets on port 80 using HTTP.
  • The health_check block pings / every 30 seconds. If a target fails 3 checks in a row, the ALB stops sending it traffic.
  • healthy_threshold = 2 means a target needs 2 consecutive successful checks to be considered healthy again.
  • Save alb.tf.

You won't see visible output yet. The listener in the next substep connects the ALB to this target group.

Create the listener and validate the configuration

A listener tells the ALB what to do with incoming requests. Without it, the ALB accepts connections but has no idea where to send them.

  • Below the target group in alb.tf, add the HTTP listener:
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?

This listener watches for HTTP traffic on port 80 and forwards every request to the target group you just defined.

  • load_balancer_arn links this listener to your ALB.
  • The default_action block with type = "forward" tells the ALB to route all traffic to the app target group.
  • Save alb.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved alb.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete alb.tf file:

resource "aws_lb" "main" {
  name               = "three-tier-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_a.id, aws_subnet.public_b.id]

  tags = {
    Name = "three-tier-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name     = "app-target-group"
  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 = "app-target-group"
  }
}

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
  }
}

Now let's validate that all your configuration files work together. Terraform needs to download the AWS provider before it can check your code.

  • Initialize the Terraform workspace by running this command in your terminal:
terraform init

What does this command do?

terraform init downloads the AWS provider plugin specified in your main.tf and prepares the working directory. It creates a hidden .terraform directory and a lock file called .terraform.lock.hcl.

  • Confirm the output ends with Terraform has been successfully initialized!.

Seeing an error during init?

  • Make sure your terminal is inside the three-tier-aws folder. Run cd ~/Desktop/three-tier-aws to get there.
  • Check that main.tf exists and contains the required_providers block with hashicorp/aws.
  • If you see a network error, check your internet connection. Terraform downloads providers from the registry.

help me troubleshoot my terraform init error

  • Preview the infrastructure changes by running:
terraform plan

What does terraform plan show?

terraform plan reads all your .tf files and shows what resources Terraform would create, modify, or destroy. It does not actually create anything.

Terraform will prompt you for the db_password variable since it has no default value. Enter any temporary password for now (at least 8 characters).

  • When prompted for var.db_password, type a temporary password (e.g. TempPass123) and press Enter.
  • Confirm the plan output shows resources to create and ends without errors. You should see a summary line like Plan: X to add, 0 to change, 0 to destroy.

Seeing errors in the plan output?

  • If you see a missing reference error (e.g. referencing aws_security_group.alb), make sure security.tf and vpc.tf are saved in the same folder.
  • If you see a syntax error, double check that every opening brace { has a matching closing brace } in alb.tf.
  • If the password prompt fails, make sure you type at least 8 characters.

help me fix my terraform plan errors

Your ALB configuration is validated and ready to go. Next up, you'll launch the EC2 instance and RDS database that the ALB will serve traffic to.

Launch the Compute and Database Tiers

Your network, security groups, and load balancer are all in place. The ALB is ready to route traffic, but it has nowhere to send it yet.

In this step, you'll deploy the two remaining tiers: an EC2 web server in the private app subnet and an RDS MySQL database in the isolated database subnets. Once you run terraform apply, you'll be able to hit the ALB's public URL and see a live response from your private server.

In this step, get ready to:
  • Deploy an EC2 instance running a web server in the private app subnet.
  • Create an RDS MySQL database in the isolated database subnets.
  • Apply the full infrastructure and verify the ALB serves traffic end-to-end.
Deploy the EC2 instance

The app tier needs a server that receives traffic from the ALB and eventually connects to the database. You'll use a Terraform data source to automatically find the latest Amazon Linux 2023 AMI, then launch an instance with a simple web server.

  • Create a new file called compute.tf in your three-tier-aws folder.
  • Add the AMI data source that finds the latest Amazon Linux 2023 image:
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

What does this code do?

A data source in Terraform fetches information from AWS without creating anything. This one queries the AMI catalog for the latest Amazon Linux 2023 image.

The most_recent = true filter ensures you always get the newest patched version. This is better than hardcoding an AMI ID, which would go stale over time.

  • Below the data source, add the EC2 instance and its target group attachment:
resource "aws_instance" "app" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.private_app_a.id
  vpc_security_group_ids = [aws_security_group.app.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from the App Tier!</h1><p>Instance: $(hostname)</p>" > /var/www/html/index.html
  EOF

  tags = {
    Name = "app-server"
  }
}

resource "aws_lb_target_group_attachment" "app" {
  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app.id
  port             = 80
}

What does this code do?

The aws_instance resource launches a t3.micro EC2 instance in the private app subnet. It uses the app tier security group, which only allows traffic from the ALB.

The user_data script runs on first boot. It installs Apache (httpd), starts the web server, and writes a simple HTML page that shows the instance hostname.

The aws_lb_target_group_attachment registers this instance with the ALB's target group so the load balancer knows to send traffic here.

  • Save compute.tf.
  • You won't see infrastructure changes yet. Terraform creates resources only when you run terraform apply in substep 3.

Seeing a syntax error in VS Code?

Check that the <<-EOF heredoc has a matching EOF closing tag on its own line with no trailing spaces. Also verify that each opening { has a matching }.

help me fix a syntax error in my compute.tf file

✔️ Awesome, I've got everything!

Great. Make sure you've saved compute.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "app" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.private_app_a.id
  vpc_security_group_ids = [aws_security_group.app.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from the App Tier!</h1><p>Instance: $(hostname)</p>" > /var/www/html/index.html
  EOF

  tags = {
    Name = "app-server"
  }
}

resource "aws_lb_target_group_attachment" "app" {
  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app.id
  port             = 80
}
Deploy the RDS instance

The database tier is the most isolated layer of the architecture. It sits in subnets with no internet route and only accepts connections from the app tier security group on port 3306.

  • Create a new file called database.tf in your three-tier-aws folder.
  • Add the DB subnet group that tells RDS which subnets to use:
resource "aws_db_subnet_group" "main" {
  name       = "three-tier-db-subnet-group"
  subnet_ids = [aws_subnet.database_a.id, aws_subnet.database_b.id]

  tags = {
    Name = "three-tier-db-subnet-group"
  }
}

What is a DB subnet group?

RDS requires a subnet group that spans at least two Availability Zones. This tells AWS where it can place the database instance and where a failover replica could go.

By using your isolated database subnets (which have no internet route), the RDS instance is physically unable to reach the internet even if security groups are misconfigured.

  • Below the subnet group, add the RDS MySQL instance:
resource "aws_db_instance" "main" {
  identifier           = "three-tier-db"
  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
  skip_final_snapshot  = true
  publicly_accessible  = false
  db_subnet_group_name = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.database.id]

  tags = {
    Name = "three-tier-db"
  }
}

What does this code do?

  • identifier sets the unique name for this RDS instance in AWS.
  • instance_class uses db.t3.micro, which is eligible for the AWS Free Tier.
  • username and password come from the variables you defined in main.tf. Terraform will prompt you for the password at apply time.
  • publicly_accessible = false ensures the database has no public IP, even within its subnet.
  • skip_final_snapshot = true allows terraform destroy to delete the database without requiring a manual snapshot.
  • Save database.tf.

Seeing an error about var.db_password?

The db_password variable is defined in main.tf without a default value. This is intentional. Terraform will prompt you to enter a password when you run terraform apply in the next substep.

help me fix a variable error in my database.tf file

✔️ Awesome, I've got everything!

Great. Make sure you've saved database.tf before moving on.

ⓧ I'd like to double check the full code

resource "aws_db_subnet_group" "main" {
  name       = "three-tier-db-subnet-group"
  subnet_ids = [aws_subnet.database_a.id, aws_subnet.database_b.id]

  tags = {
    Name = "three-tier-db-subnet-group"
  }
}

resource "aws_db_instance" "main" {
  identifier           = "three-tier-db"
  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
  skip_final_snapshot  = true
  publicly_accessible  = false
  db_subnet_group_name = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.database.id]

  tags = {
    Name = "three-tier-db"
  }
}
Apply and verify the full infrastructure

All the configuration is ready. Before applying, you'll add an outputs file so Terraform prints the ALB's public URL after deployment.

  • Create a new file called outputs.tf in your three-tier-aws folder.
  • Add the following output definitions:
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "rds_endpoint" {
  description = "Endpoint of the RDS instance"
  value       = aws_db_instance.main.endpoint
}

What does this code do?

Terraform outputs print useful values to the terminal after terraform apply completes. The alb_dns_name output gives you the public URL to visit in your browser. The rds_endpoint shows the internal database address that the app tier would use to connect.

  • Save outputs.tf.
  • Deploy the entire infrastructure by running this command in your terminal:
terraform apply

What will Terraform ask you?

Terraform will prompt you for the db_password variable. Enter any password that is at least 8 characters long (for example, MyDBpass123). This sets the master password on your RDS instance.

After the plan displays, Terraform asks you to type yes to confirm. Review the plan and type yes to proceed.

This will take 5-10 minutes

The RDS instance takes several minutes to launch. This is normal. Terraform will show progress updates as each resource is created. Do not close the terminal while it runs.

  • When the apply completes, look for the alb_dns_name value in the Outputs section at the bottom of the terminal.
  • Copy the ALB DNS name and paste it into your browser's address bar.
  • You should see a page that says Hello from the App Tier! followed by the instance hostname.

Why does this prove the architecture works?

The response comes from an EC2 instance that has no public IP address. The only path to reach it is through the ALB in the public subnet. The ALB forwards traffic on port 80 to the instance in the private subnet.

The RDS database is even more restricted. It sits in subnets with no internet route and only accepts connections from the app security group. It is completely unreachable from the public internet.

Not seeing the page in your browser?

  • Wait 1-2 minutes after apply completes. The EC2 instance needs time to boot and install Apache.
  • Make sure you're using http:// (not https://) in your browser. The ALB listener is configured for HTTP only.
  • Check the target group health in the AWS Console. Go to EC2 then Target Groups and confirm the instance shows healthy.

help me troubleshoot why my ALB is not returning a response

Secret mission

Add Auto Scaling

Your three-tier architecture works, but it relies on a single EC2 instance. What happens if that instance crashes? In production, a single point of failure is unacceptable. Replace your standalone instance with an Auto Scaling Group that maintains multiple healthy instances across both Availability Zones.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running or delete them entirely. This project provisions paid AWS resources that will incur charges as long as they remain active.

Cost warning

The NAT Gateway costs approximately $0.045/hr (~$32/month) and the ALB costs approximately $0.0225/hr (~$16/month). EC2 and RDS instances also incur charges beyond free tier limits.

If you are done with this project, run terraform destroy immediately to stop all charges.

Resources you used:

  • AWS VPC with 6 subnets, Internet Gateway, NAT Gateway, and Elastic IP.
  • Application Load Balancer with target group and listener.
  • Auto Scaling Group with 2 EC2 instances (or single EC2 instance if you skipped the Secret Mission).
  • RDS MySQL instance in isolated database subnets.
  • Security groups for ALB, app tier, and database tier.
  • Local project folder three-tier-aws with Terraform configuration files.

Keep everything running

No action needed. Choose this if you are still actively experimenting with the infrastructure or plan to extend it shortly.

  • Your NAT Gateway, ALB, EC2 instances, and RDS database will continue running and incurring charges.
  • Monitor your AWS billing dashboard to track costs.
  • When you are ready to stop charges, return here and follow the Delete tab.

Pause - I'll come back to this later

Pausing is not practical for this architecture. Individual resources like NAT Gateways, ALBs, and RDS instances cannot be paused independently without breaking the infrastructure.

  • The cleanest option is to destroy everything with Terraform and re-create it later.
  • Your Terraform files in the three-tier-aws folder are your blueprint. Keep them on your computer.
  • When you want to resume, run terraform apply again from the same folder to recreate everything.
  • Follow the Delete tab below to destroy AWS resources now, but skip the step that removes local files.

Delete - I don't want to use this again

Remove all AWS resources and delete local project files. This stops all charges immediately.

  • Destroy all AWS resources by running this command from your three-tier-aws folder:
terraform destroy
  • Terraform will show you a plan of all resources it will delete. Type yes when prompted to confirm.
  • Wait for the destroy to complete. RDS deletion may take several minutes.
  • Verify in the AWS Console that your VPC (three-tier-vpc) no longer appears under VPC > Your VPCs.
  • Delete the local project folder on macOS/Linux:
cd ~/Desktop
rm -rf three-tier-aws
  • On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force three-tier-aws
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see three-tier-aws in the output.

Nice Work!

Nice Work!

You just built a production-grade three-tier architecture on AWS using Terraform.

You've learned how to:

  • Built a VPC with 6 subnets across 2 Availability Zones, separated into public, private, and database tiers with route tables that prevent the database from ever reaching the internet.
  • Deployed an Application Load Balancer to distribute traffic to private EC2 compute resources, with security groups enforcing strict tier-to-tier access.
  • Provisioned an RDS MySQL database in fully isolated subnets, and managed the entire infrastructure lifecycle with terraform init, terraform plan, terraform apply, and terraform destroy.
  • Secret Mission: Added an Auto Scaling Group to make the architecture horizontally scalable and self-healing.

Ready to quiz yourself?