Deploy AWS Infrastructure with Terraform
Deploy a production-grade VPC, ALB, Auto Scaling, RDS, and S3 with Terraform.
Introduction
30 Second Summary
A growing online store cannot afford downtime. One server going down means lost sales and frustrated customers. The solution is infrastructure that automatically handles failures and scales with demand.
In this project, you will use Terraform to deploy a production-grade AWS infrastructure for SwiftCart Technologies. You will build a highly available architecture with networking, compute, database, and storage layers defined entirely as code.
What You'll Build
A complete AWS infrastructure for an e-commerce company, deployed through 8 Terraform configuration files that provision a VPC, Auto Scaling compute layer, RDS database, and S3 storage bucket.
By the end of this project, you'll have:
- A production-grade VPC with public and private subnets across 2 Availability Zones, a NAT Gateway, and layered security groups deployed via Terraform.
- An Auto Scaling Group behind an Application Load Balancer serving traffic with health checks, provisioned entirely as code.
- A Multi-AZ RDS MySQL database and an encrypted S3 bucket with versioning and automated 90-day Glacier archival.
- Secret Mission: Add a CloudWatch CPU alarm that triggers an Auto Scaling policy when CPU exceeds 70%.
Are there any prerequisites?
This project assumes you have an AWS account with the AWS CLI configured, Terraform installed, and a code editor ready. You should be familiar with core AWS services like VPC, EC2, RDS, and S3.
This is Project 1 of 2. You will keep this infrastructure running at the end and add operations tooling (IAM, CloudWatch, CloudTrail, Route 53) in Project 2.
Before We Start
Before We Start...
Set Up the Project Structure
Every Terraform project starts with a well-organized directory of configuration files. Each file handles a specific concern: networking, compute, storage, and so on.
In this step, you will create the swiftcart-infra project folder, scaffold 8 Terraform files, configure the AWS provider, and initialize the project so Terraform can download everything it needs.
In this step, get ready to:
- Create the project directory and Terraform file structure.
- Configure the AWS provider and define input variables.
- Initialize Terraform to download the AWS provider.
Create the project directory and file structure
Terraform reads all .tf files in a directory as a single configuration. Splitting resources into separate files keeps the project navigable as it grows.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir swiftcart-infra
cd swiftcart-infra
- Create the 8 Terraform files by running this command:
touch providers.tf variables.tf vpc.tf security.tf compute.tf database.tf storage.tf outputs.tf
- Confirm the files exist by running:
ls
- You should see all 8 .tf files listed in the output.
Don't see all 8 files?
Make sure you ran the touch command from inside the swiftcart-infra folder. Run pwd to check your current directory ends with swiftcart-infra.
If you are on Windows without Git Bash, use New-Item in PowerShell instead of touch.
Help me figure out what went wrong
Configure the AWS provider and define input variables
The provider block tells Terraform which cloud platform to target and which plugin version to download. The variables file defines inputs you can change without editing resource code.
- Open providers.tf in your code editor and paste the following code:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- required_version = ">= 1.15.0" ensures anyone running this project has a compatible Terraform CLI version.
- source = "hashicorp/aws" tells Terraform to download the official AWS provider from the Terraform Registry.
- version = "~> 6.0" allows any 6.x release but prevents automatic upgrades to 7.0 or higher, avoiding breaking changes.
- region = var.aws_region reads the region from a variable so you can change it without editing this file.
- Save providers.tf.
- You won't see a change yet. We will validate this file when we run terraform init shortly.
- Open variables.tf in your code editor and paste the following code:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "swiftcart"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "db_username" {
description = "RDS master username"
type = string
sensitive = true
}
variable "db_password" {
description = "RDS master password"
type = string
sensitive = true
}
What does this code do?
- aws_region, project_name, and vpc_cidr all have sensible defaults so you can run the project without manually entering them every time.
- db_username and db_password are marked sensitive = true. Terraform will hide these values from plan and apply output, keeping credentials out of your terminal logs.
- Variables without a default require you to provide a value at runtime. Terraform will prompt for them during terraform apply.
- Save variables.tf.
- You won't see a change yet. The next substep validates both files together.
Why separate files instead of one big main.tf?
Terraform merges every .tf file in a directory into one configuration. Splitting by concern (networking, compute, storage) makes it easy for a team to review changes without scrolling through hundreds of lines.
When this project grows in Step 2, each file stays focused and approachable.
✔️ Awesome, I've got everything!
Great. Double check you have saved both providers.tf and variables.tf.
ⓧ I'd like to double check the full code
Here is providers.tf:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Here is variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "swiftcart"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "db_username" {
description = "RDS master username"
type = string
sensitive = true
}
variable "db_password" {
description = "RDS master password"
type = string
sensitive = true
}
Initialize Terraform
Initialization downloads the provider plugins declared in your configuration and prepares the working directory for subsequent commands like terraform plan and terraform apply.
- Download the AWS provider by running this command:
terraform init
- You should see output ending with the message Terraform has been successfully initialized!.
- Confirm the hidden .terraform directory was created by running:
ls -a
- You should see .terraform in the listing. This is where Terraform stores downloaded provider binaries.
What did terraform init create?
The .terraform directory holds cached provider plugins so Terraform does not re-download them on every run.
You will also see a .terraform.lock.hcl file. This lock file records the exact provider version that was installed, ensuring consistent builds across machines.
Seeing an error during init?
If you see a provider download failure, check that your internet connection is active and that your AWS CLI credentials are configured correctly (run aws sts get-caller-identity to verify).
If the error mentions a version constraint, confirm your providers.tf uses version = "~> 6.0" with the tilde-arrow operator, not an equals sign.
Help me troubleshoot my terraform init error
Your project scaffold is in place and Terraform is ready to work. Next up, you will define the VPC networking layer that gives SwiftCart's infrastructure its foundation.
Build the Network Layer
Your project structure is set up and Terraform is initialized. Now you'll build the network foundation that all of SwiftCart's infrastructure runs on.
Production workloads need isolated network layers to separate public-facing resources from sensitive backend systems. You'll define a VPC, subnets, gateways, and security groups across two Terraform files.
In this step, get ready to:
- Build a VPC with public and private subnets across 2 Availability Zones.
- Configure internet access with an Internet Gateway and NAT Gateway.
- Define layered security groups for the ALB, EC2, and RDS tiers.
Build the VPC foundation
The VPC is the isolated virtual network where all your AWS resources live. You'll carve it into four subnets across two Availability Zones, giving SwiftCart redundancy if one data center goes down.
- Open vpc.tf in your code editor and add the VPC and subnet resources by pasting this code:
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_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}b"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-private-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-private-2"
}
}
What does this code do?
- aws_vpc.main creates the VPC with the 10.0.0.0/16 CIDR block, giving you 65,536 possible IP addresses.
- enable_dns_hostnames = true lets instances receive public DNS names, which the load balancer needs later.
- Two public subnets get map_public_ip_on_launch = true so resources in them are reachable from the internet.
- Two private subnets have no public IP mapping, keeping backend resources hidden.
- Spreading subnets across us-east-1a and us-east-1b means traffic can shift to the surviving AZ if one goes down.
- Save vpc.tf.
- You won't see output yet. The next chunk adds gateways that connect these subnets to the internet.
Seeing syntax errors in your editor?
- Make sure each resource block has matching opening and closing braces.
- Check that string values like CIDR blocks are wrapped in double quotes.
- Confirm there are no trailing commas after the last attribute in a block.
Help me fix a syntax error in my vpc.tf file.
Configure internet access
Subnets alone can't reach the internet. Public subnets need an Internet Gateway for direct access, and private subnets use a NAT Gateway for outbound-only connectivity.
- Add the Internet Gateway, Elastic IP, and NAT Gateway below your subnet resources in vpc.tf by pasting this code:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_eip" "nat" {
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-nat"
}
depends_on = [aws_internet_gateway.main]
}
What are these gateways for?
- The Internet Gateway gives your public subnets a direct path to and from the internet.
- The Elastic IP provides a static public address for the NAT Gateway so private subnet traffic has a consistent outbound identity.
- The NAT Gateway sits in the first public subnet and lets private resources make outbound requests without being directly reachable.
- depends_on ensures Terraform creates the Internet Gateway before the NAT Gateway, since NAT needs internet access to function.
- Save vpc.tf.
- You won't see output yet. The next chunk adds route tables that direct traffic through these gateways.
- Add the route tables and their subnet associations below the NAT Gateway in vpc.tf by pasting this code:
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" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-private-rt"
}
}
resource "aws_route_table_association" "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 do route tables do?
- The public route table sends all non-local traffic (0.0.0.0/0) through the Internet Gateway for direct internet access.
- The private route table sends outbound traffic through the NAT Gateway instead, keeping private resources unreachable from outside.
- Each aws_route_table_association links a subnet to its route table, telling AWS which routing rules apply to that subnet's traffic.
- Public subnets get the public route table. Private subnets get the private route table.
- Save vpc.tf.
- You won't see output yet. After adding security groups in the next substep, you'll run terraform plan to verify everything together.
Not sure where to paste each section?
- The gateway code goes directly below your last subnet resource (aws_subnet.private_2).
- The route table code goes directly below the NAT Gateway resource (aws_nat_gateway.main).
- Make sure there are no extra closing braces between sections.
Help me organize my vpc.tf file correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved vpc.tf before moving on.
ⓧ I'd like to double check the full code
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_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}b"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}a"
tags = {
Name = "${var.project_name}-private-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = "${var.aws_region}b"
tags = {
Name = "${var.project_name}-private-2"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_eip" "nat" {
tags = {
Name = "${var.project_name}-nat-eip"
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-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 = "${var.project_name}-public-rt"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-private-rt"
}
}
resource "aws_route_table_association" "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
}
Define layered security groups
Security groups act as firewalls for your resources. You'll create three that chain together: the ALB accepts traffic from the internet, EC2 instances only accept traffic from the ALB, and the database only accepts traffic from EC2.
- Open security.tf and define all three security groups by pasting this code:
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Allow HTTP inbound to ALB"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_security_group" "ec2" {
name = "${var.project_name}-ec2-sg"
description = "Allow HTTP from ALB only"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-ec2-sg"
}
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
description = "Allow MySQL from EC2 only"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from EC2"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ec2.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-rds-sg"
}
}
How does security group chaining work?
- The ALB security group allows HTTP (port 80) from anywhere (0.0.0.0/0). This is the only entry point from the public internet.
- The EC2 security group allows HTTP only from the ALB security group's ID. No direct internet traffic reaches the instances.
- The RDS security group allows MySQL (port 3306) only from the EC2 security group's ID. The database is locked behind two layers.
- All three allow unrestricted outbound (egress) traffic so resources can make external requests like downloading packages.
- Save security.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved security.tf before running the plan.
ⓧ I'd like to double check the full code
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Allow HTTP inbound to ALB"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_security_group" "ec2" {
name = "${var.project_name}-ec2-sg"
description = "Allow HTTP from ALB only"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-ec2-sg"
}
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-rds-sg"
description = "Allow MySQL from EC2 only"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from EC2"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.ec2.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-rds-sg"
}
}
- Validate the full network configuration by running this command in your terminal:
terraform plan
At the bottom of the output, you should see a summary line that says Plan: X to add, 0 to change, 0 to destroy where X is approximately 20 or more.
The listed resources include your VPC, 4 subnets, Internet Gateway, EIP, NAT Gateway, 2 route tables, 4 route table associations, and 3 security groups.
Seeing errors in the plan output?
- If you see a message about missing closing braces, go back to vpc.tf or security.tf and check that every opening { has a matching }.
- If Terraform can't find a referenced resource (like aws_vpc.main.id), make sure the VPC resource is defined before the subnets that reference it.
- If you see a provider error, confirm your AWS credentials are still configured and active.
Help me fix errors in my terraform plan output for VPC and security group resources.
Your entire network layer is defined as code. Next up, you'll add the compute resources that actually serve traffic through this network.
Deploy Compute and Load Balancing
Your network is in place. Traffic can flow between public and private subnets, and your security groups control exactly which resources can talk to each other.
Now you need something to actually serve traffic. In this step, you will create EC2 instances behind an Application Load Balancer that distributes incoming requests across multiple servers in an Auto Scaling Group.
In this step, get ready to:
- Define a launch template that configures each EC2 instance with Nginx.
- Create an Application Load Balancer with health checks to route traffic.
- Wire up an Auto Scaling Group that maintains 2 instances and can scale to 5.
Create the launch template
A launch template tells AWS how to configure each EC2 instance. It specifies the machine image, instance size, security group, and a startup script that runs when the instance boots.
- Open compute.tf in your editor and add the following code to fetch the latest Amazon Linux 2023 AMI and define the launch template:
data "aws_ssm_parameter" "al2023_ami" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
resource "aws_launch_template" "app" {
name = "${var.project_name}-lt"
image_id = data.aws_ssm_parameter.al2023_ami.value
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = base64encode(<<-EOF
#!/bin/bash
dnf update -y
dnf install -y nginx nodejs
systemctl enable nginx
systemctl start nginx
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app"
}
}
}
What does this code do?
The data "aws_ssm_parameter" block fetches the latest Amazon Linux 2023 AMI ID from AWS Systems Manager. This means your infrastructure always uses the most recent patched image without hardcoding an AMI ID.
The user_data script runs on every new instance at boot. It uses dnf (Amazon Linux 2023's package manager) to install Nginx and Node.js, then starts the Nginx web server so each instance is ready to serve traffic immediately.
Why fetch the AMI from SSM instead of hardcoding it?
AMI IDs change with every patch release and differ between regions. By reading the ID from an SSM parameter, your Terraform code stays portable. You can deploy the same configuration in any region without editing the AMI value.
- Save compute.tf.
Create the Application Load Balancer
The ALB sits in the public subnets and distributes incoming HTTP requests to healthy instances in the private subnets. It uses a target group and health checks to decide which instances should receive traffic.
- In compute.tf, add the ALB, target group, and listener below the launch template:
resource "aws_lb" "app" {
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}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
tags = {
Name = "${var.project_name}-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
What does this code do?
- aws_lb "app" creates an internet-facing Application Load Balancer in both public subnets. The ALB security group only allows inbound HTTP on port 80.
- aws_lb_target_group "app" defines where traffic goes. The health check pings / every 30 seconds and requires 2 consecutive successes before marking an instance healthy.
- aws_lb_listener "http" connects port 80 on the ALB to the target group, forwarding all incoming requests to healthy instances.
Now you need an Auto Scaling Group to launch instances using your template and register them with the target group. The ASG ensures you always have at least 2 instances running and can scale up to 5 under load.
- Add the Auto Scaling Group at the bottom of compute.tf:
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-asg"
min_size = 2
max_size = 5
desired_capacity = 2
vpc_zone_identifier = [aws_subnet.private_1.id, aws_subnet.private_2.id]
target_group_arns = [aws_lb_target_group.app.arn]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-asg-instance"
propagate_at_launch = true
}
}
What does this code do?
- vpc_zone_identifier places instances in both private subnets across two Availability Zones. If one AZ goes down, the other still serves traffic.
- target_group_arns registers every new instance with the ALB target group automatically. The ALB starts sending traffic as soon as health checks pass.
- version = "$Latest" means every new instance uses the most recent version of your launch template. If you update the template later, new instances pick up the changes.
- Save compute.tf.
- Verify your plan includes the new compute resources by running:
terraform plan
- You should see approximately 25+ resources to be created. The new compute resources include the launch template, ASG, ALB, target group, and listener.
Seeing an error in terraform plan?
- If you see a reference error mentioning aws_security_group.alb or aws_security_group.ec2, confirm that your security.tf file is saved with those resource names.
- If you see a reference error mentioning aws_subnet.public_1 or aws_subnet.private_1, confirm that your vpc.tf file is saved with those subnet resource names.
- Check that the heredoc in user_data has matching <<-EOF and EOF markers. A missing or misaligned marker causes a syntax error.
Help me debug my terraform plan error in compute.tf
✔️ Awesome, I've got everything!
Great. Double check you have saved compute.tf and that your plan shows approximately 25+ resources.
ⓧ I'd like to double check the full code
data "aws_ssm_parameter" "al2023_ami" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
resource "aws_launch_template" "app" {
name = "${var.project_name}-lt"
image_id = data.aws_ssm_parameter.al2023_ami.value
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.ec2.id]
user_data = base64encode(<<-EOF
#!/bin/bash
dnf update -y
dnf install -y nginx nodejs
systemctl enable nginx
systemctl start nginx
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-app"
}
}
}
resource "aws_lb" "app" {
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}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
matcher = "200"
}
tags = {
Name = "${var.project_name}-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project_name}-asg"
min_size = 2
max_size = 5
desired_capacity = 2
vpc_zone_identifier = [aws_subnet.private_1.id, aws_subnet.private_2.id]
target_group_arns = [aws_lb_target_group.app.arn]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-asg-instance"
propagate_at_launch = true
}
}
Your compute layer is ready. You have instances that auto-scale, a load balancer that distributes traffic, and health checks that remove unhealthy instances automatically. Next up, you will add the database and storage layers to complete the infrastructure.
Add Database and Storage
Your compute layer is ready with an Auto Scaling Group behind a load balancer. Now the application needs somewhere to persist data and store assets.
In this step, you will provision a Multi-AZ RDS MySQL database in private subnets and an encrypted S3 bucket with versioning and automated Glacier archival. You will also add Terraform outputs so you can retrieve key endpoints after deployment.
In this step, get ready to:
- Create a Multi-AZ RDS MySQL instance isolated in private subnets.
- Configure an S3 bucket with encryption, versioning, and a Glacier lifecycle policy.
- Add Terraform outputs and validate the full plan.
Create the RDS MySQL instance
The database needs its own subnet group to tell AWS which subnets it can launch in. Then the instance itself gets configured for high availability with Multi-AZ replication.
- Open database.tf in your editor and add the following code:
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_1.id, aws_subnet.private_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = "swiftcartdb"
username = var.db_username
password = var.db_password
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
skip_final_snapshot = true
tags = {
Name = "${var.project_name}-db"
}
}
What does this code do?
The aws_db_subnet_group tells RDS to place the database in your two private subnets across different Availability Zones. This is a prerequisite for enabling Multi-AZ.
The aws_db_instance creates a MySQL 8.0 database on a db.t3.micro instance with 20 GB of encrypted storage. Setting multi_az = true tells AWS to maintain a synchronous standby replica in a different AZ for automatic failover.
The security group reference (aws_security_group.rds.id) ensures only your EC2 instances can reach the database on port 3306. The skip_final_snapshot = true setting lets Terraform destroy the database without requiring a manual snapshot, which is appropriate for a learning project.
- Save database.tf.
You will not see a change yet. The next file adds storage, then you will run terraform plan to verify everything together.
Create the S3 bucket with security and lifecycle policies
SwiftCart needs an image storage bucket that is encrypted at rest, versioned for recovery, and automatically archives old files to Glacier to reduce costs.
- Open storage.tf and add the following code:
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-assets-${random_id.bucket_suffix.hex}"
tags = {
Name = "${var.project_name}-assets"
}
}
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "glacier-transition"
status = "Enabled"
transition {
days = 90
storage_class = "GLACIER"
}
}
}
What does this code do?
The random_id resource generates a random hex suffix so the bucket name is globally unique. S3 bucket names must be unique across all AWS accounts worldwide.
Each bucket configuration is defined as a separate resource because the AWS provider requires standalone resources for versioning, encryption, and lifecycle policies. This is the modern pattern replacing deprecated inline configuration.
The lifecycle rule transitions all objects to GLACIER after 90 days. When no filter is specified, the rule applies to every object in the bucket.
- Save storage.tf.
You will not see a change yet. The next chunk adds outputs and runs the plan to confirm everything.
Add outputs and validate the full plan
Outputs let you retrieve important values (like the ALB URL or database endpoint) directly from Terraform after deployment, without digging through the AWS console.
- Open outputs.tf and add the following code:
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.app.dns_name
}
output "rds_endpoint" {
description = "RDS instance endpoint"
value = aws_db_instance.main.endpoint
}
output "s3_bucket_name" {
description = "S3 bucket name for assets"
value = aws_s3_bucket.assets.id
}
What does this code do?
Each output block exposes a value after terraform apply completes. You will use alb_dns_name to test the load balancer, rds_endpoint to connect applications to the database, and s3_bucket_name to reference the storage bucket.
- Save outputs.tf.
- Validate the full infrastructure plan by running this command:
terraform plan
- Review the output. You should see approximately 30+ resources planned for creation across your VPC, compute, database, and storage layers.
Seeing errors in your plan?
- If you see a provider error about random, run terraform init again. The random_id resource requires the hashicorp/random provider, which Terraform auto-detects and downloads during init.
- If you see a syntax error, check that every opening brace { has a matching closing brace } in each file.
- If the plan shows fewer resources than expected, confirm you saved all three files (database.tf, storage.tf, outputs.tf).
Still stuck? Help me debug my terraform plan errors for the database and storage step.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created in this step.
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_1.id, aws_subnet.private_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = "swiftcartdb"
username = var.db_username
password = var.db_password
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
skip_final_snapshot = true
tags = {
Name = "${var.project_name}-db"
}
}
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-assets-${random_id.bucket_suffix.hex}"
tags = {
Name = "${var.project_name}-assets"
}
}
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "glacier-transition"
status = "Enabled"
transition {
days = 90
storage_class = "GLACIER"
}
}
}
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.app.dns_name
}
output "rds_endpoint" {
description = "RDS instance endpoint"
value = aws_db_instance.main.endpoint
}
output "s3_bucket_name" {
description = "S3 bucket name for assets"
value = aws_s3_bucket.assets.id
}
Your entire data layer is planned and ready for deployment. Next up, you will run terraform apply to bring all 30+ resources to life and validate the running infrastructure.
Deploy and Validate
Your Terraform configuration is complete and validated. The plan confirmed 30+ resources are ready to go.
Now it is time to bring SwiftCart's infrastructure to life. In this step, you will deploy everything to AWS and verify that the ALB is serving traffic, the database is running in Multi-AZ mode, and Terraform is tracking every resource.
In this step, get ready to:
- Deploy the full infrastructure to AWS with terraform apply.
- Validate the ALB endpoint returns a healthy Nginx response.
- Confirm Terraform state tracks all managed resources.
Deploy the infrastructure
Terraform has two sensitive variables without defaults: db_username and db_password. When you run terraform apply, Terraform will prompt you to enter a value for each one.
- Deploy all resources by running this command:
terraform apply
Terraform will prompt you for the two database credentials one at a time. The prompt looks like this:
var.db_username
Enter a value:
- Enter a username of your choice (e.g. admin) when prompted for db_username.
- Enter a strong password of your choice (at least 8 characters) when prompted for db_password.
- Terraform will then show you the full execution plan. Type yes when prompted to confirm the deployment.
What happens during apply?
Terraform creates resources in dependency order. The VPC and subnets go first, then security groups, then compute and database resources that depend on them.
The entire deployment takes approximately 10-15 minutes. The RDS Multi-AZ instance is the slowest resource to create because AWS provisions a primary and standby database across two Availability Zones.
Remember your credentials
Write down or remember the database username and password you entered. You will need them if you ever re-run terraform apply or modify the database configuration.
- Wait for terraform apply to complete. You will see a final message showing the number of resources created and the output values.
Seeing an error during apply?
If you see a credentials error, confirm your AWS CLI is configured with valid access keys by running aws sts get-caller-identity.
If you see a quota or limit error, your AWS account may have restrictions on NAT Gateways or Elastic IPs in the region. Check your AWS Service Quotas in the console.
Help me troubleshoot my terraform apply error.
Validate the deployment
Once terraform apply finishes, Terraform prints three output values. The most important one right now is alb_dns_name because you can use it to verify traffic is flowing through the Application Load Balancer to your EC2 instances.
- Copy the alb_dns_name value from the Terraform output.
- Test the ALB endpoint by running this command (replacing the placeholder with your actual ALB DNS name):
curl http://[[ALB_DNS="your ALB DNS name from the terraform output"]]
What should I see?
You should see the Nginx default welcome page HTML in your terminal. This confirms that the ALB is receiving your request, routing it to one of the EC2 instances in the private subnets, and Nginx is responding successfully.
You can also paste the ALB DNS name directly into your browser to see the Nginx welcome page rendered visually.
Allow time for health checks
The ALB needs 2-3 minutes after deployment to register the EC2 instances as healthy. If you see a 503 error immediately after apply completes, wait a few minutes and try again.
- Verify the rds_endpoint output is printed. This confirms your Multi-AZ MySQL database was created successfully.
- Verify the s3_bucket_name output is printed. This confirms your encrypted S3 bucket with lifecycle policies is ready.
Getting a 503 error from the ALB?
A 503 means no healthy targets are registered yet. The EC2 instances need time to boot, install Nginx via the user data script, and pass the ALB health checks.
Wait 3-5 minutes after terraform apply finishes, then try curl again. If it still fails after 5 minutes, check the EC2 instances in the AWS Console to confirm they are running.
My ALB is still returning 503 after waiting. Help me debug.
Confirm infrastructure state
Terraform tracks every resource it created in a state file. You can list all managed resources to confirm nothing was missed.
- View all resources Terraform is managing by running:
terraform state list
What does this command do?
This prints every resource address that Terraform is tracking in its state file. You should see entries for your VPC, subnets, security groups, ALB, ASG, RDS instance, S3 bucket, and all supporting resources.
Each line maps directly to a resource or data block in your .tf files. If a resource appears in this list, Terraform owns it and will manage its lifecycle going forward.
- Confirm you see approximately 30+ resources in the list.
Do not destroy this infrastructure
This infrastructure continues into Project 2, where you will add CloudWatch monitoring, CloudTrail audit logging, IAM roles, and Route 53 DNS. Keep everything running.
Do NOT run terraform destroy until you have completed Project 2. You will tear down both projects together at the end.
Secret mission
Add CloudWatch Auto Scaling Alarm
Your infrastructure can scale from 2 to 5 instances, but right now nothing triggers that scaling. In this secret mission, you will connect CloudWatch monitoring to your Auto Scaling Group so it automatically adds instances when CPU usage crosses 70%.
Clean Up Your Resources
Clean Up Your Resources
This project provisions AWS resources that incur ongoing hourly charges. Decide whether to keep your resources running for Project 2, pause them to reduce costs, or delete them entirely.
Cost warning
Your NAT Gateway, ALB, RDS Multi-AZ instance, and 2 EC2 instances are all incurring charges. The total cost is approximately $0.12/hr or $3/day.
If you plan to continue to Project 2, keep everything running and move directly into it. If you are not continuing, run terraform destroy now to avoid unnecessary charges.
Resources you used:
- VPC with 4 subnets, Internet Gateway, NAT Gateway, and Elastic IP.
- Application Load Balancer with target group and listener.
- Auto Scaling Group with 2 EC2 instances and a launch template.
- RDS Multi-AZ MySQL instance with DB subnet group.
- S3 bucket with versioning, encryption, and lifecycle configuration.
- 3 security groups (ALB, EC2, RDS).
- CloudWatch metric alarm and Auto Scaling policy (if you completed the Secret Mission).
- Local project files in the swiftcart-infra directory.
Keep everything running
No action needed. Choose this if you are moving directly to Project 2 to add operations tooling (IAM, CloudWatch detailed monitoring, CloudTrail, Route 53).
- Leave all infrastructure running. You will extend and destroy it at the end of Project 2.
- Your Terraform state file in swiftcart-infra tracks every resource. Do not delete the local directory.
Pause - I'll come back to this later
Shut down the EC2 instances to reduce compute charges while keeping the rest of the infrastructure intact.
- In your terminal, navigate to the swiftcart-infra directory.
- Set the Auto Scaling Group desired capacity to 0 to terminate all EC2 instances. This stops EC2 charges but the NAT Gateway, ALB, and RDS instance continue to incur costs.
Reduce the ASG capacity by running:
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name swiftcart-asg \
--min-size 0 \
--desired-capacity 0
What still costs money while paused?
The NAT Gateway (~$0.045/hr), ALB (~$0.0225/hr), and RDS Multi-AZ instance (~$0.034/hr) continue running. Only the EC2 instance charges stop.
When you are ready to resume, set --min-size 2 --desired-capacity 2 to bring the instances back.
Delete - I don't want to use this again
Remove all AWS resources and local project files. Only do this after completing Project 2, or if you have decided not to continue.
- Navigate to the swiftcart-infra directory in your terminal.
- Destroy all AWS resources managed by Terraform by running:
terraform destroy
- Terraform will show a plan of all resources to be destroyed. Type yes when prompted to confirm.
- Wait for the destroy to complete. RDS deletion takes a few minutes.
- Provide the db_username and db_password variables when prompted (same values you used during terraform apply).
- Once the destroy completes, delete the local project directory by running:
cd ~/Desktop
rm -rf swiftcart-infra
What does terraform destroy remove?
It removes every resource tracked in your Terraform state file. This includes the VPC, all subnets, the NAT Gateway, ALB, EC2 instances, RDS instance, S3 bucket, security groups, and any Secret Mission resources (CloudWatch alarm, Auto Scaling policy).
Your AWS account itself is not affected. Only the resources Terraform created are deleted.
Nice Work!
Nice Work!
You just deployed a complete production-grade AWS infrastructure for SwiftCart Technologies using nothing but Terraform configuration files.
You learned how to:
- Build a production-grade VPC with public and private subnets across 2 Availability Zones, including NAT Gateway and layered security groups, all defined in Terraform.
- Deploy an Auto Scaling Group behind an Application Load Balancer with health checks and automatic instance replacement, provisioned entirely as code.
- Provision a Multi-AZ RDS MySQL database accessible only from application instances via security group chaining, and an encrypted S3 bucket with versioning and automated 90-day Glacier archival.
- Secret Mission: Connected CloudWatch CPU monitoring to Auto Scaling policies for automatic horizontal scaling at 70% CPU threshold.
Ready to quiz yourself?