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.
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.
#!/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.
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.
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.
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 ;;
esacStep 4 — Testing & Verification
# 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 teardownWarning: 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.