Multi-Tier Architecture with Terraform

Deploy a 3-tier AWS VPC with an ALB and security groups using Terraform.

Introduction

30 Second Summary

Every production application you use daily separates its public-facing layer, its business logic, and its data storage into distinct tiers. This separation keeps your data secure and your systems reliable.

In this project, you will use Terraform to deploy a complete 3-tier AWS architecture with a VPC, subnets across two Availability Zones, security groups, and an Application Load Balancer.

What You'll Build

A production-grade, multi-tier AWS network architecture defined entirely in Terraform configuration files that you can version-control and redeploy at will.

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

  • A VPC spanning two Availability Zones with six subnets organized into public, application, and data tiers.
  • An internet-facing Application Load Balancer with a target group and HTTP listener routing traffic to your application tier.
  • Three security groups enforcing least-privilege communication rules between tiers.
  • Secret Mission: Add an EC2 Auto Scaling Group behind the ALB that serves a simple health check page.

Are there any prerequisites?

You need an AWS account with the AWS CLI already configured on your machine. You also need Homebrew installed on macOS for installing Terraform in Step 1.

If you have not set up AWS CLI credentials yet, complete that first before starting this project.

Set Up Terraform and Project Structure

To deploy our multi-tier architecture, we need a tool that can define and manage cloud infrastructure as code. Terraform lets us describe AWS resources in configuration files, then create, update, and destroy them with simple commands.

In this step, you'll install Terraform, verify your AWS credentials are working, and create the project directory with all the files you need. You'll also write the initial provider configuration and initialize the project so Terraform can talk to AWS.

In this step, get ready to:
  • Install Terraform and confirm AWS access.
  • Create the project directory and files.
  • Write the provider configuration and initialize the project.
Install Terraform and verify AWS credentials

Terraform is a CLI tool that reads .tf configuration files and translates them into API calls against cloud providers. You'll install it via Homebrew, then confirm your AWS CLI credentials still work.

  • Check whether Terraform is already on your machine by running this command:
terraform version

✔️ I see a version number

Terraform is already installed and ready to go. Skip ahead to the AWS credentials check below.

ⓧ Command not found

Terraform isn't installed yet. Install it through Homebrew's official HashiCorp tap.

  • Add the HashiCorp tap to Homebrew by running this command:
brew tap hashicorp/tap
  • Install Terraform by running:
brew install hashicorp/tap/terraform
  • Confirm the installation succeeded by running:
terraform version

You should see output like Terraform v1.15.7 (your exact version number may differ).

Seeing 'brew: command not found'?

This means Homebrew isn't installed on your machine. Visit brew.sh to install it first, then come back and re-run the commands above.

Help me troubleshoot my Terraform installation

  • Confirm your AWS CLI credentials are working by running this command:
aws sts get-caller-identity

You should see a JSON response containing your AWS Account ID, User ARN, and User ID.

What does this command do?

This command asks AWS "who am I?" using your stored credentials. If you see a valid response, Terraform will be able to use those same credentials to create resources on your behalf.

Seeing 'Unable to locate credentials'?

Your AWS CLI isn't configured yet. Run aws configure and enter your Access Key ID, Secret Access Key, and default region (us-east-1).

Help me fix my AWS CLI credentials

Create the project directory and file structure

Your Terraform project needs a dedicated folder with separate files for each logical concern. This keeps the configuration organized as it grows.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir multi-tier-terraform
cd multi-tier-terraform
  • Create the six configuration files by running this command:
touch providers.tf variables.tf vpc.tf security_groups.tf alb.tf outputs.tf
  • Confirm the files exist by running:
ls

You should see alb.tf, outputs.tf, providers.tf, security_groups.tf, variables.tf, and vpc.tf in the output.

Why separate files?

Terraform reads all .tf files in a directory as one configuration. Splitting resources by concern (networking, security, load balancing) makes the project easy to navigate and maintain.

  • Open the multi-tier-terraform folder in VS Code by running:
code .

Seeing 'code: command not found'?

Open VS Code manually. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) inside VS Code, type "Shell Command", and select Install 'code' command in PATH. Then try again.

Help me open my project folder in VS Code

Write the provider configuration and initialize Terraform

