Build AWS 3-Tier Architecture with Terraform and CI/CD

Provision a production AWS 3-tier architecture (ALB, EC2 ASG, RDS) with Terraform, a VPC, and an automated CI/CD deployment pipeline.

Domains & Technologies

Domains
CI-CDVPCAWS-CLOUD-ENGINEERING
Technologies
TERRAFORMAWS

Blueprint Walkthrough

Architecture Overview

In this project you will build the most common and battle-tested cloud architecture in production: a 3-tier application running on AWS. You will provision everything with Terraform — the networking (VPC, subnets, route tables, NAT gateways), the web tier (Application Load Balancer), the application tier (EC2 Auto Scaling Group), and the data tier (RDS Multi-AZ). Then you will wire a complete CI/CD pipeline that deploys application updates through GitHub Actions directly to the Auto Scaling Group.

This is the architecture that powers most Indian SaaS companies, fintech backends, and e-commerce platforms running on AWS before they move to Kubernetes.

◈ DIAGRAM
Region: ap-south-1
+------------------------------------------------------------------+
| |
| Internet Gateway |
| | |
| +-----------------------+ |
| | Public Subnets | |
| | (AZ-a and AZ-b) | |
| | Application Load | |
| | Balancer (ALB) | |
| | NAT Gateways | |
| +-----------------------+ |
| | |
| +-----------------------+ |
| | Private App Subnets | |
| | (AZ-a and AZ-b) | |
| | EC2 Auto Scaling | |
| | Group (2-8 instances) | |
| +-----------------------+ |
| | |
| +-----------------------+ |
| | Private DB Subnets | |
| | (AZ-a and AZ-b) | |
| | RDS MySQL Multi-AZ | |
| +-----------------------+ |
| |
+------------------------------------------------------------------+
Problem Solved

Most teams start by manually creating AWS resources through the console. This works for one person but breaks immediately with a team. Who changed the security group? Why is staging different from production? How do you rebuild after an incident?

This project solves these problems completely. Every resource is defined in code. Adding a new team member means giving them the Git repository — not a walkthrough of 50 AWS console pages. Creating a new environment (staging) means running terraform workspace new staging && terraform apply. Recovering from a disaster means running terraform apply from the state backup.

The CI/CD integration means no developer ever SSHes into production. Code goes through GitHub -> tests -> build -> deployment to the ASG. The ASG performs health checks and automatically removes unhealthy instances, making every deployment zero-downtime.

Step-by-Step Implementation Guide

Step 1: Terraform Project Structure and Backend

Organise the Terraform project with a module-based structure that separates concerns and makes each layer independently testable.

Bash
mkdir -p aws-3tier/{
modules/vpc,
modules/alb,
modules/asg,
modules/rds,
modules/security-groups,
environments/production,
environments/staging
}
cd aws-3tier

Create the S3 backend:

Bash
## Create state bucket
aws s3api create-bucket \
--bucket 3tier-terraform-state-$(aws sts get-caller-identity --query Account --output text) \
--region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-bucket-versioning \
--bucket 3tier-terraform-state-$(aws sts get-caller-identity --query Account --output text) \
--versioning-configuration Status=Enabled
## Enable server-side encryption on the state bucket
aws s3api put-bucket-encryption \
--bucket 3tier-terraform-state-$(aws sts get-caller-identity --query Account --output text) \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
## Create DynamoDB lock table
aws dynamodb create-table \
--table-name 3tier-terraform-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST

Step 2: Build the VPC Module

Create modules/vpc/main.tf:

HCL
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, { Name = "${var.project}-vpc" })
}
## Public subnets — ALB and NAT Gateways live here
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.project}-public-${var.availability_zones[count.index]}"
Tier = "public"
})
}
## Private application subnets — EC2 instances live here
resource "aws_subnet" "private_app" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 2)
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.project}-private-app-${var.availability_zones[count.index]}"
Tier = "application"
})
}
## Private database subnets — RDS lives here
resource "aws_subnet" "private_db" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 4)
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.project}-private-db-${var.availability_zones[count.index]}"
Tier = "database"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, { Name = "${var.project}-igw" })
}
## One NAT Gateway per AZ for high availability
resource "aws_eip" "nat" {
count = length(var.availability_zones)
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
count = length(var.availability_zones)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
depends_on = [aws_internet_gateway.main]
tags = merge(var.tags, { Name = "${var.project}-nat-${count.index}" })
}
## 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 = merge(var.tags, { Name = "${var.project}-public-rt" })
}
resource "aws_route_table" "private_app" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = merge(var.tags, { Name = "${var.project}-private-app-rt-${count.index}" })
}
Security

Database subnets should have no route to the internet — not even through a NAT Gateway. Create a private_db route table with no default route. The database only needs to receive traffic from the application layer, not initiate any outbound connections.

Step 3: Build the Security Groups Module

Create modules/security-groups/main.tf:

HCL
## ALB Security Group — accepts HTTPS from internet
resource "aws_security_group" "alb" {
name = "${var.project}-alb-sg"
description = "Allow HTTPS inbound traffic to ALB"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTPS from internet"
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "HTTP redirect to HTTPS"
}
egress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.app.id]
description = "ALB to app tier only"
}
tags = merge(var.tags, { Name = "${var.project}-alb-sg" })
}
## Application Security Group — accepts traffic only from ALB
resource "aws_security_group" "app" {
name = "${var.project}-app-sg"
description = "Allow inbound from ALB only"
vpc_id = var.vpc_id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
description = "App port from ALB only"
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Outbound to reach RDS and internet"
}
tags = merge(var.tags, { Name = "${var.project}-app-sg" })
}
## Database Security Group — accepts traffic only from application tier
resource "aws_security_group" "db" {
name = "${var.project}-db-sg"
description = "Allow MySQL from app tier only"
vpc_id = var.vpc_id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
description = "MySQL from app tier only"
}
tags = merge(var.tags, { Name = "${var.project}-db-sg" })
}

Step 4: Build the ALB and Auto Scaling Group

Create modules/alb/main.tf:

HCL
resource "aws_lb" "main" {
name = "${var.project}-alb"
internal = false
load_balancer_type = "application"
security_groups = [var.alb_security_group_id]
subnets = var.public_subnet_ids
enable_deletion_protection = true # Prevent accidental deletion in production
access_logs {
bucket = aws_s3_bucket.alb_logs.bucket
enabled = true
}
tags = merge(var.tags, { Name = "${var.project}-alb" })
}
resource "aws_lb_target_group" "app" {
name = "${var.project}-app-tg"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
enabled = true
healthy_threshold = 2
unhealthy_threshold = 5
timeout = 10
interval = 30
path = "/health" # Your app's health check endpoint
matcher = "200"
}
tags = var.tags
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}

Create modules/asg/main.tf:

HCL
## Launch template — defines what each EC2 instance looks like
resource "aws_launch_template" "app" {
name_prefix = "${var.project}-app-"
image_id = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
vpc_security_group_ids = [var.app_security_group_id]
iam_instance_profile {
name = aws_iam_instance_profile.app.name
}
# User data script — runs on every new instance launch
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y docker
systemctl start docker
systemctl enable docker
# Pull and run the latest application image from ECR
aws ecr get-login-password --region ${var.aws_region} | \
docker login --username AWS --password-stdin ${var.ecr_registry}
docker pull ${var.ecr_registry}/${var.app_image}:latest
docker run -d \
--name app \
--restart unless-stopped \
-p 8080:8080 \
-e DB_HOST=${var.db_endpoint} \
-e DB_NAME=${var.db_name} \
${var.ecr_registry}/${var.app_image}:latest
EOF
)
# Enforce IMDSv2 to prevent SSRF attacks
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
lifecycle {
create_before_destroy = true # Zero-downtime updates
}
tags = var.tags
}
resource "aws_autoscaling_group" "app" {
name = "${var.project}-app-asg"
vpc_zone_identifier = var.private_app_subnet_ids
target_group_arns = [var.target_group_arn]
health_check_type = "ELB" # Use ALB health checks, not just EC2 status checks
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50 # Keep at least 50% of instances healthy during updates
}
}
tag {
key = "Name"
value = "${var.project}-app-instance"
propagate_at_launch = true
}
}
## Auto Scaling policy — scale out when CPU > 70%
resource "aws_autoscaling_policy" "scale_out" {
name = "${var.project}-scale-out"
scaling_adjustment = 1
adjustment_type = "ChangeInCapacity"
cooldown = 300
autoscaling_group_name = aws_autoscaling_group.app.name
}
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
alarm_name = "${var.project}-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 70
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.app.name
}
alarm_actions = [aws_autoscaling_policy.scale_out.arn]
}

Step 5: Build the RDS Module

Create modules/rds/main.tf:

HCL
resource "aws_db_subnet_group" "main" {
name = "${var.project}-db-subnet-group"
subnet_ids = var.private_db_subnet_ids
tags = var.tags
}
resource "aws_db_instance" "main" {
identifier = "${var.project}-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = var.db_instance_class
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password # In production: use aws_secretsmanager_secret
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [var.db_security_group_id]
multi_az = var.multi_az # true for production, false for dev
publicly_accessible = false # Never expose RDS to the internet
deletion_protection = true # Prevent accidental deletion
skip_final_snapshot = false
final_snapshot_identifier = "${var.project}-final-snapshot"
backup_retention_period = 7 # 7-day automated backups
backup_window = "03:00-04:00" # 3-4 AM UTC
maintenance_window = "Mon:04:00-Mon:05:00"
performance_insights_enabled = true
monitoring_interval = 60 # Enhanced monitoring every 60 seconds
tags = merge(var.tags, { Name = "${var.project}-rds" })
lifecycle {
prevent_destroy = true # Block accidental terraform destroy
}
}
Remember

Store the database password in AWS Secrets Manager, not in a .tfvars file. Create the secret manually, then reference it in Terraform with a data "aws_secretsmanager_secret_version" data source. Never commit database credentials to Git.

Step 6: Wire the Production Environment

Create environments/production/main.tf:

HCL
terraform {
required_version = ">= 1.7.0"
backend "s3" {
bucket = "3tier-terraform-state-YOUR_ACCOUNT_ID"
key = "production/terraform.tfstate"
region = "ap-south-1"
encrypt = true
dynamodb_table = "3tier-terraform-lock"
}
}
provider "aws" { region = "ap-south-1" }
module "vpc" {
source = "../../modules/vpc"
project = var.project_name
vpc_cidr = "10.0.0.0/16"
availability_zones = ["ap-south-1a", "ap-south-1b"]
tags = local.tags
}
module "security_groups" {
source = "../../modules/security-groups"
project = var.project_name
vpc_id = module.vpc.vpc_id
tags = local.tags
}
module "alb" {
source = "../../modules/alb"
project = var.project_name
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
alb_security_group_id = module.security_groups.alb_sg_id
certificate_arn = var.acm_certificate_arn
tags = local.tags
}
module "asg" {
source = "../../modules/asg"
project = var.project_name
aws_region = "ap-south-1"
instance_type = "t3.small"
private_app_subnet_ids = module.vpc.private_app_subnet_ids
app_security_group_id = module.security_groups.app_sg_id
target_group_arn = module.alb.target_group_arn
ecr_registry = var.ecr_registry
app_image = var.app_image_name
db_endpoint = module.rds.db_endpoint
db_name = var.db_name
min_size = 2
max_size = 8
desired_capacity = 2
tags = local.tags
}
module "rds" {
source = "../../modules/rds"
project = var.project_name
private_db_subnet_ids = module.vpc.private_db_subnet_ids
db_security_group_id = module.security_groups.db_sg_id
db_instance_class = "db.t3.small"
db_name = var.db_name
db_username = var.db_username
db_password = data.aws_secretsmanager_secret_version.db_password.secret_string
multi_az = true
tags = local.tags
}
Bash
## Deploy the full stack
cd environments/production
terraform init
terraform plan -out=tfplan # Review carefully — especially security groups and RDS settings
terraform apply tfplan # Takes approximately 15-20 minutes (RDS Multi-AZ takes longest)

