100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Linux & Shell Scripting
55 minbeginner

Practice — Deploy a 3-Tier Architecture with ALB, EC2 ASG and RDS

What You'll Build

You will use AWS CLI and Bash scripting to deploy a complete three-tier cricket analytics platform: a public-facing Application Load Balancer, a private Auto Scaling Group of EC2 instances running the API, and a private RDS PostgreSQL database. The deployment script validates prerequisites, creates all resources in the correct dependency order, waits for each resource to become available before proceeding, outputs connection details and health check results, and provides a teardown function to clean up all resources. By the end, you will have a fully automated, idempotent deployment script that represents how real production infrastructure is provisioned.

Analogy🏏Cricket
🏏 Think of it like cricket: This script is like the pre-match ground inspection conducted by the match referee, pitch curator, and captains before a Test match begins. Before Rohit Sharma and the opposition captain walk out for the toss, the curator has measured pitch moisture, taken grass length readings, and documented the surface condition — creating a baseline against which any afternoon deterioration can be measured.Just as this structured inspection prevents surprises and creates a documented record, the inventory script creates a documented baseline for a server against which future anomalies can be compared. Just as a ground inspection without a checklist might miss a drainage issue that affects the afternoon session, a server assessment without a structured script might miss a nearly-full disk that causes a midnight deployment failure.The insight is that the value of a structured inspection is not just the current findings but the reproducible method — the same script run tomorrow highlights exactly what changed.

Prerequisites

  • VPC from M3 Practice exercise, or a new VPC with public and private subnets in 3 AZs
  • AWS CLI configured with permissions for EC2, RDS, ELB, IAM and Auto Scaling
  • A key pair for SSH access — aws ec2 create-key-pair --key-name cricket-lab --query KeyMaterial --output text > cricket-lab.pem
  • Completed M4 Lessons 1-5: IAM, EC2, S3, VPC and RDS/DynamoDB
  • jq installed for JSON processing in the deployment script

Setup — Script Structure and Configuration

The deployment script is structured as a collection of functions — one per resource type — called in dependency order by a main() function. This modular structure makes each component independently testable and allows partial re-runs when a specific resource creation fails. A state file tracks which resources have been created, enabling idempotent re-execution.

bash
#!/bin/bash
# deploy_cricket_platform.sh — 3-tier architecture deployment
set -euo pipefail

