AWS Three-Tier Architecture with Terraform

Deploy a production-grade AWS three-tier architecture using Terraform modules.

Introduction

30 Second Summary

Every time you load a website, your request passes through multiple layers of infrastructure before a page appears. Load balancers, application servers, and databases all work together in what's called a three-tier architecture.

In this project, you will build a production-grade three-tier architecture on AWS, entirely provisioned through Terraform. Your infrastructure will span two Availability Zones with proper network segmentation, load balancing, and an isolated database.

What You'll Build

A modular Terraform codebase that deploys a highly available three-tier AWS architecture with DNS routing, load balancing, and a managed database.

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

  • A VPC with public, private, and database subnet tiers across two Availability Zones for high availability.
  • An internet-facing Application Load Balancer with an HTTP listener and a Route 53 DNS alias record routing traffic to it.
  • An RDS PostgreSQL database isolated in private subnets, with security groups enforcing least-privilege access between tiers.
  • Secret Mission: extend your architecture with an Auto Scaling Group that launches EC2 instances serving live traffic through the load balancer.

Are there any prerequisites?

You'll need an AWS account with an admin IAM user or role configured. Basic familiarity with terminal commands is also helpful.

Verify Tools and Set Up the Project Structure

Enterprise teams don't throw all their Terraform code into a single file. They organize infrastructure into modules, where each module manages one logical piece of the architecture.

In this step, you'll create the directory layout for your four modules and write the root configuration files that Terraform needs to get started.

In this step, get ready to:
  • Create the module directory structure.
  • Write the Terraform provider configuration.
  • Define the project-wide input variables.
Create the module directories

Your project needs four module folders. Each one will hold the Terraform code for a single tier of the architecture: networking, database, load balancer, and DNS.

  • Create all four module directories by running this command:
mkdir -p modules/networking modules/database modules/loadbalancer modules/dns
  • Confirm the directories exist by running:
ls modules/

You should see four directories listed: database, dns, loadbalancer, and networking.

Why native resources over community modules?

Terraform has popular community modules (like terraform-aws-modules/vpc/aws) that abstract away individual resources. They're great for speed, but interviewers want to see that you understand what those modules hide.

This project uses native AWS provider resources directly. You'll write your own modules from scratch, proving you understand the underlying infrastructure.

Seeing 'cannot create directory'?

Make sure you're inside your project root folder. Run pwd to check your current location. If you're not in the project directory, cd into it before running the mkdir command.

Help me troubleshoot my directory creation issue.

Write the provider configuration

Every Terraform project needs a versions.tf file that declares which Terraform version to use, which providers to download, and how to configure them.

  • Create a new file called versions.tf in your project root folder.
  • Copy the following code into versions.tf:
terraform {
  required_version = "~> 1.15"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      ManagedBy   = "terraform"
      Environment = "demo"
    }
  }
}

What does this code do?

  • required_version = "~> 1.15" ensures anyone running this project uses Terraform 1.15.x. The ~> operator allows any patch version within 1.15.
  • required_providers tells Terraform to download the AWS provider from hashicorp/aws with any 5.x version.
  • default_tags automatically applies Project, ManagedBy, and Environment tags to every resource Terraform creates. This saves you from repeating tags on each resource.
  • Save versions.tf.

What is Infrastructure as Code?

Infrastructure as Code means defining cloud resources in text files instead of clicking through a web console. Terraform reads these files and creates, updates, or destroys the matching AWS resources automatically.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

terraform {
  required_version = "~> 1.15"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      ManagedBy   = "terraform"
      Environment = "demo"
    }
  }
}
Define the project variables

Terraform uses variables to keep your configuration flexible. Instead of hardcoding values like the AWS region or database username, you define them once and reference them everywhere.

  • Create a new file called variables.tf in your project root folder.
  • Copy 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 = "Project name used for resource naming"
  type        = string
  default     = "three-tier-demo"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "db_username" {
  description = "Master username for the RDS instance"
  type        = string
  default     = "dbadmin"
}

variable "db_password" {
  description = "Master password for the RDS instance"
  type        = string
  sensitive   = true
}

variable "domain_name" {
  description = "Domain name for the Route 53 hosted zone"
  type        = string
  default     = "demo.example.com"
}

What does this code do?

  • Each variable block defines one input your project accepts. The default value is used unless you override it.
  • db_password has no default and is marked sensitive = true. Terraform will prompt you for this value at deploy time and hide it from output logs.
  • vpc_cidr defines the IP address range for your entire VPC. The /16 block gives you over 65,000 available IP addresses to subdivide into subnets.
  • Save variables.tf.

Now you need a place to set the actual values for these variables. Terraform automatically loads any file named terraform.tfvars in the project root.

  • Create a new file called terraform.tfvars in your project root folder.
  • Copy the following code into terraform.tfvars:
aws_region   = "us-east-1"
project_name = "three-tier-demo"
vpc_cidr     = "10.0.0.0/16"
db_username  = "dbadmin"
domain_name  = "demo.example.com"

Why is db_password missing from this file?

The database password is sensitive. You should never commit it to version control. Terraform will prompt you for it when you run terraform apply later, or you can pass it with a -var flag.

  • Save terraform.tfvars.
  • Confirm all three root files exist by running:
ls *.tf *.tfvars

You should see terraform.tfvars, variables.tf, and versions.tf listed in the output.

Missing a file in the output?

Check that you saved each file with the correct extension. Terraform files must end in .tf and the variables file must be named exactly terraform.tfvars. Also confirm you saved the files inside your project root folder, not inside a modules/ subdirectory.

Help me find my missing Terraform files.

✔️ 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

Here is variables.tf:

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

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "three-tier-demo"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "db_username" {
  description = "Master username for the RDS instance"
  type        = string
  default     = "dbadmin"
}

variable "db_password" {
  description = "Master password for the RDS instance"
  type        = string
  sensitive   = true
}