Step 7: Set Up the CI/CD Pipeline with GitHub Actions

Create .github/workflows/deploy.yml in your application repository:

YAML
name: Build and Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-deploy-role
aws-region: ap-south-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Run tests
run: |
npm install
npm test
- name: Build and tag Docker image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/my-app:$IMAGE_TAG .
docker tag $ECR_REGISTRY/my-app:$IMAGE_TAG $ECR_REGISTRY/my-app:latest
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
- name: Push to ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker push $ECR_REGISTRY/my-app:$IMAGE_TAG
docker push $ECR_REGISTRY/my-app:latest
- name: Trigger ASG Instance Refresh
run: |
aws autoscaling start-instance-refresh \
--auto-scaling-group-name my-project-app-asg \
--preferences '{"MinHealthyPercentage": 50}'
# Wait for refresh to complete
aws autoscaling wait instance-refresh-succeeded \
--auto-scaling-group-name my-project-app-asg
echo "Deployment complete — new instances running ${{ github.sha }}"
Tip

The instance-refresh command tells the ASG to gradually replace all running instances with new ones that will pull the updated Docker image on startup. Combined with MinHealthyPercentage: 50, this ensures at least half the instances stay healthy at all times during the update — zero-downtime deployment without Kubernetes.

Validation & Testing
Bash
## 1. Verify Terraform outputs
terraform output
## Expected: alb_dns_name, rds_endpoint, asg_name
## 2. Check ALB is healthy
ALB_DNS=$(terraform output -raw alb_dns_name)
curl -I https://$ALB_DNS
## Expected: HTTP 200 or 301 (redirect to HTTPS)
## 3. Verify Auto Scaling Group instances are healthy
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names my-project-app-asg \
--query 'AutoScalingGroups[0].Instances[*].[InstanceId,HealthStatus,LifecycleState]' \
--output table
## Expected: All instances show HealthStatus=Healthy and LifecycleState=InService
## 4. Verify RDS is accessible from application tier (not from internet)
aws rds describe-db-instances \
--db-instance-identifier my-project-mysql \
--query 'DBInstances[0].[DBInstanceStatus,MultiAZ,PubliclyAccessible,StorageEncrypted]' \
--output table
## Expected: available, True (Multi-AZ), False (not public), True (encrypted)
## 5. Test autoscaling — generate CPU load
## SSH to one app instance via Systems Manager (no SSH needed)
aws ssm start-session --target INSTANCE_ID
## Run stress test: stress --cpu 4 --timeout 300
## Watch autoscaling in CloudWatch:
aws autoscaling describe-scaling-activities \
--auto-scaling-group-name my-project-app-asg \
--max-items 5
## 6. Trigger a deployment via GitHub Actions
git commit --allow-empty -m "trigger: test deployment"
git push origin main
## Watch the GitHub Actions workflow complete
## Verify the ASG instance refresh completes without downtime
## 7. Verify Terraform state is clean with no drift
terraform plan
## Expected: No changes. Your infrastructure matches your configuration.
Common Mistake

The RDS instance taking prevent_destroy = true will block terraform destroy in the future. When you want to tear down the environment, you must first remove the prevent_destroy lifecycle block, run terraform apply, and then run terraform destroy. This is intentional — it prevents accidental database deletion in production.