# ── Configuration ─────────────────────────────────────────────────────────────
export AWS_REGION='ap-south-1'
STACK_NAME='cricket-analytics'
VPC_ID=${VPC_ID:?'Set VPC_ID'}
PUBLIC_SUBNETS=${PUBLIC_SUBNETS:?'Set PUBLIC_SUBNETS (comma-separated subnet IDs)'}
PRIVATE_SUBNETS=${PRIVATE_SUBNETS:?'Set PRIVATE_SUBNETS (comma-separated subnet IDs)'}
DATA_SUBNETS=${DATA_SUBNETS:?'Set DATA_SUBNETS (comma-separated subnet IDs)'}
KEY_PAIR=${KEY_PAIR:-'cricket-lab'}
INSTANCE_TYPE=${INSTANCE_TYPE:-'t3.micro'}
AMI_ID=${AMI_ID:-$(aws ec2 describe-images \
    --owners amazon \
    --filters 'Name=name,Values=al2023-ami-*-x86_64' \
              'Name=state,Values=available' \
    --query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' \
    --output text)}

# ── State tracking ────────────────────────────────────────────────────────────
STATE_FILE="${STACK_NAME}.state"
state_set() { echo "$1=$2" >> "$STATE_FILE"; }
state_get() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2-; }

# ── Logging ───────────────────────────────────────────────────────────────────
log() { printf '[%s] %s\n' "$(date -Iseconds)" "$*" | tee -a deploy.log; }

log '=== Cricket Analytics Platform Deployment ==='
log "Region: ${AWS_REGION}  VPC: ${VPC_ID}  AMI: ${AMI_ID}"

Step 1 — Security Groups and IAM Role

Create security groups in the correct reference order — the ALB security group first (since the application security group references it), then the application security group, then the database security group. Create the EC2 instance profile (IAM role for EC2) that allows the application instances to read from SSM Parameter Store for configuration and write to CloudWatch Logs.

Analogy🏏Cricket
🏏 Think of it like cricket: The security group creation order is like the BCCI accreditation hierarchy — the main gate pass (ALB SG) must be defined before the dressing room key can reference it as a prerequisite (app SG references ALB SG), and the match room lock must reference the dressing room key as its prerequisite (DB SG references app SG). Creating these in the wrong order fails because you cannot reference a security group that does not exist yet.
bash
create_security_groups() {
    log 'Creating security groups...'

    # ALB Security Group
    SG_ALB=$(aws ec2 create-security-group \
        --vpc-id "$VPC_ID" \
        --group-name "${STACK_NAME}-alb-sg" \
        --description 'Cricket ALB: HTTPS/HTTP from internet' \
        --query 'GroupId' --output text)
    aws ec2 authorize-security-group-ingress --group-id "$SG_ALB" \
        --ip-permissions \
        'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]' \
        'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}]'
    state_set SG_ALB "$SG_ALB"

    # Application Security Group (references ALB SG)
    SG_APP=$(aws ec2 create-security-group \
        --vpc-id "$VPC_ID" \
        --group-name "${STACK_NAME}-app-sg" \
        --description 'Cricket App: port 8080 from ALB only' \
        --query 'GroupId' --output text)
    aws ec2 authorize-security-group-ingress --group-id "$SG_APP" \
        --ip-permissions \
        "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=${SG_ALB}}]"
    state_set SG_APP "$SG_APP"

    # Database Security Group (references App SG)
    SG_DB=$(aws ec2 create-security-group \
        --vpc-id "$VPC_ID" \
        --group-name "${STACK_NAME}-db-sg" \
        --description 'Cricket DB: PostgreSQL from app only' \
        --query 'GroupId' --output text)
    aws ec2 authorize-security-group-ingress --group-id "$SG_DB" \
        --ip-permissions \
        "IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=${SG_APP}}]"
    state_set SG_DB "$SG_DB"

    log "SGs created: ALB=${SG_ALB}  App=${SG_APP}  DB=${SG_DB}"
}

create_instance_role() {
    log 'Creating EC2 instance role...'
    # Trust policy
    aws iam create-role \
        --role-name "${STACK_NAME}-ec2-role" \
        --assume-role-policy-document '{"Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
        --query 'Role.RoleName' --output text
    # Attach managed policies
    for policy in \
        'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore' \
        'arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy'; do
        aws iam attach-role-policy --role-name "${STACK_NAME}-ec2-role" --policy-arn "$policy"
    done
    # Create instance profile
    aws iam create-instance-profile --instance-profile-name "${STACK_NAME}-instance-profile"
    aws iam add-role-to-instance-profile \
        --instance-profile-name "${STACK_NAME}-instance-profile" \
        --role-name "${STACK_NAME}-ec2-role"
    state_set INSTANCE_PROFILE "${STACK_NAME}-instance-profile"
    log 'Instance role and profile created'
}

Step 2 — RDS, ALB, Launch Template and Auto Scaling Group

Create the RDS instance and ALB in parallel (they have no dependency on each other), then create the Launch Template and Auto Scaling Group once the ALB target group is available. The deployment waits for RDS to become available before registering the database endpoint in SSM Parameter Store for the application instances to retrieve.

Analogy🏏Cricket
🏏 Think of it like cricket: The RDS and ALB are provisioned in parallel — like the pitch preparation team and the media setup crew working simultaneously before the match. Both must complete before the match can start (before instances launch), but they do not depend on each other. The deployment script waits for both to be ready before registering the application instances (equivalent to calling the playing squad to the ground only after both the pitch and the broadcast systems are operational).
bash
create_rds() {
    log 'Creating RDS subnet group and database...'
    # Subnet group
    aws rds create-db-subnet-group \
        --db-subnet-group-name "${STACK_NAME}-db-subnet-group" \
        --db-subnet-group-description 'Cricket DB subnets' \
        --subnet-ids $(echo "$DATA_SUBNETS" | tr ',' ' ')

    # RDS instance (Single-AZ for lab, Multi-AZ for production)
    DB_ID=$(aws rds create-db-instance \
        --db-instance-identifier "${STACK_NAME}-db" \
        --db-instance-class db.t3.micro \
        --engine postgres --engine-version '16.1' \
        --master-username cricket_admin \
        --master-user-password "CricketDB$(date +%Y)!" \
        --allocated-storage 20 --storage-type gp3 \
        --no-multi-az \
        --vpc-security-group-ids "$SG_DB" \
        --db-subnet-group-name "${STACK_NAME}-db-subnet-group" \
        --backup-retention-period 1 \
        --no-deletion-protection \
        --query 'DBInstance.DBInstanceIdentifier' --output text)
    state_set DB_ID "$DB_ID"
    log "RDS creating: ${DB_ID} (this takes ~5 minutes)"
}

create_alb() {
    log 'Creating ALB and target group...'
    ALB_ARN=$(aws elbv2 create-load-balancer \
        --name "${STACK_NAME}-alb" \
        --subnets $(echo "$PUBLIC_SUBNETS" | tr ',' ' ') \
        --security-groups "$SG_ALB" \
        --query 'LoadBalancers[0].LoadBalancerArn' --output text)

    TG_ARN=$(aws elbv2 create-target-group \
        --name "${STACK_NAME}-tg" \
        --protocol HTTP --port 8080 \
        --vpc-id "$VPC_ID" \
        --health-check-path '/health' \
        --health-check-interval-seconds 30 \
        --healthy-threshold-count 2 \
        --unhealthy-threshold-count 3 \
        --query 'TargetGroups[0].TargetGroupArn' --output text)

    # HTTP listener (redirect to HTTPS in production)
    aws elbv2 create-listener \
        --load-balancer-arn "$ALB_ARN" \
        --protocol HTTP --port 80 \
        --default-actions "Type=forward,TargetGroupArn=${TG_ARN}"

    state_set ALB_ARN "$ALB_ARN"
    state_set TG_ARN "$TG_ARN"
    log "ALB created: ${ALB_ARN##*/}"
}

create_asg() {
    log 'Waiting for RDS to be available...'
    aws rds wait db-instance-available --db-instance-identifier "$DB_ID"
    DB_ENDPOINT=$(aws rds describe-db-instances \
        --db-instance-identifier "$DB_ID" \
        --query 'DBInstances[0].Endpoint.Address' --output text)
    aws ssm put-parameter --name "/${STACK_NAME}/db-endpoint" \
        --value "$DB_ENDPOINT" --type String --overwrite
    log "RDS available: ${DB_ENDPOINT}"

    # Launch Template with user data
    USER_DATA=$(base64 -w0 << 'UD'
#!/bin/bash
yum update -y
yum install -y python3-pip
pip3 install flask psycopg2-binary
curl -s http://localhost/health || true
UD
    )
    LT_ID=$(aws ec2 create-launch-template \
        --launch-template-name "${STACK_NAME}-lt" \
        --launch-template-data \
            "{\"ImageId\":\"${AMI_ID}\",\"InstanceType\":\"${INSTANCE_TYPE}\",\
              \"KeyName\":\"${KEY_PAIR}\",\"SecurityGroupIds\":[\"${SG_APP}\"],\
              \"IamInstanceProfile\":{\"Name\":\"${STACK_NAME}-instance-profile\"},\
              \"UserData\":\"${USER_DATA}\",\
              \"MetadataOptions\":{\"HttpTokens\":\"required\",\"HttpEndpoint\":\"enabled\"}}" \
        --query 'LaunchTemplate.LaunchTemplateId' --output text)

    # Auto Scaling Group
    aws autoscaling create-auto-scaling-group \
        --auto-scaling-group-name "${STACK_NAME}-asg" \
        --launch-template "LaunchTemplateId=${LT_ID},Version=\$Latest" \
        --min-size 1 --desired-capacity 2 --max-size 6 \
        --vpc-zone-identifier "$PRIVATE_SUBNETS" \
        --target-group-arns "$TG_ARN" \
        --health-check-type ELB --health-check-grace-period 120
    state_set ASG_NAME "${STACK_NAME}-asg"
    log 'ASG created — instances launching'
}

Step 3 — Main Orchestration, Verification and Teardown

The main() function calls each creation function in dependency order and verifies the final deployment by querying the ALB DNS name. The teardown() function deletes all resources in reverse dependency order — ASG first, then ALB, then RDS, then security groups and IAM roles.

bash
verify_deployment() {
    log 'Verifying deployment...'
    ALB_DNS=$(aws elbv2 describe-load-balancers \
        --load-balancer-arns "$ALB_ARN" \
        --query 'LoadBalancers[0].DNSName' --output text)

    log "ALB DNS: ${ALB_DNS}"
    log 'Waiting for instances to pass health checks (up to 5 minutes)...'
    sleep 120  # Give instances time to launch and register

    HEALTHY=$(aws elbv2 describe-target-health \
        --target-group-arn "$TG_ARN" \
        --query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
        --output text)
    log "Healthy targets: ${HEALTHY}"

    echo
    echo '=== Deployment Complete ==='
    echo "ALB URL:      http://${ALB_DNS}/"
    echo "DB Endpoint:  ${DB_ENDPOINT}"
    echo "State file:   ${STATE_FILE}"
    echo 'Run teardown to remove all resources'
}

teardown() {
    log 'TEARDOWN: removing all resources...'
    ASG_NAME=$(state_get ASG_NAME)
    TG_ARN=$(state_get TG_ARN)
    ALB_ARN=$(state_get ALB_ARN)
    DB_ID=$(state_get DB_ID)
    SG_ALB=$(state_get SG_ALB) SG_APP=$(state_get SG_APP) SG_DB=$(state_get SG_DB)

    [[ -n "$ASG_NAME" ]] && aws autoscaling delete-auto-scaling-group \
        --auto-scaling-group-name "$ASG_NAME" --force-delete || true
    sleep 30  # Wait for instances to terminate
    [[ -n "$ALB_ARN" ]] && aws elbv2 delete-load-balancer --load-balancer-arn "$ALB_ARN" || true
    [[ -n "$TG_ARN" ]] && aws elbv2 delete-target-group --target-group-arn "$TG_ARN" || true
    [[ -n "$DB_ID" ]] && aws rds delete-db-instance \
        --db-instance-identifier "$DB_ID" --skip-final-snapshot || true
    sleep 30
    for sg in "$SG_DB" "$SG_APP" "$SG_ALB"; do
        [[ -n "$sg" ]] && aws ec2 delete-security-group --group-id "$sg" || true
    done
    rm -f "$STATE_FILE"
    log 'Teardown complete'
}

main() {
    create_security_groups
    create_instance_role
    create_rds &   # parallel: RDS takes 5 minutes
    create_alb     # parallel: ALB takes 30 seconds
    wait           # wait for both background jobs
    create_asg
    verify_deployment
}

case "${1:-deploy}" in
    deploy)   main ;;
    teardown) teardown ;;
    *) echo "Usage: $0 [deploy|teardown]"; exit 1 ;;