variable "domain_name" {
  description = "Domain name for the Route 53 hosted zone"
  type        = string
  default     = "demo.example.com"
}

Here is terraform.tfvars:

aws_region   = "us-east-1"
project_name = "three-tier-demo"
vpc_cidr     = "10.0.0.0/16"
db_username  = "dbadmin"
domain_name  = "demo.example.com"

Your project skeleton is in place. Next up, you'll build the networking module that creates the VPC, subnets, and security groups forming the backbone of your three-tier architecture.

Build the Networking Module

Your project structure and root configuration are ready. Now it's time to build the first and most critical module: networking.

Every resource in your three-tier architecture needs a network to live in. The networking module creates the VPC, carves it into isolated subnet tiers, and defines the security boundaries between them. Without this foundation, nothing else can deploy.

In this step, get ready to:
  • Create a VPC with public, private, and database subnets across 2 Availability Zones.
  • Add an Internet Gateway, NAT Gateway, and route tables connecting each tier.
  • Define security groups enforcing least-privilege access between tiers.
Create the VPC and subnet tiers

A three-tier architecture separates resources by function: public-facing load balancers, private application servers, and isolated databases. Each tier gets its own set of subnets with different levels of internet access.

You'll start by defining the module's input variables, then build the VPC and all three subnet tiers.

  • Create the module's input variables by adding this code to modules/networking/variables.tf:
variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "azs" {
  description = "List of availability zones"
  type        = list(string)
}

What does this code do?

These three variables define what the networking module needs from the outside world. project_name is used to tag every resource. vpc_cidr sets the IP address range for the entire VPC. azs is a list of Availability Zones so the module can spread subnets across them for high availability.

  • Save modules/networking/variables.tf.
  • Confirm the file exists by running:
ls modules/networking/

You should see variables.tf in the output.

  • Now build the VPC and subnet tiers by adding this code to modules/networking/main.tf:
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# Public subnets (ALB)
resource "aws_subnet" "public" {
  count                   = length(var.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = var.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${var.azs[count.index]}"
  }
}

# Private subnets (Application tier)
resource "aws_subnet" "private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = var.azs[count.index]

  tags = {
    Name = "${var.project_name}-private-${var.azs[count.index]}"
  }
}

# Database subnets (isolated)
resource "aws_subnet" "database" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
  availability_zone = var.azs[count.index]

  tags = {
    Name = "${var.project_name}-database-${var.azs[count.index]}"
  }
}

What does this code do?

The VPC is your isolated virtual network in AWS. enable_dns_hostnames and enable_dns_support let resources inside the VPC resolve each other by hostname.

The cidrsubnet function carves smaller /24 subnets out of your /16 VPC CIDR. Public subnets start at offset 0 (10.0.0.0/24, 10.0.1.0/24), private at offset 10 (10.0.10.0/24), and database at offset 20 (10.0.20.0/24). This spacing leaves room for future expansion.

Only public subnets set map_public_ip_on_launch = true because resources in private and database subnets should never be directly reachable from the internet.

  • Save modules/networking/main.tf.

You won't see a change yet. The next chunk adds the gateways and routing that bring this network to life.

Seeing a syntax error in your editor?

Make sure every opening brace { has a matching closing brace }. Terraform's HCL syntax requires braces around resource bodies, tags blocks, and nested blocks like ingress.

Also check that string interpolations use the format ${var.project_name} with the dollar sign before the opening brace.

Help me debug a Terraform syntax error in my networking module

Add gateways and route tables

Subnets alone have no internet access. You need gateways to connect them to the outside world, and route tables to tell traffic where to go. Public subnets route through an Internet Gateway directly. Private subnets route through a NAT Gateway so they can reach the internet for updates without being reachable from it.

  • Add the Internet Gateway, NAT Gateway, and their dependencies below your subnet code in modules/networking/main.tf:
# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-igw"
  }
}

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

  tags = {
    Name = "${var.project_name}-nat-eip"
  }
}

# NAT Gateway (single for cost savings)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.project_name}-nat"
  }

  depends_on = [aws_internet_gateway.main]
}

Why a single NAT Gateway?

In production, you would place a NAT Gateway in each Availability Zone for redundancy. For this demo, a single NAT Gateway in public[0] keeps costs down. NAT Gateways cost ~$0.045/hour, so two would double that charge.

The depends_on ensures Terraform creates the Internet Gateway before the NAT Gateway, since the NAT needs internet connectivity to function.

  • Save modules/networking/main.tf.

You won't see a change yet. The route tables in the next chunk connect these gateways to the correct subnets.

  • Add the route tables and their subnet associations below the NAT Gateway in modules/networking/main.tf:
# Public route table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Private route table (with NAT)
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${var.project_name}-private-rt"
  }
}

resource "aws_route_table_association" "private" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.private[count.index].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 = "${var.project_name}-database-rt"
  }
}

resource "aws_route_table_association" "database" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.database[count.index].id
  route_table_id = aws_route_table.database.id
}

What does this code do?

Each route table defines where outbound traffic goes for its associated subnets:

  • Public route table: sends 0.0.0.0/0 (all internet-bound traffic) to the Internet Gateway. Resources here are directly reachable.
  • Private route table: sends 0.0.0.0/0 through the NAT Gateway. Resources can reach the internet for updates but are not reachable from it.
  • Database route table: has no routes to the internet at all. Database subnets are fully isolated.
  • Save modules/networking/main.tf.

You won't see a change yet. The next chunk adds security groups that control which traffic is allowed between tiers.

Unsure if your route tables are correct?

The most common mistake is putting the NAT Gateway route in the public route table or the Internet Gateway route in the private table. Remember: public subnets use gateway_id (Internet Gateway) and private subnets use nat_gateway_id (NAT Gateway).

Help me verify my Terraform route tables are wired correctly

Define security groups for each tier