Before Terraform can manage any resources, it needs to know which cloud provider to talk to and which version of the provider plugin to download. You'll define this in providers.tf.

  • In VS Code, open providers.tf from the Explorer sidebar and paste this code:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block declares which provider plugins to download. Here you're requesting the official AWS provider from HashiCorp's registry, pinned to any version in the 6.x range.

The provider "aws" block configures the provider itself. The region is set using a variable (var.aws_region) that you'll define next. Terraform picks up your AWS credentials automatically from the ~/.aws/credentials file.

  • Save providers.tf.

Now define the variables that providers.tf references. The variables.tf file stores all configurable values in one place, so you can change things like region or naming prefix without editing multiple files.

  • Open variables.tf and paste this code:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "multi-tier"
}

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

What does this code do?

Each variable block declares a named input with a type and a default value. Other files reference these using var.<name> syntax.

  • aws_region controls which AWS region your resources deploy to.
  • project_name is a prefix added to every resource name so you can identify them in the console.
  • vpc_cidr sets the IP address range for your entire VPC network.
  • Save variables.tf.

✔️ Awesome, I've got everything!

Great. Double check that both files are saved before moving on to the next command.

ⓧ I'd like to double check the full code

Here is the full providers.tf:

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

provider "aws" {
  region = var.aws_region
}

Here is the full variables.tf:

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

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "multi-tier"
}

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

With the provider configured, you can now initialize Terraform. This downloads the AWS provider plugin so Terraform knows how to create AWS resources.

  • Initialize the project by running this command in your terminal (make sure you're inside the multi-tier-terraform folder):
terraform init

You should see output ending with Terraform has been successfully initialized!

What does terraform init do?

This command reads your required_providers block, downloads the matching plugin (the AWS provider ~> 6.0), and stores it in a hidden .terraform/ directory.

It also creates a .terraform.lock.hcl file that pins the exact provider version for reproducibility. You only need to run terraform init once unless you change provider requirements.

Seeing 'Error: Failed to install provider'?

Make sure your terminal is inside the multi-tier-terraform folder where providers.tf lives. Run pwd to check your current directory.

Also confirm you have an internet connection. Terraform needs to download the provider plugin from the HashiCorp registry.

Help me fix my terraform init error

Your environment is ready and Terraform can talk to AWS. Next up, you'll build the VPC and subnet architecture that forms the network foundation for the entire project.

Build the Network Foundation

Your Terraform project is initialized and ready to go. Now it's time to define the actual cloud infrastructure that will host your multi-tier application.

Every production application needs network isolation between its tiers. In this step, you'll create a VPC with six subnets spread across two Availability Zones, giving your architecture both security and high availability.

In this step, get ready to:
  • Define a VPC with an Internet Gateway for public internet access.
  • Create six subnets across two Availability Zones (public, application, and data tiers).
  • Configure routing to direct public traffic through the Internet Gateway.
Define the VPC and Internet Gateway

A VPC is your own private network inside AWS. It defines the IP address range that all your resources will live within. The Internet Gateway connects your VPC to the public internet so the load balancer can receive external traffic.

  • In VS Code, open vpc.tf from the multi-tier-terraform folder.
  • Add the following code to define the VPC, a data source to look up available Availability Zones, and the Internet Gateway:
# Look up which Availability Zones are available in the region
data "aws_availability_zones" "available" {
  state = "available"
}

# Create the VPC with DNS support for internal hostname resolution
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

# Attach an Internet Gateway so public subnets can reach the internet
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

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

What does this code do?

The data "aws_availability_zones" block queries AWS to find which AZs are available in your region. This means your code works in any region without hardcoding zone names.

The VPC uses a 10.0.0.0/16 CIDR block (from your vpc_cidr variable), giving you over 65,000 IP addresses to carve into subnets. enable_dns_hostnames = true lets AWS assign readable DNS names to your resources.

What is a CIDR block?

A CIDR block defines a range of IP addresses. 10.0.0.0/16 means "all addresses from 10.0.0.0 to 10.0.255.255". The /16 tells you how many bits are fixed. A smaller number after the slash means a bigger range.

  • Save vpc.tf.

Seeing red squiggly lines in VS Code?

If VS Code underlines your HCL code, it may not have a Terraform language extension installed. The code will still work fine with terraform plan regardless of editor highlighting.

Make sure your string interpolations use the exact syntax ${var.project_name} with the dollar sign and curly braces.

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

Create six subnets across two Availability Zones

Subnets divide your VPC into smaller network segments. Public subnets get direct internet access. Private subnets stay isolated, only reachable from within the VPC.

  • In vpc.tf, add the two public subnets below your Internet Gateway resource:
# Public subnet in AZ 1 - hosts internet-facing resources like the ALB
resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

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

# Public subnet in AZ 2 - redundant public tier for high availability
resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

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

What does map_public_ip_on_launch do?

Setting map_public_ip_on_launch = true means any resource launched in this subnet automatically gets a public IP address. Only the public tier subnets need this.

Each subnet references a different AZ using data.aws_availability_zones.available.names[0] and names[1]. This spreads your infrastructure across physically separate data centres for fault tolerance.

  • Now add the four private subnets below the public subnets. These cover the application tier and the data tier:
# Application tier subnet in AZ 1 - hosts app servers
resource "aws_subnet" "app_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.project_name}-app-1"
    Tier = "application"
  }
}