esac

Step 4 — Testing & Verification

bash
# Run deployment (requires VPC_ID, PUBLIC_SUBNETS, PRIVATE_SUBNETS, DATA_SUBNETS set)
bash deploy_cricket_platform.sh deploy

# Verify ALB health
ALB_DNS=$(grep ALB_URL deploy.log | tail -1 | awk '{print $2}' | sed 's|http://||;s|/||')
for i in $(seq 1 5); do
    echo -n "Request $i: "
    curl -sf -o /dev/null -w 'HTTP %{http_code} in %{time_total}s\n' \
        "http://${ALB_DNS}/health" 2>/dev/null || echo 'Connection failed (instances may still be starting)'
done

# Check ASG instance health
aws autoscaling describe-auto-scaling-groups \
    --auto-scaling-group-names cricket-analytics-asg \
    --query 'AutoScalingGroups[0].Instances[*].{ID:InstanceId,State:LifecycleState,Health:HealthStatus}' \
    --output table

# Clean up after lab
bash deploy_cricket_platform.sh teardown

Warning: This deployment creates billable AWS resources — ALB ($0.008/hour), RDS db.t3.micro ($0.016/hour), EC2 t3.micro instances ($0.0104/hour each) and NAT Gateways ($0.045/hour each if in the VPC). Run the teardown immediately after completing the exercise to avoid unexpected charges. Set a CloudWatch billing alarm at $5 to alert on unexpected resource costs from this or any other lab exercise.

