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
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.
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.
mkdir -p aws-3tier/{ modules/vpc, modules/alb, modules/asg, modules/rds, modules/security-groups, environments/production, environments/staging}cd aws-3tierCreate the S3 backend:
## Create state bucketaws 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 bucketaws 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 tableaws dynamodb create-table \ --table-name 3tier-terraform-lock \ --attribute-definitions AttributeName=LockID,AttributeType=S \ --key-schema AttributeName=LockID,KeyType=HASH \ --billing-mode PAY_PER_REQUESTStep 2: Build the VPC Module
Create modules/vpc/main.tf:
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 hereresource "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 hereresource "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 hereresource "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 availabilityresource "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 tablesresource "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}" })}SecurityDatabase subnets should have no route to the internet — not even through a NAT Gateway. Create a
private_dbroute 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:
## ALB Security Group — accepts HTTPS from internetresource "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 ALBresource "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 tierresource "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:
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:
## Launch template — defines what each EC2 instance looks likeresource "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:
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 }}RememberStore the database password in AWS Secrets Manager, not in a
.tfvarsfile. Create the secret manually, then reference it in Terraform with adata "aws_secretsmanager_secret_version"data source. Never commit database credentials to Git.
Step 6: Wire the Production Environment
Create environments/production/main.tf:
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}## Deploy the full stackcd environments/productionterraform initterraform plan -out=tfplan # Review carefully — especially security groups and RDS settingsterraform 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:
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 }}"TipThe
instance-refreshcommand tells the ASG to gradually replace all running instances with new ones that will pull the updated Docker image on startup. Combined withMinHealthyPercentage: 50, this ensures at least half the instances stay healthy at all times during the update — zero-downtime deployment without Kubernetes.
Validation & Testing
## 1. Verify Terraform outputsterraform output## Expected: alb_dns_name, rds_endpoint, asg_name ## 2. Check ALB is healthyALB_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 healthyaws 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 Actionsgit 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 driftterraform plan## Expected: No changes. Your infrastructure matches your configuration.Common MistakeThe RDS instance taking
prevent_destroy = truewill blockterraform destroyin the future. When you want to tear down the environment, you must first remove theprevent_destroylifecycle block, runterraform apply, and then runterraform destroy. This is intentional — it prevents accidental database deletion in production.
Videos & Guides
AWS 3-Tier Architecture with Terraform — Full Tutorial
Complete walkthrough of building AWS 3-tier architecture with Terraform including VPC, ALB, Auto Scaling Groups, and RDS from scratch.
AWS Auto Scaling Group Documentation
Official AWS ASG documentation covering launch templates, scaling policies, instance refresh for zero-downtime deployments, and health checks.
HashiCorp Terraform Getting Started Guide
Official HashiCorp getting started tutorial for Terraform with AWS — covers installation, first configuration, plan and apply workflow, variables, and outputs.