Security groups act as virtual firewalls controlling which traffic can reach each tier. You'll create a chain: the ALB accepts HTTP from the internet, the application tier only accepts traffic from the ALB, and the database only accepts connections from the application tier. This is least-privilege access in action.

  • Add the ALB security group below the route table associations in modules/networking/main.tf:
# Security Groups
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group for the Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

What does this code do?

This security group allows HTTP traffic on port 80 from anywhere (0.0.0.0/0). It's the entry point for all external traffic into your architecture. The Application Load Balancer will use this group in a later step.

The egress rule with protocol -1 means "all protocols." This allows the ALB to send health check requests to its targets.

  • Save modules/networking/main.tf.

You won't see a change yet. The next chunk adds the application and database security groups that chain from this one.

  • Add the application tier and database security groups below the ALB security group in modules/networking/main.tf:
resource "aws_security_group" "app" {
  name        = "${var.project_name}-app-sg"
  description = "Security group for the application tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "HTTP from ALB"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-app-sg"
  }
}

resource "aws_security_group" "db" {
  name        = "${var.project_name}-db-sg"
  description = "Security group for the database tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "PostgreSQL from app tier"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-db-sg"
  }
}

What does this code do?

These two security groups create a chain of trust between tiers:

  • App SG: only allows port 80 traffic from the ALB security group. No direct internet access to application servers.
  • DB SG: only allows PostgreSQL connections (port 5432) from the app security group. The database is completely unreachable from the internet or the ALB.

Notice that instead of using CIDR blocks, each ingress rule references another security group by ID. This means access is tied to the resource's identity, not its IP address.

  • Save modules/networking/main.tf.

You won't see a change yet. The final piece is the outputs file that exposes these resources to other modules.

  • Create the module's outputs by adding this code to modules/networking/outputs.tf:
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = aws_subnet.private[*].id
}

output "database_subnet_ids" {
  description = "IDs of the database subnets"
  value       = aws_subnet.database[*].id
}

output "alb_security_group_id" {
  description = "ID of the ALB security group"
  value       = aws_security_group.alb.id
}

output "app_security_group_id" {
  description = "ID of the application security group"
  value       = aws_security_group.app.id
}

output "db_security_group_id" {
  description = "ID of the database security group"
  value       = aws_security_group.db.id
}

What does this code do?

Outputs are how Terraform modules share information with the rest of your configuration. Other modules (database, load balancer, DNS) will reference these outputs to place their resources in the correct subnets with the correct security groups.

The [*].id syntax is a splat expression. It collects the id attribute from every instance created by count and returns them as a list.

  • Save modules/networking/outputs.tf.
  • Confirm all three module files exist by running:
ls modules/networking/

You should see main.tf, outputs.tf, and variables.tf in the output.

Missing a file?

Check that you created each file inside modules/networking/ and not in the project root or another module folder. The directory matters because Terraform loads all .tf files within a module's directory together.

Help me find where my Terraform module files ended up

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files in modules/networking/.

ⓧ I'd like to double check the full code

Here is the complete modules/networking/main.tf:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# Public subnets (ALB)
resource "aws_subnet" "public" {
  count                   = length(var.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = var.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${var.azs[count.index]}"
  }
}

# Private subnets (Application tier)
resource "aws_subnet" "private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = var.azs[count.index]

  tags = {
    Name = "${var.project_name}-private-${var.azs[count.index]}"
  }
}

# Database subnets (isolated)
resource "aws_subnet" "database" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
  availability_zone = var.azs[count.index]

  tags = {
    Name = "${var.project_name}-database-${var.azs[count.index]}"
  }
}

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

  tags = {
    Name = "${var.project_name}-igw"
  }
}

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

  tags = {
    Name = "${var.project_name}-nat-eip"
  }
}

# NAT Gateway (single for cost savings)
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.project_name}-nat"
  }

  depends_on = [aws_internet_gateway.main]
}

# Public route table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Private route table (with NAT)
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${var.project_name}-private-rt"
  }
}

resource "aws_route_table_association" "private" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.private[count.index].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 = "${var.project_name}-database-rt"
  }
}

resource "aws_route_table_association" "database" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.database[count.index].id
  route_table_id = aws_route_table.database.id
}

# Security Groups
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group for the Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

resource "aws_security_group" "app" {
  name        = "${var.project_name}-app-sg"
  description = "Security group for the application tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "HTTP from ALB"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-app-sg"
  }
}

resource "aws_security_group" "db" {
  name        = "${var.project_name}-db-sg"
  description = "Security group for the database tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "PostgreSQL from app tier"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-db-sg"
  }
}

Here is the complete modules/networking/variables.tf:

variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "azs" {
  description = "List of availability zones"
  type        = list(string)
}

Here is the complete modules/networking/outputs.tf:

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

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = aws_subnet.private[*].id
}

output "database_subnet_ids" {
  description = "IDs of the database subnets"
  value       = aws_subnet.database[*].id
}

output "alb_security_group_id" {
  description = "ID of the ALB security group"
  value       = aws_security_group.alb.id
}

output "app_security_group_id" {
  description = "ID of the application security group"
  value       = aws_security_group.app.id
}

output "db_security_group_id" {
  description = "ID of the database security group"
  value       = aws_security_group.db.id
}

Your networking module is complete. It defines the entire network foundation: VPC, subnets across two Availability Zones, gateways, routing, and security boundaries. Next up, you'll build the database module that places an RDS instance inside the isolated database subnets you just created.

Build the Database Module

Your networking layer is locked in. The VPC, subnets, and security groups give your architecture its structure.

Now you need somewhere to store data. In this step, you will create a Terraform module that provisions an Amazon RDS PostgreSQL database inside those isolated database subnets. The database security group from the networking module ensures only the application tier can reach it on port 5432.

In this step, get ready to:
  • Create the database module's input variables.
  • Provision a DB subnet group and RDS PostgreSQL instance.
  • Output the database endpoint for other modules to consume.
Create the database module and DB subnet group