Extension Challenge: Extend the deployment script with three production enhancements: (1) Add Aurora PostgreSQL Serverless v2 instead of standard RDS — it scales to zero when idle (eliminating the database cost between lab runs) and scales to handle peak load automatically; (2) Add a Secrets Manager secret for the database password instead of hardcoding it in the script, and configure the EC2 instances to retrieve it via the IAM role; (3) Add a CloudWatch alarm that triggers SNS notification when ALB target healthy host count drops below 1 — this provides alerting for the scenario where all instances fail health checks simultaneously.

  • Create security groups in reference order — the referenced group (ALB SG) must exist before the referencing group (App SG) can be created with the reference.
  • Create RDS and ALB in parallel (background & with wait) since they have no dependency on each other — this cuts total deployment time from ~10 minutes to ~5 minutes.
  • Use IMDSv2 (HttpTokens=required) in the Launch Template — it prevents SSRF attacks from accessing instance credentials and is a CIS security benchmark requirement.
  • Store database endpoints in SSM Parameter Store rather than user data or environment variables — instances retrieve them at startup using the IAM role, keeping configuration separate from code.
  • Always implement a teardown function alongside the deploy function — resources left running after practice exercises are the most common source of unexpected AWS bills.
  • State files that track created resource IDs enable partial re-runs when individual resource creation fails — idempotent deployment scripts are essential for production use.
Lesson 27 of 40
0% complete