# Application tier subnet in AZ 2 - redundant app tier
resource "aws_subnet" "app_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.12.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name = "${var.project_name}-app-2"
    Tier = "application"
  }
}

# Data tier subnet in AZ 1 - hosts databases
resource "aws_subnet" "data_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.21.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.project_name}-data-1"
    Tier = "data"
  }
}

# Data tier subnet in AZ 2 - redundant data tier
resource "aws_subnet" "data_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.22.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name = "${var.project_name}-data-2"
    Tier = "data"
  }
}

Why separate CIDR ranges for each tier?

The public subnets use 10.0.1.0/24 and 10.0.2.0/24. The app subnets use 10.0.11.0/24 and 10.0.12.0/24. The data subnets use 10.0.21.0/24 and 10.0.22.0/24.

This numbering convention (1x for public, 11x for app, 21x for data) makes it easy to identify which tier a resource belongs to just by looking at its IP address.

  • Save vpc.tf.

Getting a CIDR overlap error?

Double check that each subnet has a unique CIDR block. If two subnets share the same range, Terraform will reject the configuration.

Help me debug a CIDR overlap issue in my Terraform subnets.

Create the route table and preview the plan

A route table tells AWS where to send traffic leaving a subnet. Public subnets need a route to the internet through the Internet Gateway. Private subnets have no such route, which keeps them isolated by default.

  • In vpc.tf, add the route table and its subnet associations below your data subnets:
# Route table that sends all non-local traffic to the Internet Gateway
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"
  }
}

# Associate the public route table with public subnet 1
resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

# Associate the public route table with public subnet 2
resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

The route 0.0.0.0/0 is a catch-all that matches any destination not within the VPC. By pointing it at the Internet Gateway, any traffic from the public subnets heading to the internet gets routed through the gateway.

The route table associations explicitly link this route table to both public subnets. Without these associations, the subnets would use the VPC's default route table, which has no internet route.

  • Save vpc.tf.
  • Preview what Terraform will create by running this command in your terminal:
terraform plan

You should see a summary line that says Plan: 11 to add, 0 to change, 0 to destroy. The 11 resources are: 1 VPC, 1 Internet Gateway, 6 subnets, 1 route table, and 2 route table associations.

Seeing errors in the plan output?

If Terraform reports a syntax error, check for missing closing braces or mismatched quotes in vpc.tf. Every opening { needs a matching }.

If you see a "reference to undeclared resource" error, make sure your resource names match exactly. For example, aws_vpc.main.id must reference a resource named "aws_vpc" "main".

Help me fix a terraform plan error in my VPC configuration.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that the plan shows 11 resources to add.

ⓧ I'd like to double check the full code

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

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

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

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

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

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

resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

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

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

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

resource "aws_subnet" "app_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.project_name}-app-1"
    Tier = "application"
  }
}

resource "aws_subnet" "app_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.12.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name = "${var.project_name}-app-2"
    Tier = "application"
  }
}

resource "aws_subnet" "data_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.21.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "${var.project_name}-data-1"
    Tier = "data"
  }
}

resource "aws_subnet" "data_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.22.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name = "${var.project_name}-data-2"
    Tier = "data"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

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

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

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