Every Terraform module needs its own set of input variables. The database module receives the subnet IDs and security group ID from the networking module so it can place the database in the right network segment.

  • Create and open the file modules/database/variables.tf in your editor.
  • Define the module's input variables by pasting this code:
variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "subnet_ids" {
  description = "List of subnet IDs for the DB subnet group"
  type        = list(string)
}

variable "security_group_id" {
  description = "Security group ID for the RDS instance"
  type        = string
}

variable "db_username" {
  description = "Master username for the database"
  type        = string
}

variable "db_password" {
  description = "Master password for the database"
  type        = string
  sensitive   = true
}

What do these variables do?

  • subnet_ids accepts the isolated database subnet IDs from the networking module, telling RDS where to place the instance.
  • security_group_id receives the database security group that only allows traffic from the app tier on port 5432.
  • db_password is marked sensitive = true so Terraform redacts it from plan output and state file previews.
  • Save modules/database/variables.tf.

Now you can create the DB subnet group that tells RDS which subnets it is allowed to use.

  • Create and open modules/database/main.tf.
  • Add the DB subnet group resource by pasting this code:
resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = var.subnet_ids

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

What is a DB subnet group?

RDS requires a DB subnet group to know which subnets it can launch instances in. AWS enforces that the group spans at least two Availability Zones for resilience.

Your networking module already created two database subnets in separate AZs, so passing them here satisfies that requirement.

  • Save modules/database/main.tf.
  • Confirm both files exist by running:
ls modules/database/

You should see main.tf and variables.tf listed.

Don't see both files?

  • Make sure you created the files inside modules/database/, not in the project root or another module folder.
  • Check for typos in the filenames. Terraform only reads files ending in .tf.

Still stuck? Why can't Terraform find my database module files?

Provision the RDS PostgreSQL instance

With the subnet group in place, you can now define the actual database instance. This resource tells AWS what engine to use, how large the instance should be, and which credentials to configure.

  • In modules/database/main.tf, add the RDS instance resource below the DB subnet group by pasting this code:
resource "aws_db_instance" "main" {
  identifier     = "${var.project_name}-postgres"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.t3.micro"

  allocated_storage = 20
  storage_type      = "gp2"

  db_name  = "appdb"
  username = var.db_username
  password = var.db_password
  port     = 5432

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [var.security_group_id]

  publicly_accessible = false
  skip_final_snapshot = true
  multi_az            = false

  tags = {
    Name = "${var.project_name}-postgres"
  }
}

What does this configuration do?

  • engine = "postgres" with engine_version = "16.4" tells AWS to run PostgreSQL 16.4.
  • instance_class = "db.t3.micro" selects the smallest instance size, covered by AWS Free Tier credits for new accounts.
  • publicly_accessible = false ensures no public IP is assigned. The database is reachable only from within the VPC.
  • skip_final_snapshot = true lets Terraform destroy the instance without requiring a final backup. Essential for a demo environment.
  • multi_az = false keeps costs down by running a single database instance instead of a standby replica.

Why these cost-saving choices?

A production database would use Multi-AZ for automatic failover. For a demo project, single-AZ with db.t3.micro keeps your bill near zero while still teaching the correct architecture patterns.

  • Save modules/database/main.tf.
  • You won't see a change yet. The next substep adds outputs so other modules can reference the database endpoint.

Seeing a syntax error in your editor?

  • Check that the aws_db_subnet_group resource has a closing } before the aws_db_instance resource begins.
  • Verify that every opening { has a matching }. The RDS resource has one nested tags block inside it.

Need help? Help me fix a syntax error in my database module main.tf

✔️ Awesome, I've got everything!

Great. Make sure you have saved modules/database/main.tf with both resources.

ⓧ I'd like to double check the full code

Here is the complete modules/database/main.tf file:

resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = var.subnet_ids

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

resource "aws_db_instance" "main" {
  identifier     = "${var.project_name}-postgres"
  engine         = "postgres"
  engine_version = "16.4"
  instance_class = "db.t3.micro"

  allocated_storage = 20
  storage_type      = "gp2"

  db_name  = "appdb"
  username = var.db_username
  password = var.db_password
  port     = 5432

  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [var.security_group_id]

  publicly_accessible = false
  skip_final_snapshot = true
  multi_az            = false

  tags = {
    Name = "${var.project_name}-postgres"
  }
}
Define the module outputs

Other modules in this project need the database endpoint to configure connections. Terraform outputs expose values from one module so they can be referenced elsewhere.

  • Create and open modules/database/outputs.tf.
  • Define the module's outputs by pasting this code:
output "db_endpoint" {
  description = "Endpoint of the RDS instance"
  value       = aws_db_instance.main.endpoint
}

output "db_name" {
  description = "Name of the database"
  value       = aws_db_instance.main.db_name
}

What do these outputs expose?

  • db_endpoint returns the hostname and port of the RDS instance (e.g. three-tier-demo-postgres.abc123.us-east-1.rds.amazonaws.com:5432). Application code uses this to connect.
  • db_name returns appdb. Consumers of this module can reference it without hardcoding the database name.
  • Save modules/database/outputs.tf.
  • Verify all three database module files exist by running:
ls modules/database/

You should see main.tf, outputs.tf, and variables.tf listed.

Missing outputs.tf?

  • Make sure you saved the file inside modules/database/, not in the project root.
  • Double-check the filename is exactly outputs.tf with no extra characters.

Need help? Help me verify my database module file structure is correct

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in modules/database/.

ⓧ I'd like to double check the full code

Here is the complete modules/database/outputs.tf:

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

output "db_name" {
  description = "Name of the database"
  value       = aws_db_instance.main.db_name
}

And here is the complete modules/database/variables.tf:

variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "subnet_ids" {
  description = "List of subnet IDs for the DB subnet group"
  type        = list(string)
}

variable "security_group_id" {
  description = "Security group ID for the RDS instance"
  type        = string
}

variable "db_username" {
  description = "Master username for the database"
  type        = string
}

