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 | | | +-----------------------+ | | | +------------------------------------------------------------------+
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 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.
```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.
In this project you will build the most common and battle-tested cloud architecture in production: a 3-tier application ...
Most teams start by manually creating AWS resources through the console. This works for one person but breaks immediatel...
Step 1: Terraform Project Structure and Backend Organise the Terraform project with a module-based structure that separa...
> 🔴 Common Mistake: The RDS instance taking preventdestroy = true will block terraform destroy in the future. When you ...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.