Deploy a Multi-Tier AWS Architecture
Define a full VPC, ALB, and EC2 architecture in Terraform and deploy it with one command.
Introduction
30 Second Summary
Clicking through the AWS console to build infrastructure is like assembling furniture without saving the instructions. Every time you need to rebuild, you start from scratch.
In this project, you will use Terraform to define a multi-tier AWS architecture entirely in code. You will deploy and destroy the whole thing with single commands.
What You'll Build
A production-style multi-tier architecture on AWS with a VPC, public and private subnets, an Application Load Balancer, and EC2 instances, all defined in Terraform configuration files.
By the end of this project, you'll have:
- 6 Terraform configuration files defining ~20 AWS resources across networking, security, load balancing, and compute.
- A working ALB endpoint serving HTTP traffic from EC2 instances running in private subnets.
- A fully repeatable infrastructure setup that you can deploy with terraform apply and tear down with terraform destroy.
- Secret Mission: Add an Auto Scaling Group to replace static EC2 instances, making your architecture dynamically responsive to load.
Are there any prerequisites?
You need an AWS account with admin access and the AWS CLI installed and configured with credentials. You will install Terraform and VS Code in Step 1.
Set Up Terraform and Your Project
You have a clear picture of the architecture you want to build: a VPC with public and private subnets, an ALB, EC2 instances, and all the networking in between. Now you need a tool that can translate that architecture into real AWS resources from a single command.
That tool is Terraform. In this step, you'll install Terraform, set up VS Code as your code editor, and create the configuration files that tell Terraform how to connect to your AWS account.
In this step, get ready to:
- Install Terraform and verify it works.
- Create a project folder and open it in VS Code.
- Write provider and variable configuration files.
Install Terraform on Windows
Terraform is an infrastructure as code tool that lets you define cloud resources in configuration files. Instead of clicking through the AWS console, you write code that Terraform reads, plans, and executes.
Why Terraform instead of CloudFormation?
CloudFormation is AWS-specific and manages state implicitly through stacks. Terraform works across multiple cloud providers and uses an explicit state file you can inspect and manage directly.
Terraform also has a larger job market presence and teaches a different mental model for infrastructure management. Once you know both tools, you can pick the right one for any project.
- Open Git Bash by pressing the Windows key, typing Git Bash, and pressing Enter.
- Install Terraform using Chocolatey by running this command:
choco install terraform -y
What does this command do?
Chocolatey is a package manager for Windows that handles downloading, installing, and adding programs to your PATH. The -y flag automatically confirms the installation without prompting you.
Seeing 'choco is not recognized'?
You need Chocolatey installed first. Visit chocolatey.org/install and follow the installation instructions. You'll need to run the install command in an Administrator PowerShell window, then restart Git Bash.
Help me install Chocolatey on Windows
- Verify Terraform installed correctly by running:
terraform -v
✔️ I see a version number
You should see something like Terraform v1.15.1 (your exact version may differ). Terraform is ready to use.
ⓧ Command not found
If Git Bash doesn't recognize the terraform command, close and reopen Git Bash. Chocolatey adds Terraform to your PATH, but the change only takes effect in new terminal sessions.
If it still doesn't work after reopening, run choco install terraform -y again in an Administrator PowerShell window.
Help me troubleshoot my Terraform installation on Windows
Install VS Code and create your project folder
You'll write your Terraform files in VS Code. It has built-in support for HCL (Terraform's configuration language) and makes managing multiple files straightforward.
- Check if VS Code is already installed by pressing the Windows key, typing Visual Studio Code, and checking if it appears.
✔️ I see VS Code in my search results
VS Code is already installed. Press Enter to open it.
ⓧ It's not installed yet
- Download VS Code from code.visualstudio.com.
- Run the installer and accept the default settings.
- Open VS Code once the installation completes.
Now you'll create a dedicated folder for your Terraform project files.
- In Git Bash, move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir terraform-multi-tier
cd terraform-multi-tier
- Confirm the folder was created by running:
ls
The folder is empty for now, which is expected. You'll add files in the next substep.
- Open this folder in VS Code by running:
code .
What does code . do?
The code command launches VS Code from the terminal. The . tells it to open the current directory as a project folder.
VS Code didn't open?
If you get 'command not found', VS Code's PATH entry may not be set. Open VS Code manually using the Windows search bar, then go to View > Command Palette and type Shell Command: Install 'code' command in PATH. Restart Git Bash and try again.
Help me open my project folder in VS Code
Create the Terraform provider configuration
Terraform needs to know which cloud provider to talk to and how to authenticate. You define this in a provider configuration file that tells Terraform to use the AWS provider and which region to deploy resources in.
- In VS Code's Explorer sidebar, click the New File icon.
- Name the file providers.tf and press Enter.
- Paste the following code into providers.tf:
# Declare the minimum Terraform version and required providers
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# Configure the AWS provider with region and default tags
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "terraform-multi-tier"
ManagedBy = "Terraform"
Environment = "dev"
}
}
}
What does this code do?
The terraform {} block sets two constraints. required_version ensures anyone running this code has Terraform 1.5.0 or higher. required_providers tells Terraform to download the AWS provider from HashiCorp's registry, pinned to any 6.x version.
The provider "aws" {} block configures how Terraform interacts with AWS. The region reads from a variable you'll define next. The default_tags block automatically applies these tags to every resource Terraform creates, so you can always identify what belongs to this project.
- Save providers.tf by pressing Ctrl+S.
Now you need to define the variables that providers.tf references. Terraform variables let you reuse values across multiple files and change settings without editing the main configuration.
- Create a new file called variables.tf in the same folder.
- Paste the following code into variables.tf:
# Input variable for the AWS region
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
# Input variable for the VPC CIDR range
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
# Input variable for the EC2 instance size
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
What does this code do?
Each variable block defines an input that other files can reference using var.<name> syntax. The default value is used when you don't override it at runtime.
You defined three variables: the AWS region (where resources are created), the VPC CIDR block (the IP address range for your network), and the instance type (the size of your EC2 machines). The t2.micro default keeps you within the AWS free tier.
- Save variables.tf by pressing Ctrl+S.
Check the Explorer sidebar in VS Code. You should see both providers.tf and variables.tf listed in your terraform-multi-tier folder.
Only seeing one file?
Make sure both files end with the .tf extension. If you accidentally named a file providers without the extension, right-click it in the Explorer sidebar and select Rename to add .tf.
Help me check my Terraform project files are set up correctly
Why split into multiple .tf files?
Terraform automatically loads every .tf file in a directory. File names are a convention for organization, not a technical requirement. Separating providers, variables, networking, and compute into individual files keeps each file focused and makes large projects easier to navigate.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete providers.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "terraform-multi-tier"
ManagedBy = "Terraform"
Environment = "dev"
}
}
}
Here is the complete variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
Your development environment is ready and Terraform knows how to connect to AWS. Next up, you'll define the entire network layer: a VPC, subnets, gateways, and route tables.
Build the Network Layer
Your Terraform project is configured and ready to talk to AWS. Now it's time to define the actual infrastructure you want to create.
Every multi-tier architecture starts with the network. You need a VPC (your own isolated network in AWS), subnets split across multiple Availability Zones, and gateways that control how traffic flows in and out. In this step, you'll define all of that in a single Terraform file.
In this step, get ready to:
- Define a VPC with DNS support and two public subnets across Availability Zones.
- Add private subnets, an Internet Gateway, and a NAT Gateway with route tables.
- Associate each subnet with the correct route table for proper traffic flow.
Define the VPC with DNS support and two public subnets across Availability Zones
The VPC is the foundation of your network. It defines the IP address range everything lives within. You'll also need a way to dynamically discover which Availability Zones are available in your region.
- In VS Code, create a new file called vpc.tf inside your terraform-multi-tier folder.
- Add the data source and VPC resource by pasting this code:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "terraform-multi-tier-vpc"
}
}
What does this code do?
The data source queries AWS for all available Availability Zones in your region. Terraform uses this list to spread your subnets across different zones for high availability.
The VPC resource creates your isolated network with the 10.0.0.0/16 CIDR block (defined in variables.tf). Enabling DNS hostnames and DNS support means your resources can resolve each other by hostname inside the VPC.
You won't see a change yet. The next chunk adds subnets that use this VPC.
- Below the VPC resource in vpc.tf, add the two public subnets:
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 = "public-subnet-1"
}
}
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 = "public-subnet-2"
}
}
What does this code do?
Each subnet carves out a /24 block (256 IP addresses) from the VPC's /16 range. The first public subnet gets 10.0.1.0/24 and the second gets 10.0.2.0/24.
They're placed in different Availability Zones using names[0] and names[1] from the data source. The map_public_ip_on_launch = true setting means any resource placed here automatically gets a public IP address.
- Save vpc.tf.
- Check that VS Code does not show any red underline errors in your code. If you see squiggly lines, double check your curly braces and quote marks.
Seeing syntax highlighting errors?
Make sure every opening brace has a matching closing brace. HCL uses curly braces for blocks and the indentation is cosmetic, so a missing brace is the most common syntax issue.
Help me fix a syntax error in my vpc.tf file
Add two private subnets, an Internet Gateway, and a NAT Gateway with proper route tables
Public subnets host resources that face the internet directly (like your load balancer). Private subnets host resources that should never be directly reachable from the internet (like your application servers).
- Below the public subnets in vpc.tf, add the two private subnets:
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "private-subnet-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
tags = {
Name = "private-subnet-2"
}
}
What does this code do?
These private subnets use 10.0.3.0/24 and 10.0.4.0/24. Notice they don't have map_public_ip_on_launch. Resources in these subnets won't get public IPs, keeping them hidden from the internet.
You won't see a change yet. The next chunk adds the gateways that connect these subnets to the outside world.
Private subnets still need outbound internet access (to download software updates, for example). An Internet Gateway handles traffic for public subnets, while a NAT Gateway lets private subnets reach the internet without being reachable from it.
- Below the private subnets in vpc.tf, add the Internet Gateway, Elastic IP, and NAT Gateway:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "terraform-multi-tier-igw"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "nat-gateway-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "terraform-multi-tier-nat"
}
depends_on = [aws_internet_gateway.main]
}
What does this code do?
- The Internet Gateway attaches to your VPC and provides a direct route between your public subnets and the internet.
- The Elastic IP (aws_eip) reserves a static public IP address for the NAT Gateway. The domain = "vpc" setting tells AWS this IP belongs to your VPC.
- The NAT Gateway sits in a public subnet (public_1) and uses the Elastic IP. Private subnet resources send outbound traffic through it.
- The depends_on line tells Terraform to create the Internet Gateway first. Without it, the NAT Gateway might fail to provision.
You won't see a change yet. The next chunk defines route tables that tell subnets which gateway to use.
Gateways exist, but subnets don't know about them yet. Route tables act as the traffic rulebook. Each subnet will get a route table that says "for internet-bound traffic, use this gateway."
- Below the NAT Gateway in vpc.tf, add the public and private route tables:
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "public-route-table"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "private-route-table"
}
}
What does this code do?
The 0.0.0.0/0 CIDR block means "all internet-bound traffic." The public route table sends that traffic to the Internet Gateway. The private route table sends it to the NAT Gateway instead.
This is the core difference between public and private subnets. Public subnets route directly to the internet. Private subnets route through the NAT, which hides them from inbound connections.
- Save vpc.tf.
Associate subnets with their respective route tables
Route tables exist but aren't connected to any subnets yet. Without explicit associations, subnets fall back to the VPC's default (implicit) route table, which has no internet route.
- At the bottom of vpc.tf, add the four route table associations:
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_1" {
subnet_id = aws_subnet.private_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_2" {
subnet_id = aws_subnet.private_2.id
route_table_id = aws_route_table.private.id
}
What does this code do?
Each association links one subnet to one route table. Both public subnets point to the public route table (Internet Gateway route). Both private subnets point to the private route table (NAT Gateway route).
- Save vpc.tf.
- Verify your Terraform formatting is correct by running this command in your terminal:
terraform fmt
- If Terraform reformatted any files, you'll see the filename printed (e.g. vpc.tf). If nothing prints, your formatting was already correct.
Seeing an error when running terraform fmt?
If you see a syntax error, check that every resource block has matching opening and closing braces. Also confirm every string value is wrapped in double quotes.
Make sure you're running the command from inside the terraform-multi-tier folder where your .tf files live.
Help me fix a terraform fmt error in my vpc.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
Here's the complete vpc.tf file. Compare it to yours and fix any differences.
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "terraform-multi-tier-vpc"
}
}
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 = "public-subnet-1"
}
}
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 = "public-subnet-2"
}
}
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "private-subnet-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
tags = {
Name = "private-subnet-2"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "terraform-multi-tier-igw"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "nat-gateway-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "terraform-multi-tier-nat"
}
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "public-route-table"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "private-route-table"
}
}
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_1" {
subnet_id = aws_subnet.private_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_2" {
subnet_id = aws_subnet.private_2.id
route_table_id = aws_route_table.private.id
}
Why write each resource individually instead of using a module?
A popular Terraform module called terraform-aws-modules/vpc/aws can create all of this in about 10 lines. Modules abstract away the details, which is great for production speed.
Writing each resource yourself teaches you how VPC networking actually works. You see the dependencies between gateways, route tables, and subnets. Once you understand the primitives, you can graduate to modules knowing exactly what they do under the hood.
Your entire network layer is now defined in code. Next up, you'll add security groups and a load balancer to control who can talk to what.
Configure Security and Load Balancing
Your network layer is complete with public and private subnets, an Internet Gateway, and a NAT Gateway. Now you need to control who can talk to what inside that network.
In a multi-tier architecture, the load balancer is the only resource that should accept traffic from the internet. Your EC2 instances should only accept traffic from the load balancer. Security groups enforce these boundaries, acting as virtual firewalls for each tier.
In this step, get ready to:
- Create an ALB security group allowing HTTP traffic from the internet.
- Create an EC2 security group allowing traffic only from the ALB.
- Define the Application Load Balancer, target group, and HTTP listener.
Create a security group for the ALB
The Application Load Balancer is the front door to your architecture. It needs a security group that allows inbound HTTP traffic from anywhere on the internet, so users can reach your application.
- Create a new file called security.tf in your terraform-multi-tier folder.
- Add the ALB security group definition by pasting this code:
# Security group for the Application Load Balancer
resource "aws_security_group" "alb" {
name = "alb-security-group"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "alb-sg"
}
}
# Allow inbound HTTP traffic from anywhere on the internet
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
# Allow all outbound traffic so the ALB can forward requests to targets
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
This defines three resources that work together as the ALB's firewall:
- aws_security_group "alb" creates the security group itself, attached to your VPC.
- aws_vpc_security_group_ingress_rule "alb_http" opens port 80 (HTTP) to all IP addresses (0.0.0.0/0).
- aws_vpc_security_group_egress_rule "alb_all" allows all outbound traffic so the ALB can forward requests to your EC2 instances.
Why standalone rule resources instead of inline blocks?
Terraform's AWS provider (version 5.x and above) recommends using separate aws_vpc_security_group_ingress_rule resources instead of inline ingress blocks inside the security group. Standalone rules prevent conflicts when multiple configurations modify the same security group.
- Save security.tf.
- You won't see a change yet. The next security group references this one directly.
Create a security group for EC2 instances
Your EC2 instances live in private subnets, so they should never receive traffic directly from the internet. The only traffic they accept should come through the ALB. This is the principle of least-privilege access applied to networking.
- In security.tf, add the EC2 security group below the ALB egress rule by pasting this code:
# Security group for EC2 instances in private subnets
resource "aws_security_group" "ec2" {
name = "ec2-security-group"
description = "Security group for EC2 instances"
vpc_id = aws_vpc.main.id
tags = {
Name = "ec2-sg"
}
}
# Only allow inbound traffic from the ALB security group on port 80
resource "aws_vpc_security_group_ingress_rule" "ec2_from_alb" {
security_group_id = aws_security_group.ec2.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
# Allow all outbound traffic for package installs and updates
resource "aws_vpc_security_group_egress_rule" "ec2_all" {
security_group_id = aws_security_group.ec2.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
The key difference from the ALB security group is the ingress rule. Instead of cidr_ipv4 = "0.0.0.0/0" (anyone), the EC2 ingress rule uses referenced_security_group_id to allow traffic only from resources attached to the ALB security group.
This means the only way to reach your EC2 instances on port 80 is through the load balancer. Direct internet access is blocked.
- Save security.tf.
- You won't see a visible change yet. You will verify both security groups work when you run terraform plan in Step 5.
Seeing red squiggles in VS Code?
VS Code may show syntax warnings if you do not have a Terraform extension installed. This does not affect your code. You can install the **HashiCorp Terraform** extension from the Extensions panel for syntax highlighting, but it is optional.
If you see a typo in a resource name (like aws_security_group.alb not matching between files), double check the exact spelling against the code above.
Still stuck? Help me fix a syntax issue in my security.tf file.
✔️ Awesome, I've got everything!
Great. Double check you have saved security.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete security.tf file for comparison:
resource "aws_security_group" "alb" {
name = "alb-security-group"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
tags = {
Name = "alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "ec2" {
name = "ec2-security-group"
description = "Security group for EC2 instances"
vpc_id = aws_vpc.main.id
tags = {
Name = "ec2-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ec2_from_alb" {
security_group_id = aws_security_group.ec2.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "ec2_all" {
security_group_id = aws_security_group.ec2.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Define the Application Load Balancer, target group, and listener
With security groups in place, you can now define the load balancer itself. An ALB distributes incoming requests across multiple targets. It needs three components: the load balancer resource, a target group that defines where to send traffic, and a listener that connects the two.
- Create a new file called alb.tf in your terraform-multi-tier folder.
- Add the full load balancing configuration by pasting this code:
# Internet-facing Application Load Balancer in public subnets
resource "aws_lb" "main" {
name = "terraform-multi-tier-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 = "terraform-multi-tier-alb"
}
}
# Target group defines where the ALB sends traffic and how it checks health
resource "aws_lb_target_group" "main" {
name = "terraform-multi-tier-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 10
}
tags = {
Name = "terraform-multi-tier-tg"
}
}
# HTTP listener on port 80 forwards all traffic to the target group
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
What does this code do?
These three resources form the complete load balancing pipeline:
- aws_lb "main" creates an internet-facing ALB in your two public subnets. Setting internal = false means it gets a public DNS name that anyone can reach.
- aws_lb_target_group "main" defines the pool of backend servers. The health_check block tells the ALB how to verify each target is still healthy before sending traffic to it.
- aws_lb_listener "http" listens on port 80 and forwards every request to the target group. This is the glue connecting the ALB to your backend targets.
- Save alb.tf.
- You won't see a visible change yet. You will test the full ALB setup when you run terraform apply in Step 5.
Not sure if you copied everything correctly?
Check that alb.tf has exactly three resource blocks: one aws_lb, one aws_lb_target_group, and one aws_lb_listener. Make sure there are no stray characters or missing closing braces.
Help me verify my alb.tf looks correct.
✔️ Awesome, I've got everything!
Great. Double check you have saved alb.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete alb.tf file for comparison:
resource "aws_lb" "main" {
name = "terraform-multi-tier-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 = "terraform-multi-tier-alb"
}
}
resource "aws_lb_target_group" "main" {
name = "terraform-multi-tier-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 10
}
tags = {
Name = "terraform-multi-tier-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
}
}
Your security boundaries and load balancer are fully defined. Next up, you will create the EC2 instances that serve your application from within the private subnets.
Deploy Compute Resources
Your network is built, your security rules are in place, and your load balancer is ready to distribute traffic. The missing piece is the actual application servers that will receive and respond to requests.
In this step, you'll define two EC2 instances inside the private subnets. Each instance will run Apache and serve a simple web page. You'll also wire them into the ALB target group so the load balancer knows where to send traffic.
In this step, get ready to:
- Define EC2 instances with user data that installs and starts Apache.
- Attach both instances to the ALB target group.
- Create output values to display the ALB DNS name after deployment.
Define EC2 instances in private subnets with user data
Your EC2 instances need an Amazon Machine Image (AMI) to boot from. Rather than hardcoding an AMI ID that changes between regions, you'll use a Terraform data source to dynamically look up the latest Amazon Linux 2023 image.
- Create a new file called ec2.tf in your terraform-multi-tier folder.
- Add the AMI data source and the first EC2 instance by pasting this code:
# Look up the latest Amazon Linux 2023 AMI dynamically
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# First application server in private subnet 1
resource "aws_instance" "app_1" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_1.id
vpc_security_group_ids = [aws_security_group.ec2.id]
# Bootstrap script to install and start Apache
user_data = <<-EOF
#!/bin/bash
dnf install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Instance 1 - $(hostname)</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "app-instance-1"
}
}
What does this code do?
The data "aws_ami" block queries AWS for the most recent Amazon Linux 2023 AMI. This means your code always uses the latest patched image without hardcoding an ID.
The user_data block is a bash script that runs once when the instance first boots. It installs Apache (httpd), starts the web server, and writes a simple HTML page. The $(hostname) tag lets you identify which instance served the response.
Why place instances in private subnets?
Instances in private subnets have no direct internet access. All inbound traffic must flow through the ALB in the public subnet. This is a core security pattern in multi-tier architectures because it prevents anyone from reaching your servers directly.
The NAT Gateway you created in Step 2 allows the instances to reach the internet for outbound traffic (like downloading Apache), without exposing them to inbound connections.
Now add the second instance. It uses the same configuration but deploys into the other private subnet for high availability.
- Add the second EC2 instance below the first one in ec2.tf:
# Second application server in private subnet 2
resource "aws_instance" "app_2" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_2.id
vpc_security_group_ids = [aws_security_group.ec2.id]
# Bootstrap script to install and start Apache
user_data = <<-EOF
#!/bin/bash
dnf install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Instance 2 - $(hostname)</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "app-instance-2"
}
}
What's different about this instance?
The only differences are subnet_id (pointing to private_2 instead of private_1), the HTML message ("Instance 2"), and the Name tag. Spreading instances across Availability Zones means your application stays up even if one zone has issues.
- Save ec2.tf.
Seeing red squiggles in VS Code?
If VS Code shows syntax warnings on the <<-EOF heredoc block, that is normal. VS Code may not recognize HCL heredoc syntax without a Terraform extension. The code is still valid.
help me fix syntax errors in my ec2.tf file
Attach both instances to the ALB target group
The ALB needs to know which instances to send traffic to. Target group attachments register each EC2 instance with the target group you defined in alb.tf.
- Add the target group attachments at the bottom of ec2.tf:
# Register instance 1 with the ALB target group
resource "aws_lb_target_group_attachment" "app_1" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.app_1.id
port = 80
}
# Register instance 2 with the ALB target group
resource "aws_lb_target_group_attachment" "app_2" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.app_2.id
port = 80
}
What does this code do?
Each aws_lb_target_group_attachment resource tells the ALB to route traffic to a specific instance on port 80. Without these attachments, the ALB would have an empty target group and return errors to visitors.
- Save ec2.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved ec2.tf with all four resources (data source, two instances, two attachments).
ⓧ I'd like to double check the full code
Here is the complete ec2.tf file for comparison:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "app_1" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_1.id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Instance 1 - $(hostname)</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "app-instance-1"
}
}
resource "aws_instance" "app_2" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_2.id
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = <<-EOF
#!/bin/bash
dnf install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Instance 2 - $(hostname)</h1>" > /var/www/html/index.html
EOF
tags = {
Name = "app-instance-2"
}
}
resource "aws_lb_target_group_attachment" "app_1" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.app_1.id
port = 80
}
resource "aws_lb_target_group_attachment" "app_2" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.app_2.id
port = 80
}
Create output values to display the ALB DNS name
After Terraform finishes deploying, you need a way to find the ALB's public URL. Terraform outputs print values to your terminal at the end of terraform apply, so you don't have to dig through the AWS console.
- Create a new file called outputs.tf in your terraform-multi-tier folder.
- Add the following output definitions:
# Print the ALB URL after deployment
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
# Print the VPC ID for reference
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
What does this code do?
The alb_dns_name output will print the load balancer's public URL after deployment. You'll paste this URL into a browser to test your infrastructure.
The vpc_id output is useful for debugging and for referencing this VPC from other Terraform configurations in the future.
- Save outputs.tf.
- Confirm your project folder now contains seven .tf files by running:
ls *.tf
You should see alb.tf, ec2.tf, outputs.tf, providers.tf, security.tf, variables.tf, and vpc.tf listed in the output.
Missing a file?
Make sure your terminal is inside the terraform-multi-tier folder. If you see fewer than seven files, check that you created both ec2.tf and outputs.tf with the correct .tf extension (not .tf.txt or similar).
help me find my missing Terraform files
Your entire infrastructure is now defined in code. Next up, you'll run terraform apply to bring all of these resources to life on AWS and test the load balancer in your browser.
Apply, Verify, and Destroy Your Infrastructure
Your six Terraform configuration files are complete. They define an entire multi-tier architecture, from VPC and subnets all the way to load-balanced EC2 instances.
Now it's time to bring that code to life. Terraform's core workflow has three stages: initialize the project, preview what will be created, then apply the changes to your AWS account.
In this step, get ready to:
- Run terraform init and terraform plan to preview all resources.
- Execute terraform apply to provision the infrastructure and verify the ALB endpoint.
- Run terraform destroy to tear down all resources and stop charges.
Initialize and preview your infrastructure
Before Terraform can create anything, it needs to download the AWS provider plugin specified in your providers.tf file. The init command handles this setup.
- In your Git Bash terminal, make sure you are inside the terraform-multi-tier folder.
- Initialize your Terraform project by running this command:
terraform init
You should see Terraform has been successfully initialized! in the output. Terraform downloaded the AWS provider plugin and created a .terraform directory in your project folder.
Seeing a credential error?
If you see an error about AWS credentials, your AWS CLI configuration may have expired or be missing. Run aws sts get-caller-identity to check if your credentials are working.
If that command fails, reconfigure your AWS CLI with aws configure and enter your access key, secret key, and default region.
Help me fix my AWS credentials for Terraform
Now you can preview exactly what Terraform will create before touching your AWS account. This is the planning stage.
- Preview all the resources Terraform will create by running:
terraform plan
Terraform outputs a detailed list of every resource it will create. Scroll through to see your VPC, subnets, security groups, ALB, and EC2 instances all listed.
At the very bottom, you should see a summary line like Plan: 27 to add, 0 to change, 0 to destroy.
What does the plan output mean?
The plan is a dry run. Terraform reads all your .tf files, compares them against the current state (empty, since nothing exists yet), and calculates the difference.
Each line prefixed with a + means a resource will be created. You can review every attribute before committing real resources to your account.
Seeing syntax errors in the plan?
If Terraform reports an error with a specific file and line number, open that file in VS Code and check for missing brackets, incorrect indentation, or typos in resource names.
Common issues include missing closing braces }, incorrect attribute names, or references to resources that are misspelled.
Help me fix my Terraform plan error
Provision the infrastructure and test the endpoint
The plan looks good. Time to provision real AWS resources from your Terraform code.
- Deploy your entire infrastructure by running:
terraform apply
Terraform will ask for confirmation
Terraform shows the full plan again and prompts Do you want to perform these actions? at the bottom. Type yes and press Enter to confirm.
This step takes 3-5 minutes. The NAT Gateway alone needs 1-2 minutes to provision. You will see resources being created one by one in the terminal output.
When it finishes, you should see Apply complete! followed by your outputs. Look for the alb_dns_name value in the terminal output.
Apply failed with an error?
If you see a permissions error, your AWS IAM user may not have admin access. Check that your AWS CLI user has the AdministratorAccess policy attached.
If the error mentions a resource limit or quota, your AWS account may have hit a default limit for VPCs or Elastic IPs in that region.
Help me fix my terraform apply error
Now let's verify that the Application Load Balancer is actually serving traffic from your EC2 instances.
- Copy the alb_dns_name value from the terminal output. It looks something like terraform-multi-tier-alb-123456789.us-east-1.elb.amazonaws.com.
- Paste the DNS name into your browser's address bar and press Enter.
- Wait about 30 seconds for the ALB health checks to pass if you don't see a response immediately.
You should see a page displaying either Hello from Instance 1 or Hello from Instance 2 followed by a hostname. Refresh a few times and you may see the response alternate between instances.
Why does it alternate?
The ALB distributes incoming requests across both EC2 instances in your private subnets. Each instance serves a slightly different page so you can confirm load balancing is working.
Getting a 503 error or blank page?
The ALB health checks need time to mark targets as healthy. Wait 30-60 seconds and refresh again.
If it still doesn't work after a full minute, verify that your security groups and subnets are configured correctly. The EC2 instances need outbound internet access through the NAT Gateway to download Apache during startup.
Help me troubleshoot my ALB returning a 503 error
Destroy all resources to stop charges
Your infrastructure is working. The NAT Gateway costs $0.045 per hour and the ALB costs about $0.0225 per hour, so you want to tear everything down now that you have confirmed it works.
- Remove all AWS resources by running:
terraform destroy
Terraform will ask for confirmation again
Terraform shows all the resources it will delete and prompts for confirmation. Type yes and press Enter. This takes 2-3 minutes as Terraform removes resources in the correct dependency order.
When it finishes, you should see Destroy complete! followed by the number of resources destroyed. All AWS resources are now gone and you will not incur any further charges.
What about your Terraform files?
Your local .tf files are untouched. Only the AWS resources are destroyed. You can run terraform apply again at any time to recreate the exact same infrastructure from scratch.
Destroy is stuck or erroring?
If destroy hangs on a resource, wait up to 5 minutes. Some resources like NAT Gateways take time to fully delete.
If you see a dependency error, run terraform destroy a second time. Terraform will retry removing the remaining resources.
Help me fix my terraform destroy error
Secret mission
Add Auto Scaling
Your architecture works, but it has a fixed number of instances. In production, traffic spikes can overwhelm two servers, and quiet periods waste money on idle compute. Replace those static EC2 instances with an Auto Scaling Group that grows and shrinks based on demand.
Clean Up Your Resources
Clean Up Your Resources
This project creates AWS resources that cost money while running. Decide whether to keep your resources running, destroy them to stop all charges, or confirm they are already gone from Step 5.
Cost warning
Your NAT Gateway costs $0.045/hour and your Application Load Balancer costs approximately $0.0225/hour. Together, that is roughly $0.07/hour or $1.68/day.
If you completed Step 5, you already ran terraform destroy and your resources are gone. If you skipped that step or want to experiment further, destroy your resources when you are done to avoid ongoing charges.
Resources you used:
- VPC with 2 public subnets and 2 private subnets.
- Internet Gateway and NAT Gateway (with Elastic IP).
- Application Load Balancer, target group, and listener.
- 2 EC2 instances (t2.micro) running Apache.
- Security groups and route tables.
- Local project folder with 6 Terraform configuration files.
- Terraform state file (terraform.tfstate) stored locally.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your infrastructure or want to test changes.
- Your resources will continue to cost approximately $0.07/hour (~$1.68/day).
- EC2 instances are free tier eligible if your account is within the 12-month window.
- Remember to run terraform destroy when you are finished experimenting.
Pause - I'll come back to this later
Destroy the AWS infrastructure to stop all charges, but keep your Terraform code so you can recreate everything later with a single command.
- Open Git Bash and navigate to your terraform-multi-tier project folder.
- Run terraform destroy and type yes when prompted.
- Keep your project folder with all .tf files intact on your Desktop.
- When you are ready to resume, run terraform init followed by terraform apply to recreate everything from your code.
The power of Infrastructure as Code
This is one of the biggest advantages of Terraform. Your infrastructure is defined in code, so you can destroy and recreate it at will. No clicking through the console to rebuild.
Delete - I don't want to use this again
Remove all AWS resources and delete the project files from your computer entirely.
- Open Git Bash and navigate to your terraform-multi-tier project folder.
- Run terraform destroy and type yes when prompted. If you already did this in Step 5, you will see a message that there are no resources to destroy.
- Wait for the destroy to complete and confirm all resources are removed.
- Navigate up one level with cd .. and delete the project folder with rm -rf terraform-multi-tier.
Nice Work!
Nice Work!
You just built a production-style multi-tier architecture entirely from code. Every subnet, route table, security group, and load balancer was defined in Terraform configuration files and deployed with a single command.
You've learned how to:
- Write Terraform configuration files defining a complete VPC with public and private subnets across multiple Availability Zones.
- Deploy an Application Load Balancer distributing traffic to EC2 instances running in private subnets with least-privilege security groups.
- Apply and destroy infrastructure with single commands, proving repeatable Infrastructure as Code workflows using terraform apply and terraform destroy.
- Added Auto Scaling to make the architecture dynamically responsive to load.
Ready to quiz yourself?