variable "db_password" {
  description = "Master password for the database"
  type        = string
  sensitive   = true
}

Your database module is complete. Next, you will build the load balancer module that sits in the public subnets and distributes traffic across your application tier.

Build the Load Balancer Module

Your database is isolated in private subnets, exactly where it belongs. Now you need a way to receive traffic from the internet and route it to your application tier.

An Application Load Balancer sits in your public subnets and distributes incoming HTTP requests across targets in your private subnets. In this step, you'll create the ALB, a target group that defines where traffic goes, and a listener that tells the ALB which port to accept connections on.

In this step, get ready to:
  • Create an Application Load Balancer in the public subnets with the ALB security group.
  • Create a target group with a health check that monitors port 80.
  • Create an HTTP listener on port 80 that forwards traffic to the target group.
Create the Application Load Balancer

The load balancer module needs to know which VPC, subnets, and security group to use. You'll define these as input variables first, then create the ALB resource.

  • Create a new file called variables.tf inside the modules/loadbalancer directory and paste the following code:
variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID for the target group"
  type        = string
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs for the ALB"
  type        = list(string)
}

variable "security_group_id" {
  description = "Security group ID for the ALB"
  type        = string
}

What does this code do?

These four variables accept the values your root module will pass in when it calls this module.

  • vpc_id tells the target group which VPC it belongs to.
  • public_subnet_ids determines which subnets the ALB spans (it needs at least two for high availability).
  • security_group_id is the ALB security group you created in the networking module, which allows HTTP port 80 inbound from anywhere.
  • Save modules/loadbalancer/variables.tf.
  • Now create a new file called main.tf inside modules/loadbalancer and paste the ALB resource:
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [var.security_group_id]
  subnets            = var.public_subnet_ids

  tags = {
    Name = "${var.project_name}-alb"
  }
}

What does this code do?

  • internal = false makes this an internet-facing load balancer. Internal ALBs only accept traffic from within the VPC.
  • load_balancer_type = "application" creates an ALB (Layer 7), which can route based on HTTP paths and headers.
  • The subnets list places ALB nodes in both public subnets across your two Availability Zones.
  • Save modules/loadbalancer/main.tf.
  • Confirm both new files exist by running:
ls modules/loadbalancer/

You should see main.tf and variables.tf listed in the output.

Don't see both files?

Make sure you created the files inside modules/loadbalancer/ and not in the root project directory or another module folder.

help me find where my loadbalancer module files ended up

Create a target group with health check

A target group tells the ALB where to send traffic and how to verify that the targets are healthy. The health check pings each target on a schedule and removes unhealthy ones from rotation.

  • In modules/loadbalancer/main.tf, add the target group resource below the aws_lb resource you just wrote:
resource "aws_lb_target_group" "main" {
  name     = "${var.project_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = var.vpc_id

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }

  tags = {
    Name = "${var.project_name}-tg"
  }
}

What does the health check do?

  • Every 30 seconds (interval), the ALB sends an HTTP GET to / on each target.
  • If the target returns HTTP 200 twice in a row (healthy_threshold = 2), it is marked healthy.
  • If it fails 3 times (unhealthy_threshold = 3), the ALB stops sending traffic to it.
  • port = "traffic-port" means the health check uses the same port (80) that the target group receives traffic on.
  • Save modules/loadbalancer/main.tf.
  • You won't see a change yet. The next chunk adds the listener that connects the ALB to this target group.
Create the HTTP listener and define outputs

A listener watches a specific port on the ALB and decides what to do with incoming connections. Your listener will accept HTTP traffic on port 80 and forward it to the target group.

  • In modules/loadbalancer/main.tf, add the listener resource below the aws_lb_target_group resource:
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?

  • The listener binds to port 80 on the ALB using load_balancer_arn.
  • default_action forwards all incoming requests to the target group. In production, you could add rules to route by path or host header.
  • Save modules/loadbalancer/main.tf.

Now define the module's outputs so other modules (like DNS) can reference the ALB's DNS name and zone ID.

  • Create a new file called outputs.tf inside modules/loadbalancer and paste the following code:
output "alb_dns_name" {
  description = "DNS name of the ALB"
  value       = aws_lb.main.dns_name
}

output "alb_zone_id" {
  description = "Hosted zone ID of the ALB"
  value       = aws_lb.main.zone_id
}

output "target_group_arn" {
  description = "ARN of the target group"
  value       = aws_lb_target_group.main.arn
}

Why expose these three outputs?

  • alb_dns_name is the URL you'll point your Route 53 record at in the next step.
  • alb_zone_id is required by Route 53 alias records to identify the ALB's hosted zone.
  • target_group_arn lets you attach compute instances (like an Auto Scaling Group) to this target group later.
  • Save modules/loadbalancer/outputs.tf.
  • Confirm all three files are in place by running:
ls modules/loadbalancer/

You should see main.tf, outputs.tf, and variables.tf in the output.

Missing a file?

Double check that you created all three files inside modules/loadbalancer/ specifically. A common mistake is saving outputs.tf in the root directory or inside another module folder.

help me find my missing loadbalancer module file

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete modules/loadbalancer/main.tf:

resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [var.security_group_id]
  subnets            = var.public_subnet_ids

  tags = {
    Name = "${var.project_name}-alb"
  }
}

resource "aws_lb_target_group" "main" {
  name     = "${var.project_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = var.vpc_id

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }

  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 modules/loadbalancer/variables.tf:

variable "project_name" {
  description = "Project name for resource naming"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID for the target group"
  type        = string
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs for the ALB"
  type        = list(string)
}

variable "security_group_id" {
  description = "Security group ID for the ALB"
  type        = string
}

Here is the complete modules/loadbalancer/outputs.tf:

output "alb_dns_name" {
  description = "DNS name of the ALB"
  value       = aws_lb.main.dns_name
}

output "alb_zone_id" {
  description = "Hosted zone ID of the ALB"
  value       = aws_lb.main.zone_id
}