Your network foundation is solid. You have a VPC with six subnets split across two Availability Zones, and public routing configured through the Internet Gateway. Next up, you'll lock down communication between tiers using security groups.

Define Security Groups with Tier Isolation

Your VPC has the network foundation: subnets split across tiers and Availability Zones. But without access controls, any resource in any subnet could talk to any other resource.

In this step, you'll create three security groups that enforce strict communication rules between tiers. Each group only allows traffic from the tier directly above it, creating a chain of trust from the internet down to your data layer.

In this step, get ready to:
  • Create the ALB security group that accepts HTTP traffic from the internet.
  • Create application and data tier security groups with chained access rules.
  • Run terraform plan to verify the security group configuration.
Create the ALB security group

The Application Load Balancer is the front door of your architecture. Its security group needs to allow inbound HTTP traffic from anywhere on the internet, so users can reach your application.

  • Open security_groups.tf in VS Code and add the ALB security group by pasting the following code:
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 internet"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

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

What does this code do?

  • The ingress block allows inbound TCP traffic on port 80 (HTTP) from any IP address (0.0.0.0/0).
  • The egress block with protocol "-1" means all outbound traffic is allowed on all ports.
  • vpc_id = aws_vpc.main.id ties this security group to the VPC you created in the previous step.
  • Save security_groups.tf.
  • You won't see a change yet. The next substep adds two more security groups that chain to this one.
Create the application and data tier security groups

The real power of security groups comes from chaining. Instead of allowing traffic from broad IP ranges, each tier's security group references the security group of the tier above it.

This means the application tier only accepts traffic that already passed through the ALB. The data tier only accepts traffic from verified application instances.

  • In security_groups.tf, add the application tier security group below the ALB security group you just created:
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 only"
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

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

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

What does this code do?

  • The security_groups = [aws_security_group.alb.id] line is the key difference. Instead of opening port 80 to the whole internet, this security group only accepts traffic from resources attached to the ALB security group.
  • Terraform resolves aws_security_group.alb.id automatically at deploy time, so you never need to hard-code IDs.
  • You won't see a change yet. Add the data tier security group directly below the app security group:
resource "aws_security_group" "data" {
  name        = "${var.project_name}-data-sg"
  description = "Security group for the data tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "MySQL from app tier only"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

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

What does this code do?

  • Port 3306 is the standard MySQL database port. Only the app tier can reach the data tier on this port.
  • There is no egress block. Without one, Terraform does not add a default outbound rule. This means the data tier has no outbound internet access, which is the desired security posture for a database.

Why chain security groups instead of using IP ranges?

IP addresses change as instances scale up and down. Security group references are dynamic. When a new application instance launches and gets the app security group attached, it automatically gains access to the data tier without any rule changes.

This is the canonical least-privilege pattern that Solutions Architect interviews test: ALB accepts internet traffic, app accepts only ALB traffic, data accepts only app traffic.

  • Save security_groups.tf.

Seeing a syntax error in VS Code?

  • Check that each resource block has matching opening and closing braces. The nested ingress and egress blocks each need their own pair of braces.
  • Make sure security_groups in the ingress blocks uses square brackets around the reference (e.g. [aws_security_group.alb.id]).
  • Confirm the data security group references aws_security_group.app.id (not aws_security_group.alb.id).

Help me debug my security_groups.tf file

Run terraform plan to verify the security groups

Terraform's plan command previews what will be created without actually deploying anything. It also validates that all your resource references resolve correctly.

  • Preview the full architecture so far by running this command in your terminal:
terraform plan

What should I see?

The output should show Plan: 14 to add, 0 to change, 0 to destroy at the bottom. That is 11 network resources from the previous step plus 3 new security groups.

Look at the app security group in the output. You'll see security_groups listed as (known after apply) because the ALB security group's ID doesn't exist yet. Terraform understands this dependency and will create them in the correct order.

  • Confirm you see 14 resources to add with no errors in the output.

Seeing fewer than 14 resources or an error?

  • If you see a reference error mentioning aws_security_group.alb, make sure the ALB security group resource name is exactly "alb" (not "alb_sg" or another variation).
  • If you see fewer resources, double check that vpc.tf still has all 11 resources from the previous step.
  • If the error mentions aws_vpc.main, make sure your vpc.tf is saved.

Help me debug my terraform plan output

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete security_groups.tf file:

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 internet"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

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

resource "aws_security_group" "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 only"
    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" "data" {
  name        = "${var.project_name}-data-sg"
  description = "Security group for the data tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "MySQL from app tier only"
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

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

Your network is locked down with proper tier isolation. Next up, you'll deploy an Application Load Balancer into those public subnets and wire it to a target group for your future application instances.

Deploy the Application Load Balancer

Your security groups are locked down, with each tier only accepting traffic from the tier above it. The network foundation is solid.

Now you need a front door for your architecture. An Application Load Balancer will sit in your public subnets, accept HTTP traffic from the internet, and route it to your application tier.

In this step, get ready to:
  • Define the ALB resource in the public subnets.
  • Create a target group and HTTP listener.
  • Add Terraform outputs and deploy the full architecture.
Create the ALB resource

The ALB is the internet-facing entry point for your architecture. It lives in both public subnets so it can receive traffic in either Availability Zone.

  • Open alb.tf in VS Code and paste the following code:
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_1.id, aws_subnet.public_2.id]

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

