AWS 3-Tier on EC2 with Terraform and CI/CD
Build a 3-tier AWS stack with Terraform modules: VPC, ALB, EC2 Auto Scaling, and Multi-AZ RDS, deployed by a GitHub Actions pipeline.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a three-tier AWS application with reusable Terraform modules: an Application Load Balancer in public subnets, an EC2 Auto Scaling Group in private subnets, and a Multi-AZ RDS MySQL database in isolated subnets. A GitHub Actions pipeline builds a Docker image, scans it, pushes it to ECR, and rolls it out with an ASG instance refresh. You finish with a repository you can show in a portfolio: modular infrastructure code plus a working deploy pipeline.
This is the EC2-based version of the three-tier pattern. You manage launch templates, instance roles, and rolling replacement yourself. If this is your first three-tier build, do Build a Three-Tier App on ECS Fargate with Terraform first: it uses containers on Fargate, flat Terraform files, and no pipeline, so you meet the network layout before the extra moving parts here.
What you build:
- A VPC with public, private app, and private database subnets across two availability zones
- An ALB with an HTTPS listener and an HTTP-to-HTTPS redirect
- An Auto Scaling Group of EC2 instances that pull a Docker image from ECR
- A Multi-AZ RDS MySQL instance whose password is managed by AWS Secrets Manager
- A GitHub Actions pipeline with OIDC authentication, Trivy scanning, and a rolling instance refresh
What you learn: Terraform modules and remote state, security group chaining, launch templates and IAM instance profiles, target-tracking autoscaling, and rolling deployments without SSH access.
public subnets] ALB --> ASG[EC2 Auto Scaling
private app subnets] ASG --> DB[(RDS MySQL
Multi-AZ, private)] GH[GitHub Actions] -->|push image| ECR[ECR] GH -->|instance refresh| ASG ECR -.->|pull image| ASG classDef edge fill:#F97316,color:#fff classDef app fill:#326CE5,color:#fff classDef data fill:#10B981,color:#fff classDef ci fill:#475569,color:#fff class ALB edge class ASG app class DB data class GH,ECR ci
The problem this solves
Teams that build AWS resources by hand in the console cannot answer basic questions: who changed that security group, why does staging differ from production, how do we rebuild after an incident. Here every resource lives in Git, so a new environment is a new folder under environments/ and a rebuild is terraform apply. No developer SSHes into production: code goes through GitHub, tests, a build, and a rolling replacement of instances. Treat zero-downtime as something to measure, not assume: Milestone 8 shows how.
TipCost matters in this lab. Two NAT Gateways and a Multi-AZ RDS instance bill hourly. Run
terraform destroyas soon as you finish a session.
Milestone 1: Create the Project Layout and Remote State
This milestone sets up a module-based folder structure and an S3 bucket for Terraform state, so state survives your laptop and two people cannot corrupt it by applying at once.
mkdir -p aws-3tier/modules/{vpc,security-groups,alb,asg,rds}mkdir -p aws-3tier/environments/{staging,production}cd aws-3tier ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)BUCKET=3tier-tfstate-$ACCOUNT_ID aws s3api create-bucket --bucket $BUCKET --region ap-south-1 \ --create-bucket-configuration LocationConstraint=ap-south-1 aws s3api put-bucket-versioning --bucket $BUCKET \ --versioning-configuration Status=Enabled aws s3api put-bucket-encryption --bucket $BUCKET \ --server-side-encryption-configuration \ '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'Recent Terraform versions (1.10 and later) lock state with a lock file in the same S3 bucket (use_lockfile = true), so you no longer need a DynamoDB table. Install Terraform 1.11 or newer.
| Folder | Purpose |
|---|---|
modules/* |
Reusable building blocks, one per layer |
environments/staging |
Calls the modules with small, cheap settings |
environments/production |
Calls the same modules with production settings |
Common MistakePutting both environments in one state file. Give each environment its own state
keyso a staging experiment can never plan changes against production.
Milestone 2: Build the VPC Module
This milestone creates the network: three subnet tiers per availability zone, an internet gateway, one NAT Gateway per zone, and route tables that decide what each tier can reach.
Each module declares its own variables.tf. The VPC module needs project, vpc_cidr, and availability_zones, and must output vpc_id, public_subnet_ids, private_app_subnet_ids, and private_db_subnet_ids.
modules/vpc/main.tf:
resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${var.project}-vpc" }} 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 = { Name = "${var.project}-public-${count.index}", Tier = "public" }} 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 = { Name = "${var.project}-app-${count.index}", Tier = "application" }} 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 = { Name = "${var.project}-db-${count.index}", Tier = "database" }} resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id} 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]} 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 }} 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 }} # Database tier: no default route at all, not even through NATresource "aws_route_table" "private_db" { vpc_id = aws_vpc.main.id} resource "aws_route_table_association" "public" { count = length(var.availability_zones) subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id} resource "aws_route_table_association" "private_app" { count = length(var.availability_zones) subnet_id = aws_subnet.private_app[count.index].id route_table_id = aws_route_table.private_app[count.index].id} resource "aws_route_table_association" "private_db" { count = length(var.availability_zones) subnet_id = aws_subnet.private_db[count.index].id route_table_id = aws_route_table.private_db.id}SecurityThe database route table has no routes on purpose. The database only answers requests from the application tier and never needs to reach the internet.
Milestone 3: Build the Security Groups Module
This milestone creates the firewall rules that enforce the tier boundaries: the internet reaches only the ALB, the ALB reaches only the app, and the app reaches only the database.
The three groups reference each other, so the rules are separate resources. Inline ingress and egress blocks that point at each other create a Terraform dependency cycle. The module takes project and vpc_id and outputs alb_sg_id, app_sg_id, and db_sg_id.
modules/security-groups/main.tf:
resource "aws_security_group" "alb" { name_prefix = "${var.project}-alb-" vpc_id = var.vpc_id} resource "aws_security_group" "app" { name_prefix = "${var.project}-app-" vpc_id = var.vpc_id} resource "aws_security_group" "db" { name_prefix = "${var.project}-db-" vpc_id = var.vpc_id} resource "aws_vpc_security_group_ingress_rule" "alb_https" { security_group_id = aws_security_group.alb.id cidr_ipv4 = "0.0.0.0/0" from_port = 443 to_port = 443 ip_protocol = "tcp"} 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_to_app" { security_group_id = aws_security_group.alb.id referenced_security_group_id = aws_security_group.app.id from_port = 8080 to_port = 8080 ip_protocol = "tcp"} resource "aws_vpc_security_group_ingress_rule" "app_from_alb" { security_group_id = aws_security_group.app.id referenced_security_group_id = aws_security_group.alb.id from_port = 8080 to_port = 8080 ip_protocol = "tcp"} # The app needs outbound access for ECR, Secrets Manager, and the databaseresource "aws_vpc_security_group_egress_rule" "app_all" { security_group_id = aws_security_group.app.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1"} resource "aws_vpc_security_group_ingress_rule" "db_from_app" { security_group_id = aws_security_group.db.id referenced_security_group_id = aws_security_group.app.id from_port = 3306 to_port = 3306 ip_protocol = "tcp"}Common MistakeUsing a CIDR range such as
10.0.0.0/16as a rule source. That admits everything in the VPC, including resources added later. Referencing a security group ID admits only resources that carry that group.
Milestone 4: Build the ALB Module
This milestone creates the public entry point: an Application Load Balancer that terminates HTTPS, redirects HTTP, and forwards to a target group that the Auto Scaling Group registers instances into.
The module takes project, vpc_id, public_subnet_ids, alb_security_group_id, and certificate_arn, and outputs target_group_arn and alb_dns_name. The certificate must be an ACM certificate in the same region, validated through DNS for a domain you own.
modules/alb/main.tf:
resource "aws_lb" "main" { name = "${var.project}-alb" load_balancer_type = "application" security_groups = [var.alb_security_group_id] subnets = var.public_subnet_ids drop_invalid_header_fields = true} resource "aws_lb_target_group" "app" { name = "${var.project}-app-tg" port = 8080 protocol = "HTTP" vpc_id = var.vpc_id health_check { path = "/health" matcher = "200" interval = 30 timeout = 10 healthy_threshold = 2 unhealthy_threshold = 3 }} 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 }} resource "aws_lb_listener" "http_redirect" { load_balancer_arn = aws_lb.main.arn port = 80 protocol = "HTTP" default_action { type = "redirect" redirect { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } }}TipNo domain for the lab? Swap the HTTPS listener for an HTTP listener on port 80 that forwards to the target group, and keep the HTTPS version for real use.
Milestone 5: Build the Auto Scaling Group Module
This milestone creates the application tier: an IAM role so instances can pull from ECR and read the database secret, a launch template that starts the container, and an Auto Scaling Group that replaces unhealthy instances and scales on CPU.
The module takes project, aws_region, instance_type, private_app_subnet_ids, app_security_group_id, target_group_arn, ecr_registry, app_image, db_endpoint, db_name, db_secret_arn, and the three size values. It outputs asg_name.
modules/asg/main.tf:
data "aws_ami" "al2023" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-2023.*-x86_64"] }} resource "aws_iam_role" "app" { name = "${var.project}-app-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "sts:AssumeRole" Principal = { Service = "ec2.amazonaws.com" } }] })} resource "aws_iam_role_policy_attachment" "ecr_read" { role = aws_iam_role.app.name policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"} # Session Manager replaces SSHresource "aws_iam_role_policy_attachment" "ssm" { role = aws_iam_role.app.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"} resource "aws_iam_role_policy" "read_db_secret" { name = "read-db-secret" role = aws_iam_role.app.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "secretsmanager:GetSecretValue" Resource = var.db_secret_arn }] })} resource "aws_iam_instance_profile" "app" { name = "${var.project}-app-profile" role = aws_iam_role.app.name} resource "aws_launch_template" "app" { name_prefix = "${var.project}-app-" image_id = data.aws_ami.al2023.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 = base64encode(<<-EOF #!/bin/bash dnf install -y docker jq systemctl enable --now docker aws ecr get-login-password --region ${var.aws_region} | \ docker login --username AWS --password-stdin ${var.ecr_registry} DB_PASSWORD=$(aws secretsmanager get-secret-value \ --secret-id ${var.db_secret_arn} --region ${var.aws_region} \ --query SecretString --output text | jq -r .password) 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} \ -e DB_PASSWORD="$DB_PASSWORD" \ ${var.ecr_registry}/${var.app_image}:latest EOF ) # IMDSv2 only, which blocks the classic SSRF credential theft path metadata_options { http_endpoint = "enabled" http_tokens = "required" http_put_response_hop_limit = 1 } lifecycle { create_before_destroy = true }} 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" health_check_grace_period = 120 min_size = var.min_size max_size = var.max_size desired_capacity = var.desired_capacity launch_template { id = aws_launch_template.app.id version = aws_launch_template.app.latest_version } instance_refresh { strategy = "Rolling" preferences { min_healthy_percentage = 50 } } tag { key = "Name" value = "${var.project}-app" propagate_at_launch = true }} resource "aws_autoscaling_policy" "cpu" { name = "${var.project}-cpu-target" autoscaling_group_name = aws_autoscaling_group.app.name policy_type = "TargetTrackingScaling" target_tracking_configuration { predefined_metric_specification { predefined_metric_type = "ASGAverageCPUUtilization" } target_value = 60 }}Health checks use ELB, so the ASG replaces an instance when the load balancer marks it unhealthy, not only when EC2 itself fails. A target-tracking policy keeps average CPU near 60 percent without hand-built alarms.
Common MistakeUsing
version = "$Latest"in the ASG. The value never changes in your Terraform, so a new launch template version does not trigger an instance refresh. Referencelatest_versioninstead.
Milestone 6: Build the RDS Module
This milestone creates the data tier: an encrypted, private MySQL instance with Multi-AZ failover, where AWS generates and stores the master password in Secrets Manager so it never appears in your code or state file.
The module takes project, private_db_subnet_ids, db_security_group_id, db_instance_class, db_name, db_username, multi_az, and protect_data. It outputs db_endpoint and db_secret_arn.
modules/rds/main.tf:
resource "aws_db_subnet_group" "main" { name = "${var.project}-db-subnets" subnet_ids = var.private_db_subnet_ids} 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 manage_master_user_password = true db_subnet_group_name = aws_db_subnet_group.main.name vpc_security_group_ids = [var.db_security_group_id] publicly_accessible = false multi_az = var.multi_az backup_retention_period = 7 deletion_protection = var.protect_data skip_final_snapshot = !var.protect_data final_snapshot_identifier = var.protect_data ? "${var.project}-final" : null performance_insights_enabled = true} output "db_endpoint" { value = aws_db_instance.main.address} output "db_secret_arn" { value = aws_db_instance.main.master_user_secret[0].secret_arn}Tip
protect_dataistruein production andfalsein staging. Production keeps deletion protection and a final snapshot; staging tears down cleanly. That is safer than a hardprevent_destroythat also blocks your lab cleanup.
Milestone 7: Wire the Production Environment
This milestone calls the five modules from one environment folder, passing outputs from one into inputs of the next so Terraform works out the creation order.
environments/production/main.tf:
terraform { required_version = ">= 1.11.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } backend "s3" { bucket = "3tier-tfstate-YOUR_ACCOUNT_ID" key = "production/terraform.tfstate" region = "ap-south-1" encrypt = true use_lockfile = true }} provider "aws" { region = "ap-south-1" default_tags { tags = { Project = var.project_name, Environment = "production", ManagedBy = "Terraform" } }} module "vpc" { source = "../../modules/vpc" project = var.project_name vpc_cidr = "10.0.0.0/16" availability_zones = ["ap-south-1a", "ap-south-1b"]} module "security_groups" { source = "../../modules/security-groups" project = var.project_name vpc_id = module.vpc.vpc_id} 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} 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 multi_az = true protect_data = true} 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 db_secret_arn = module.rds.db_secret_arn min_size = 2 max_size = 8 desired_capacity = 2} output "alb_dns_name" { value = module.alb.alb_dns_name} output "asg_name" { value = module.asg.asg_name} output "db_identifier" { value = "${var.project_name}-mysql"}Declare project_name, acm_certificate_arn, ecr_registry, app_image_name, db_name, and db_username in variables.tf, and set values in a terraform.tfvars that holds no secrets. Create the ECR repository and push a first image before the apply so instances have something to pull.
cd environments/productionterraform initterraform plan -out=tfplanterraform apply tfplanRead the plan carefully, especially the security groups and the RDS settings. Expect 15 to 20 minutes, mostly for Multi-AZ RDS.
For staging, copy the folder, change the state key, use multi_az = false, protect_data = false, and a smaller desired_capacity. The module code stays identical, which is the point of modules.
Milestone 8: Deploy with GitHub Actions
This milestone adds a pipeline that tests, builds, scans, and pushes an image, then replaces the instances one batch at a time. It authenticates to AWS with OIDC, so no long-lived access keys sit in GitHub.
Create an IAM role for GitHub with a trust policy limited to your repository and a permissions policy limited to ECR push, autoscaling:StartInstanceRefresh, and autoscaling:DescribeInstanceRefreshes. Store the role ARN and ASG name as repository variables (AWS_DEPLOY_ROLE_ARN, ASG_NAME).
.github/workflows/deploy.yml:
name: Build and Deploy to AWS on: push: branches: [main] permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run tests run: | npm ci npm test - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: ap-south-1 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Build image env: REGISTRY: ${{ steps.login-ecr.outputs.registry }} run: | docker build -t $REGISTRY/my-app:${{ github.sha }} -t $REGISTRY/my-app:latest . - name: Scan image with Trivy uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }} severity: CRITICAL,HIGH exit-code: 1 - name: Push image env: REGISTRY: ${{ steps.login-ecr.outputs.registry }} run: | docker push $REGISTRY/my-app:${{ github.sha }} docker push $REGISTRY/my-app:latest - name: Roll out with instance refresh env: ASG_NAME: ${{ vars.ASG_NAME }} run: | REFRESH_ID=$(aws autoscaling start-instance-refresh \ --auto-scaling-group-name "$ASG_NAME" \ --preferences '{"MinHealthyPercentage":50,"InstanceWarmup":120}' \ --query InstanceRefreshId --output text) while true; do STATUS=$(aws autoscaling describe-instance-refreshes \ --auto-scaling-group-name "$ASG_NAME" \ --instance-refresh-ids "$REFRESH_ID" \ --query 'InstanceRefreshes[0].Status' --output text) echo "Refresh status: $STATUS" case "$STATUS" in Successful) exit 0 ;; Failed|Cancelled) exit 1 ;; esac sleep 30 doneThe AWS CLI has no waiter for instance refreshes, so the last step polls the status itself and fails the job if the refresh fails. With MinHealthyPercentage at 50, at least half the fleet keeps serving while the other half is replaced.
Common MistakePinning third-party actions to
@master. A moving branch can change under you between runs. Pin to a released version tag, or a commit SHA for stricter control.
Validation & Testing
Run these checks after the apply and after your first pipeline run.
cd environments/production # 1. Outputs existterraform output # 2. The ALB responds (301 from HTTP, 200 from HTTPS)ALB=$(terraform output -raw alb_dns_name)curl -I http://$ALBcurl -kI https://$ALB # 3. Every ASG instance is healthy and in serviceaws autoscaling describe-auto-scaling-groups \ --auto-scaling-group-names $(terraform output -raw asg_name) \ --query 'AutoScalingGroups[0].Instances[*].[InstanceId,HealthStatus,LifecycleState]' \ --output table # 4. RDS is Multi-AZ, private and encryptedaws rds describe-db-instances \ --db-instance-identifier $(terraform output -raw db_identifier) \ --query 'DBInstances[0].[DBInstanceStatus,MultiAZ,PubliclyAccessible,StorageEncrypted]' \ --output table # 5. No drift between code and AWSterraform planExpected results: instances show Healthy and InService; RDS shows available, True, False, True; the final plan says no changes.
Test autoscaling by opening a Session Manager shell on one instance and generating CPU load, then watch the ASG add capacity:
aws ssm start-session --target INSTANCE_ID# on the instance: run a CPU stress tool for a few minutes aws autoscaling describe-scaling-activities \ --auto-scaling-group-name $(terraform output -raw asg_name) \ --max-items 5Measure the deployment instead of assuming it is zero-downtime. In one terminal, request the app every second and log any failure. In another, push a commit to trigger the pipeline.
while true; do CODE=$(curl -ks -o /dev/null -w "%{http_code}" https://$ALB/health) echo "$(date +%T) $CODE" sleep 1doneCount the non-200 lines during the refresh and record the number in your project README. A result near zero is the outcome to aim for, and your own measurement is the evidence to show in an interview.
Common MistakeRunning
terraform destroyon production settings and being blocked bydeletion_protection. That is intentional. To remove the stack, setprotect_data = false, apply, then destroy.