output "target_group_arn" {
  description = "ARN of the target group"
  value       = aws_lb_target_group.main.arn
}

Your load balancer module is ready to distribute traffic across your private subnets. Next up, you'll create a Route 53 DNS module that points a domain name at this ALB.

Build the DNS Module

Your load balancer is ready to distribute traffic, but right now the only way to reach it is through an auto-generated AWS hostname. Real infrastructure uses human-readable domain names routed through DNS.

In this step, you'll build the DNS module using Route 53. Route 53 is AWS's DNS service. You'll create a hosted zone for your domain and an alias record that points directly to your ALB.

In this step, get ready to:
  • Create a Route 53 public hosted zone for your domain.
  • Create an alias A record pointing the zone apex to the ALB.
  • Output the hosted zone nameservers and zone ID.
Create the Route 53 hosted zone

A hosted zone is a container for DNS records belonging to a single domain. When you create one, Route 53 automatically assigns four nameservers that can answer queries for that domain.

The DNS module needs three inputs: the domain name itself, and the ALB's DNS name and zone ID (which the load balancer module already outputs).

  • Create the module's variable definitions by adding this code to modules/dns/variables.tf:
variable "domain_name" {
  description = "Domain name for the hosted zone"
  type        = string
}

variable "alb_dns_name" {
  description = "DNS name of the ALB"
  type        = string
}

variable "alb_zone_id" {
  description = "Hosted zone ID of the ALB"
  type        = string
}

What does this code do?

These three variables define everything the DNS module needs from the outside world.

  • domain_name is the domain for which Route 53 will host DNS records.
  • alb_dns_name and alb_zone_id come from the load balancer module's outputs. AWS needs both to create an alias record.
  • Save modules/dns/variables.tf.

Now you can write the hosted zone resource that uses these variables.

  • Create the hosted zone by adding this code to modules/dns/main.tf:
resource "aws_route53_zone" "main" {
  name = var.domain_name

  tags = {
    Name = var.domain_name
  }
}

What does this code do?

The aws_route53_zone resource creates a public hosted zone for your domain. Route 53 automatically assigns four nameservers to the zone. If you owned this domain, you'd point your domain registrar at these nameservers to activate DNS resolution.

  • Save modules/dns/main.tf.
  • Confirm both files exist by running:
ls modules/dns/
  • You should see main.tf and variables.tf in the output.

Don't see both files?

Make sure you're running ls from the project root directory (three-tier-infra). Check that the modules/dns/ directory was created in an earlier step and that you saved both files inside it.

Help me find my DNS module files.

Add the alias A record

An alias record is a Route 53-specific DNS record type that maps your domain directly to an AWS resource like an ALB. Unlike a standard CNAME, an alias record works at the zone apex and incurs no query charges when pointed at an Elastic Load Balancer.

  • In modules/dns/main.tf, add the alias record below the hosted zone resource:
resource "aws_route53_record" "alb_alias" {
  zone_id = aws_route53_zone.main.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = var.alb_dns_name
    zone_id                = var.alb_zone_id
    evaluate_target_health = true
  }
}

What does this code do?

  • zone_id tells Route 53 which hosted zone owns this record. It references the zone you just created above.
  • type = "A" with an alias block is how Terraform creates an alias record. It resolves to the ALB's IP addresses without a traditional CNAME hop.
  • evaluate_target_health = true makes Route 53 check the ALB's health before returning it in DNS responses. If the ALB is unhealthy, Route 53 stops routing traffic to it.

Why an alias record instead of a CNAME?

A CNAME cannot be placed at the zone apex (the bare domain like example.com). AWS alias records solve this limitation. They also skip the extra DNS lookup that a CNAME requires, and Route 53 does not charge for alias queries to AWS resources like load balancers.

  • Save modules/dns/main.tf.
  • You won't see a change yet. The next substep adds the outputs, and you'll verify the full module in the final step when you run terraform plan.
Output the nameservers and zone ID

The root module needs the nameservers so you can display them after deployment. If you owned a real domain, you'd copy these nameservers into your registrar to activate the hosted zone.

  • Define the module's outputs by adding this code to modules/dns/outputs.tf:
output "nameservers" {
  description = "Nameservers for the hosted zone"
  value       = aws_route53_zone.main.name_servers
}

output "zone_id" {
  description = "ID of the hosted zone"
  value       = aws_route53_zone.main.zone_id
}

What does this code do?

  • nameservers exposes the four Route 53 nameservers assigned to your hosted zone. The root module will display these after terraform apply.
  • zone_id outputs the hosted zone's unique identifier. Other modules or future records can reference it.
  • Save modules/dns/outputs.tf.
  • Confirm all three DNS module files exist by running:
ls modules/dns/
  • You should see main.tf, outputs.tf, and variables.tf listed.

Missing a file?

If you only see two files, check that you created outputs.tf inside the modules/dns/ directory and not in the project root or another module's folder.

Help me find my missing DNS module file.

What about the Route 53 cost?

A Route 53 hosted zone costs $0.50 per month. However, if you delete the hosted zone within 12 hours of creation, you won't be charged. Alias queries to AWS resources like your ALB incur no query charges.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files in modules/dns/.

ⓧ I'd like to double check the full code

Here is the complete code for all three DNS module files.

variable "domain_name" {
  description = "Domain name for the hosted zone"
  type        = string
}

variable "alb_dns_name" {
  description = "DNS name of the ALB"
  type        = string
}

variable "alb_zone_id" {
  description = "Hosted zone ID of the ALB"
  type        = string
}
resource "aws_route53_zone" "main" {
  name = var.domain_name

  tags = {
    Name = var.domain_name
  }
}

resource "aws_route53_record" "alb_alias" {
  zone_id = aws_route53_zone.main.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = var.alb_dns_name
    zone_id                = var.alb_zone_id
    evaluate_target_health = true
  }
}
output "nameservers" {
  description = "Nameservers for the hosted zone"
  value       = aws_route53_zone.main.name_servers
}