What does this code do?

  • internal = false makes the ALB internet-facing, so it gets a public DNS name.
  • load_balancer_type = "application" creates a Layer 7 load balancer that understands HTTP requests.
  • security_groups attaches the ALB security group you created in the previous step, which allows inbound HTTP from the internet.
  • subnets places the ALB across both public subnets for high availability.
  • Save alb.tf.

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

Create the target group and listener

A target group defines where the ALB sends traffic, and the health check tells the ALB how to verify targets are healthy. A listener defines the port and protocol the ALB listens on.

  • Add the target group and listener below the ALB resource in alb.tf:
resource "aws_lb_target_group" "app" {
  name     = "${var.project_name}-app-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path                = "/"
    protocol            = "HTTP"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
  }

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

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

What does this code do?

The target group and listener work together to complete the ALB's routing logic.

  • The target group defines a pool of targets (like EC2 instances) that receive traffic on port 80. The health check pings / every 30 seconds to confirm targets are responsive.
  • The listener opens port 80 on the ALB and forwards all incoming HTTP requests to the target group.
  • Right now the target group has no registered targets. Traffic will arrive at the ALB but get a 503 response because there is nothing to forward to.
  • Save alb.tf.

You won't see a change yet. The next substep adds outputs and deploys everything with terraform apply.

Seeing a syntax error?

  • Make sure the target group resource block is closed with a matching brace before the listener resource begins.
  • Check that the listener's default_action block is nested inside the listener resource, not outside it.

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

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_1.id, aws_subnet.public_2.id]

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

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

  health_check {
    path                = "/"
    protocol            = "HTTP"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
  }

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

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}
Add outputs and deploy the architecture