output "zone_id" {
  description = "ID of the hosted zone"
  value       = aws_route53_zone.main.zone_id
}

Your DNS module is complete. All four modules (networking, database, loadbalancer, dns) are now written and ready to be wired together. Next up, you'll connect every module in the root main.tf and deploy the entire three-tier architecture with a single terraform apply.

Wire It All Together and Deploy

You have four complete modules sitting in your project directory. Each one knows how to provision its own slice of infrastructure. But right now, nothing connects them.

The root main.tf is where you orchestrate everything. It calls each module, passes the right variables in, and threads outputs from one module into inputs for the next. Once that wiring is done, a single terraform apply command deploys your entire three-tier architecture.

In this step, get ready to:
  • Write the root main.tf wiring all modules together with cross-module references.
  • Write outputs.tf to surface the ALB DNS name, Route 53 nameservers, and RDS endpoint.
  • Deploy the full stack with terraform init, terraform plan, and terraform apply.
Write the root main.tf with module calls

The root main.tf is the orchestration layer. It tells Terraform which modules to call, what variables to feed them, and how their outputs connect to each other.

  • Create a new file called main.tf in your project root (the same directory as versions.tf).
  • Start by adding the data source and networking module by pasting this code:
data "aws_availability_zones" "available" {
  state = "available"
}

module "networking" {
  source = "./modules/networking"

  project_name = var.project_name
  vpc_cidr     = var.vpc_cidr
  azs          = slice(data.aws_availability_zones.available.names, 0, 2)
}

What does this code do?

The data "aws_availability_zones" block fetches all available AZs in your region at plan time. This means your code adapts to any region without hardcoding zone names.

The slice(..., 0, 2) expression picks only the first two AZs from that list. This gives you multi-AZ redundancy without over-provisioning.

  • Below the networking module, add the database, load balancer, and DNS modules:
module "database" {
  source = "./modules/database"

  project_name       = var.project_name
  subnet_ids         = module.networking.database_subnet_ids
  security_group_id  = module.networking.db_security_group_id
  db_username        = var.db_username
  db_password        = var.db_password
}

module "loadbalancer" {
  source = "./modules/loadbalancer"

  project_name      = var.project_name
  vpc_id            = module.networking.vpc_id
  public_subnet_ids = module.networking.public_subnet_ids
  security_group_id = module.networking.alb_security_group_id
}

module "dns" {
  source = "./modules/dns"

  domain_name  = var.domain_name
  alb_dns_name = module.loadbalancer.alb_dns_name
  alb_zone_id  = module.loadbalancer.alb_zone_id
}

How do the modules connect?

Each module.networking.* reference pulls an output value from the networking module and passes it as an input to the next module. Terraform automatically figures out the dependency order from these references.

The database module gets its subnets and security group from networking. The load balancer module gets the VPC ID and public subnets. The DNS module gets the ALB's DNS name and zone ID from the load balancer module.

  • Save main.tf.

Not sure about the file location?

The main.tf file goes in your project root directory, alongside versions.tf, variables.tf, and terraform.tfvars. It does not go inside any modules/ subfolder.

Help me figure out where to put my main.tf file.

Write outputs.tf to surface key deployment values

Outputs let you see critical information about your deployed infrastructure without digging through the AWS Console. After terraform apply completes, these values print directly in your terminal.

  • Create a new file called outputs.tf in your project root and paste this code:
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = module.loadbalancer.alb_dns_name
}

output "route53_nameservers" {
  description = "Nameservers for the Route 53 hosted zone"
  value       = module.dns.nameservers
}

output "rds_endpoint" {
  description = "Endpoint of the RDS PostgreSQL instance"
  value       = module.database.db_endpoint
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = module.networking.vpc_id
}

What does this code do?

Each output block reaches into a module and pulls out a specific value. After deployment, you'll see the ALB's public URL, the nameservers you'd configure at your domain registrar, the RDS connection endpoint, and the VPC ID for reference.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved both main.tf and outputs.tf in your project root.

ⓧ I'd like to double check the full code

Here is the complete main.tf:

data "aws_availability_zones" "available" {
  state = "available"
}

module "networking" {
  source = "./modules/networking"

  project_name = var.project_name
  vpc_cidr     = var.vpc_cidr
  azs          = slice(data.aws_availability_zones.available.names, 0, 2)
}

module "database" {
  source = "./modules/database"

  project_name       = var.project_name
  subnet_ids         = module.networking.database_subnet_ids
  security_group_id  = module.networking.db_security_group_id
  db_username        = var.db_username
  db_password        = var.db_password
}

module "loadbalancer" {
  source = "./modules/loadbalancer"

  project_name      = var.project_name
  vpc_id            = module.networking.vpc_id
  public_subnet_ids = module.networking.public_subnet_ids
  security_group_id = module.networking.alb_security_group_id
}

module "dns" {
  source = "./modules/dns"

  domain_name  = var.domain_name
  alb_dns_name = module.loadbalancer.alb_dns_name
  alb_zone_id  = module.loadbalancer.alb_zone_id
}

Here is the complete outputs.tf:

output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = module.loadbalancer.alb_dns_name
}

output "route53_nameservers" {
  description = "Nameservers for the Route 53 hosted zone"
  value       = module.dns.nameservers
}

output "rds_endpoint" {
  description = "Endpoint of the RDS PostgreSQL instance"
  value       = module.database.db_endpoint
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = module.networking.vpc_id
}
Deploy the full stack with Terraform

With all configuration files in place, you'll run three commands to bring the infrastructure to life. Each command serves a distinct purpose in the Terraform workflow.

  • Initialize the project by running this command:
terraform init

What does this command do?

The terraform init command downloads the AWS provider plugin and registers all four modules. You'll see messages confirming the provider was installed and the modules were initialized.

  • You should see output ending with a message that Terraform has been successfully initialized.

Seeing a module error?

If Terraform reports a module source path error, check that each source value in main.tf matches the actual folder names inside your modules/ directory (networking, database, loadbalancer, dns).

Help me debug my terraform init error.

  • Preview the resources Terraform will create by running:
terraform plan -var="db_password=YourSecurePass123!"

Why pass db_password on the command line?

The db_password variable is marked sensitive and has no default value. If you don't supply it, Terraform will prompt you to type it interactively. The -var flag passes the value directly so you can see the full plan without interruption.

Replace YourSecurePass123! with any password you choose. This is for a demo database that you'll destroy at the end of the project.

  • Review the plan output. You should see Terraform reporting resources to add (VPC, subnets, security groups, NAT Gateway, ALB, RDS instance, Route 53 zone, and more) with zero to change and zero to destroy.
  • Deploy the infrastructure by running:
terraform apply -var="db_password=YourSecurePass123!"

What happens during apply?

Terraform will show the same plan and ask you to confirm. Type yes and press Enter to approve. Terraform then creates all resources in dependency order, running independent resources in parallel where possible.

The RDS instance takes the longest to create (typically 3-5 minutes). Be patient while it provisions.

  • When prompted, type yes and press Enter.
  • Wait for the deployment to complete. You should see output showing all four outputs: alb_dns_name, route53_nameservers, rds_endpoint, and vpc_id.

Apply failed with an error?

If you see an error about insufficient permissions, confirm your AWS credentials have admin access by running aws sts get-caller-identity.

If the error mentions a resource name conflict (like the security group name already exists), a previous partial apply may have left resources behind. Run terraform destroy -var="db_password=YourSecurePass123!" first, then try terraform apply again.

Help me debug my terraform apply error.

Your entire three-tier architecture is now live in AWS. Next up, you'll extend it with an Auto Scaling Group in the Secret Mission.

Secret mission

Add an Auto Scaling Group with EC2 Instances

Your three-tier architecture has networking, a load balancer, a database, and DNS. But the ALB target group is empty. In this secret mission, you'll add the missing compute layer: an Auto Scaling Group that launches EC2 instances serving a health check page through the ALB.

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. This project deploys AWS resources that incur hourly charges while running.

Cost warning

Your NAT Gateway and Application Load Balancer charge approximately $0.045/hr and $0.0225/hr respectively. Left running, this architecture costs roughly $3-5 per day.

RDS db.t3.micro and EC2 t3.micro instances are covered by Free Tier credits for eligible accounts. The Route 53 hosted zone is $0.50/month but free if deleted within 12 hours of creation.

Resources you used:

  • NAT Gateway (hourly charge ~$0.045/hr plus data processing).
  • Application Load Balancer (hourly charge ~$0.0225/hr plus LCU charges).
  • RDS PostgreSQL instance (db.t3.micro, covered by Free Tier credits for new accounts).
  • Route 53 hosted zone ($0.50/month, free if deleted within 12 hours).
  • EC2 instances in the Auto Scaling Group (t3.micro, covered by Free Tier credits for eligible accounts).
  • Local Terraform project files on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building on this architecture or want to demo it to others.

  • Your resources will continue running and incurring charges (~$3-5/day primarily from the NAT Gateway and ALB).
  • RDS and EC2 costs are covered by Free Tier credits for eligible accounts.
  • Because your entire infrastructure is defined in Terraform code, you can always destroy and recreate it in minutes whenever you want.

Pause - I'll come back to this later

Pausing is not practical for this architecture. There is no way to stop a NAT Gateway or ALB without deleting them.

  • The recommended approach is to destroy all resources now and redeploy from your Terraform code later.
  • Your local Terraform files contain everything needed to recreate the full stack in minutes.
  • Follow the instructions in the Delete tab to tear down your AWS resources, but keep your local project folder intact for redeployment.

Delete - I don't want to use this again

Remove all project resources and start fresh. This destroys every AWS resource and deletes your local files.

  • Make sure you are inside your project folder (the one containing main.tf) in your terminal.
  • Destroy all AWS resources by running:
terraform destroy
  • Terraform will show you a plan of everything it will delete. Type yes when prompted to confirm.
  • Wait for the destroy to complete. This may take several minutes as RDS instances take time to delete.

What does terraform destroy remove?

This single command tears down all resources Terraform manages: the VPC, subnets, NAT Gateway, Internet Gateway, ALB, target group, RDS instance, Route 53 hosted zone, security groups, EC2 instances, and Auto Scaling Group. Everything defined in your .tf files is destroyed.

  • After the destroy completes, delete your local project files. Navigate to your Desktop and remove the project folder:

macOS

cd ~/Desktop
rm -rf three-tier-demo

Windows

cd ~/Desktop
Remove-Item -Recurse -Force three-tier-demo
  • Confirm the folder is gone by running:
ls
  • You should no longer see the three-tier-demo folder in the output.

Destroy failed or timed out?

If terraform destroy fails partway through, run it again. Terraform tracks state and will retry deleting any remaining resources. If a specific resource is stuck, check the AWS Console to delete it manually, then run terraform destroy once more to clean up the rest.

Help me troubleshoot my terraform destroy failure

Nice Work!

Nice Work!

You just built a production-grade three-tier AWS architecture entirely with Terraform. This is the same modular, multi-AZ pattern used by platform teams at companies deploying real workloads to AWS.

You've learned how to:

  • Designed a multi-AZ VPC with proper network segmentation separating public, private, and database tiers using security groups enforcing least-privilege access between each layer.
  • Provisioned an Application Load Balancer, an RDS PostgreSQL database, and Route 53 DNS with an alias record routing traffic through a complete three-tier architecture.
  • Structured your Terraform code into reusable modules (networking, database, loadbalancer, dns) following enterprise landing zone patterns that you can showcase on GitHub.
  • Added an Auto Scaling Group with EC2 instances serving live traffic through the ALB, proving end-to-end connectivity across all three tiers.

Ready to quiz yourself?