Terraform outputs let you extract important values (like the ALB's DNS name) after deployment. They also serve as documentation for anyone reading your code.

  • Open outputs.tf and paste the following code:
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "alb_dns_name" {
  description = "The DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

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

output "app_subnet_ids" {
  description = "IDs of the application subnets"
  value       = [aws_subnet.app_1.id, aws_subnet.app_2.id]
}

output "data_subnet_ids" {
  description = "IDs of the data subnets"
  value       = [aws_subnet.data_1.id, aws_subnet.data_2.id]
}

What does this code do?

Each output block exports a resource attribute so Terraform prints it after apply. The alb_dns_name output is the one you'll use to visit your load balancer in a browser.

  • Save outputs.tf.

Cost warning

The ALB costs $0.0225 per hour in us-east-1. If your AWS account is within its 12-month free tier window, you get 750 ALB-hours per month at no charge.

If you are outside the free tier window, complete the project and run terraform destroy within a few hours to keep costs under $0.50.

  • Deploy the entire architecture by running this command:
terraform apply

What will Terraform ask?

Terraform will show a plan of all resources it intends to create (around 17 total), then prompt you to confirm. Type yes and press Enter to proceed.

  • Type yes when prompted and press Enter.

Terraform takes 2-3 minutes to provision all the resources. When it finishes, you should see the outputs printed in your terminal, including the alb_dns_name value.

  • Copy the alb_dns_name value from your terminal output and paste it into your browser's address bar.

You should see an HTTP 503 error page. This is expected. The ALB is running but the target group has no registered targets yet, so there is nothing to forward traffic to.

Why 503 and not 404?

A 503 means the service is unavailable. The ALB received your request but could not find a healthy backend to send it to. A 404 would mean the ALB forwarded your request but the backend did not recognize the path.

  • Confirm the deployment in the AWS Console by navigating to the EC2 service, then selecting Load Balancers in the left sidebar.
  • Verify the multi-tier-alb load balancer shows as active and is placed in your two public subnets across different Availability Zones.

Terraform apply failed?

  • If you see a naming error, check that the ALB name in your code only contains alphanumeric characters and hyphens. No underscores are allowed in ALB names.
  • If you see a reference error like "resource not found", make sure your security_groups.tf and vpc.tf are saved with the code from the previous steps.
  • If you see an authorization error, confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity.

Help me debug my terraform apply failure

Your full 3-tier architecture is now live on AWS. Next up, you'll validate the setup in the AWS Console and then safely tear it all down.

Validate and Destroy

Your entire 3-tier architecture is now live in AWS. Before tearing it down, it is worth verifying that every resource landed in the right place.

In this step you will walk through the AWS Console to confirm the architecture matches your Terraform code, then destroy all resources to stop charges. Finally, you will commit your configuration files to a Git repository so you can reference this project on LinkedIn.

In this step, get ready to:
  • Verify the architecture in the AWS Console.
  • Destroy all resources with terraform destroy.
  • Commit your Terraform code to a Git repository.
Verify the architecture in the AWS Console

Checking your resources in the Console confirms that Terraform created everything exactly as your code described.

  • Open the AWS Console in your browser and make sure you are in the us-east-1 region (shown in the top-right corner).
  • Search for VPC in the top search bar and select VPC.
  • Click Your VPCs in the left sidebar.
  • Confirm you see a VPC named multi-tier-vpc with CIDR 10.0.0.0/16.
  • Click Subnets in the left sidebar.
  • Confirm you see 6 subnets. The public subnets should be in two different Availability Zones.
  • Click Security Groups in the left sidebar.
  • Find multi-tier-alb-sg and check that its inbound rules allow HTTP (port 80) from 0.0.0.0/0.
  • Find multi-tier-app-sg and confirm its inbound rule references the ALB security group ID (not a CIDR block).
  • Find multi-tier-data-sg and confirm its inbound rule references the app security group ID on port 3306.

What is tier-chaining?

Each security group only allows traffic from the tier directly above it. The ALB accepts public traffic, then the app tier only accepts traffic from the ALB, and the data tier only accepts traffic from the app tier.

This is the canonical least-privilege pattern that Solutions Architect interviews test.

  • Search for EC2 in the top search bar and select EC2.
  • Click Load Balancers in the left sidebar.
  • Confirm you see multi-tier-alb with scheme internet-facing and its Availability Zones showing both public subnets.

Why does the ALB return a 503?

The ALB has no registered targets yet. A 503 Service Temporarily Unavailable response is expected because the target group is empty. The Secret Mission adds EC2 instances to fix this.

Destroy all resources with terraform destroy

The ALB charges $0.0225 per hour in us-east-1. Running terraform destroy removes every resource Terraform manages in one command.

  • Return to your terminal in the multi-tier-terraform directory.
  • Tear down all AWS resources by running this command:
terraform destroy
  • Terraform will show a plan listing all resources marked for destruction. When prompted, type yes and press Enter to confirm.

What does terraform destroy do?

It reads the current Terraform state file, generates a destroy plan for every managed resource, then deletes them in dependency order. Subnets are removed before the VPC, the listener before the ALB, and so on.

Your .tf configuration files are untouched. Only the cloud resources disappear.

The destroy process takes 1-2 minutes. You should see a final message that reads:

Destroy complete! Resources: 17 destroyed.

The exact resource count may vary slightly, but you should see 0 errors and the confirmation that all resources are gone.

Seeing a dependency error during destroy?

Occasionally Terraform tries to delete a resource before its dependents are fully removed. Run terraform destroy again. Terraform will pick up where it left off and finish the remaining resources.

If the error persists, check the AWS Console to see which resource is stuck and delete it manually, then run terraform destroy one more time.

Still stuck? Help me troubleshoot a terraform destroy dependency error.

Commit your Terraform code to a Git repository

Your configuration files are a portfolio-ready artifact. Committing them to Git means you can push to GitHub and link the repository on your LinkedIn profile.

  • Create a .gitignore file to exclude Terraform's local state and provider binaries by running:
echo ".terraform/
terraform.tfstate
terraform.tfstate.backup
.terraform.lock.hcl" > .gitignore

Why ignore these files?

The .terraform/ directory contains downloaded provider binaries (hundreds of MB). The state files hold sensitive resource IDs and metadata. Neither should be committed to a public repository.

  • Initialize a Git repository and commit your code by running these commands:
git init
git add .
git commit -m "Add multi-tier AWS architecture Terraform configuration"

You should see a commit summary listing your 7 files (6 .tf files plus .gitignore).

Push to GitHub for your portfolio

Create a new repository on GitHub, then add the remote and push. You can reference this repo on your LinkedIn profile under Projects to demonstrate your Infrastructure as Code skills.

Git commit says nothing to commit?

Make sure you are inside the multi-tier-terraform folder when you run git init. If you accidentally initialized in the parent directory, remove the .git folder there and try again from inside multi-tier-terraform.

Need help? Why is my git commit not finding any files?

Secret mission

Add an Auto Scaling Group with a Web Server

Your architecture has a load balancer, but nothing behind it to serve traffic. In this secret mission, you'll add an EC2 Auto Scaling Group that launches instances running a simple web server, proving your full 3-tier pipeline works end to end.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ALB incurs charges of $0.0225 per hour while it exists, so deleting is recommended once you have finished experimenting.

Cost warning

The Application Load Balancer costs $0.0225 per ALB-hour plus $0.008 per LCU-hour in us-east-1. If your AWS account is within its 12-month free tier window, you get 750 ALB-hours per month at no charge.

EC2 instances launched by the Secret Mission use t2.micro, which is free-tier eligible. If you are outside the free tier window, run terraform destroy as soon as possible to stop all charges.

Resources you used:

  • AWS infrastructure deployed via Terraform (VPC, Internet Gateway, 6 subnets, route table, 2 route table associations, 3 security groups, ALB, target group, listener).
  • Secret Mission resources (launch template, Auto Scaling Group, 2 EC2 instances). Only applicable if you completed the Secret Mission.
  • Local project folder multi-tier-terraform/ on your Desktop.
  • Terraform CLI installed via Homebrew.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the architecture.

  • Your ALB will continue to incur charges ($0.0225/hour, roughly $16/month) unless you are within the 12-month free tier window.
  • EC2 instances (if Secret Mission completed) remain running. t2.micro instances are free-tier eligible.
  • Terraform CLI and your local multi-tier-terraform/ folder remain on your machine.

Pause - I'll come back to this later

AWS infrastructure resources like VPCs, subnets, and ALBs do not have a pause state. You are charged for the ALB as long as it exists.

  • The recommended approach is to run terraform destroy to remove all AWS resources and stop charges.
  • Keep your local multi-tier-terraform/ folder intact so you can redeploy later with terraform apply.
  • When you are ready to resume, navigate back into the folder and run terraform apply to recreate everything.

Delete - I don't want to use this again

Remove all project resources and stop all charges. This is the recommended option if you have finished the project.

  • Destroy all AWS resources by running this command inside your multi-tier-terraform/ folder:
cd ~/Desktop/multi-tier-terraform
terraform destroy
  • Type yes when Terraform asks for confirmation. Wait for all resources to be destroyed.
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf multi-tier-terraform

On Windows, use:

cd ~\Desktop
rmdir /s /q multi-tier-terraform
  • Uninstall Terraform from your machine by running:
brew uninstall hashicorp/tap/terraform

That's a Wrap!

That's a Wrap!

Nice work! You just designed and deployed a production-grade, multi-tier AWS architecture entirely through Terraform code.

You've learned how to:

  • Built a 3-tier VPC architecture with public, private app, and private data subnets spanning 2 Availability Zones.
  • Deployed an internet-facing Application Load Balancer with health checks and a target group using Terraform.
  • Implemented least-privilege security groups with tier-to-tier chaining (ALB to App to Data) enforcing strict network segmentation.
  • Secret Mission: Added an Auto Scaling Group with EC2 instances serving live traffic through the ALB.

Ready to quiz yourself?