AWS CLI First #10: Layer 7 Traffic Distribution: Application Load Balancers, Target Groups & SSL Routing
Deploy internet-facing Multi-AZ Application Load Balancers (ALB), configure Target Groups with custom HTTP health checks, path-based routing rules, and security group chaining purely from the command line.
AWS with CLI: The Terminal-First Mastery Path
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
In enterprise production architectures, never expose backend compute instances directly to incoming internet traffic. Instead, resilient systems place an Elastic Load Balancer (ELB) at the ingress boundary to distribute client traffic across multiple Availability Zones, automatically isolate unhealthy instances, and dynamically route traffic to specialized microservices based on HTTP request headers and URL paths.
In Part 10 of our series, we eliminate the AWS Management Console to construct an enterprise-grade Layer 7 Application Load Balancer (ALB) architecture entirely through the AWS CLI. You will configure Multi-AZ target groups, customize active HTTP health checking parameters, implement Security Group Chaining to lock down backend nodes, and deploy Path-Based Routing to split frontend and API traffic across isolated compute clusters.
π° Estimated Lab Cost & Resource Architecture
1. Cost Breakdown for this Lab
All resources in this lab are provisioned on-demand in the Asia Pacific (Singapore) ap-southeast-1 region and can be torn down immediately after completing the exercises:
| Resource | Quantity | Unit Cost (Hourly) | Estimated Lab Cost (1 Hour) |
|---|---|---|---|
| Application Load Balancer (ALB) | 1 Load Balancer | $0.0225 / hour + $0.008 / LCU-hr | ~$0.025 |
EC2 Graviton (t4g.micro) | 2 Nodes | $0.0084 / hour per instance | ~$0.017 |
| Public IPv4 Addresses (AWS 2024 Rule) | 2 Attached Public IPs | $0.005 / hour per IPv4 | ~$0.010 |
| Public VPC & Subnets (2 AZs) | 1 VPC (2 Subnets) | $0.00 / hour (Free) | $0.000 |
| Target Groups & Listeners | 2 Target Groups | $0.00 / hour (Free) | $0.000 |
| Total Estimated Lab Cost | ~$0.052 USD |
[!NOTE] AWS Public IPv4 Pricing Rule (Effective Feb 2024): AWS charges $0.005/hour per public IPv4 address, whether attached to an EC2 instance, NAT Gateway, or load balancer (AWS Free Tier includes 750 free hours/month for the first 12 months). In production, placing backend compute nodes in private subnets behind an ALB eliminates these public IPv4 charges entirely!
2. Architecture Diagram
+----------------------------------------------------------------+
| Multi-AZ Application Load Balancer Architecture |
+----------------------------------------------------------------+
[ Client Requests (Internet) ]
|
v
[ Route 53 / Public DNS ]
|
v (HTTP:80 / HTTPS:443)
+-----------------------------------+
| ALB-Public-SG (0.0.0.0/0:80) |
+-----------------------------------+
| Application Load Balancer (ALB) |
| * Ingress across 2 Public AZs |
+-----------------------------------+
|
+---------------+---------------+
| |
(Path: /* - Default) (Path: /api* - API)
v v
+-------------------------------+ +-------------------------------+
| Default-App-TG | | API-Microservice-TG |
| Health: HTTP GET / | | Health: /api/status |
+-------------------------------+ +-------------------------------+
| |
(Port 80: ALB-SG Only) (Port 80: ALB-SG Only)
v v
+-------------------------------+ +-------------------------------+
| App-Tier-SG | | App-Tier-SG |
+-------------------------------+ +-------------------------------+
| Node-A (Public-Subnet-A) | | Node-B (Public-Subnet-B) |
| AZ: ap-southeast-1a | | AZ: ap-southeast-1b |
| "Frontend Web UI (Node A)" | | "Microservice API (Node B)" |
+-------------------------------+ +-------------------------------+
Prerequisites: Base Compute Fleet Setup
To test load balancing and path-based routing, you need a VPC with two public subnets in different Availability Zones and two backend EC2 instances serving distinct web pages.
Option A: If your VPC and Compute Nodes are already running
If reconnecting from a new terminal session or continuing with an existing fleet, re-export all required resource IDs dynamically using tag filters:
REGION="ap-southeast-1"
PROFILE="cloud-dev-admin"
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=Production-VPC" --region "$REGION" --profile "$PROFILE" --query "Vpcs[0].VpcId" --output text)
PUB_SUB_A=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Subnet-A" --region "$REGION" --profile "$PROFILE" --query "Subnets[0].SubnetId" --output text)
PUB_SUB_B=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Subnet-B" --region "$REGION" --profile "$PROFILE" --query "Subnets[0].SubnetId" --output text)
ALB_SG_ID=$(aws ec2 describe-security-groups --filters "Name=vpc-id,Values=$VPC_ID" "Name=group-name,Values=ALB-Public-SG" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text)
APP_SG_ID=$(aws ec2 describe-security-groups --filters "Name=vpc-id,Values=$VPC_ID" "Name=group-name,Values=App-Tier-SG" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text)
NODE_A_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=Production-App-Node-A" "Name=instance-state-name,Values=running,pending" --region "$REGION" --profile "$PROFILE" --query "Reservations[0].Instances[0].InstanceId" --output text)
NODE_B_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=Production-App-Node-B" "Name=instance-state-name,Values=running,pending" --region "$REGION" --profile "$PROFILE" --query "Reservations[0].Instances[0].InstanceId" --output text)
Option B: Fast-Track 1-Click Multi-AZ Fleet Rebuild
If starting fresh, run this idempotent bootstrap script to build the complete networking foundation, security groups with chaining, and 2 sample web servers in under 90 seconds:
cat <<'EOF' > setup-alb-lab.sh
#!/usr/bin/env bash
set -euo pipefail
REGION="ap-southeast-1"
PROFILE="cloud-dev-admin"
echo "=== 1. Setting up Multi-AZ Base Network ==="
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=Production-VPC" --region "$REGION" --profile "$PROFILE" --query "Vpcs[0].VpcId" --output text 2>/dev/null || true)
if [ -z "$VPC_ID" ] || [ "$VPC_ID" = "None" ]; then
VPC_ID=$(aws ec2 create-vpc --cidr-block "10.0.0.0/16" --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=Production-VPC}]' --region "$REGION" --profile "$PROFILE" --query "Vpc.VpcId" --output text)
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-support '{"Value": true}' --region "$REGION" --profile "$PROFILE"
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-hostnames '{"Value": true}' --region "$REGION" --profile "$PROFILE"
fi
IGW_ID=$(aws ec2 describe-internet-gateways --filters "Name=attachment.vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "InternetGateways[0].InternetGatewayId" --output text 2>/dev/null || true)
if [ -z "$IGW_ID" ] || [ "$IGW_ID" = "None" ]; then
IGW_ID=$(aws ec2 create-internet-gateway --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=Production-IGW}]' --region "$REGION" --profile "$PROFILE" --query "InternetGateway.InternetGatewayId" --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" --region "$REGION" --profile "$PROFILE"
fi
PUB_RTB_ID=$(aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Route-Table" --region "$REGION" --profile "$PROFILE" --query "RouteTables[0].RouteTableId" --output text 2>/dev/null || true)
if [ -z "$PUB_RTB_ID" ] || [ "$PUB_RTB_ID" = "None" ]; then
PUB_RTB_ID=$(aws ec2 create-route-table --vpc-id "$VPC_ID" --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=Public-Route-Table}]' --region "$REGION" --profile "$PROFILE" --query "RouteTable.RouteTableId" --output text)
aws ec2 create-route --route-table-id "$PUB_RTB_ID" --destination-cidr-block "0.0.0.0/0" --gateway-id "$IGW_ID" --region "$REGION" --profile "$PROFILE"
fi
PUB_SUB_A=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Subnet-A" --region "$REGION" --profile "$PROFILE" --query "Subnets[0].SubnetId" --output text 2>/dev/null || true)
if [ -z "$PUB_SUB_A" ] || [ "$PUB_SUB_A" = "None" ]; then
PUB_SUB_A=$(aws ec2 create-subnet --vpc-id "$VPC_ID" --cidr-block "10.0.1.0/24" --availability-zone "${REGION}a" --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Public-Subnet-A}]' --region "$REGION" --profile "$PROFILE" --query "Subnet.SubnetId" --output text)
aws ec2 modify-subnet-attribute --subnet-id "$PUB_SUB_A" --map-public-ip-on-launch --region "$REGION" --profile "$PROFILE"
aws ec2 associate-route-table --route-table-id "$PUB_RTB_ID" --subnet-id "$PUB_SUB_A" --region "$REGION" --profile "$PROFILE"
fi
PUB_SUB_B=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Subnet-B" --region "$REGION" --profile "$PROFILE" --query "Subnets[0].SubnetId" --output text 2>/dev/null || true)
if [ -z "$PUB_SUB_B" ] || [ "$PUB_SUB_B" = "None" ]; then
PUB_SUB_B=$(aws ec2 create-subnet --vpc-id "$VPC_ID" --cidr-block "10.0.2.0/24" --availability-zone "${REGION}b" --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Public-Subnet-B}]' --region "$REGION" --profile "$PROFILE" --query "Subnet.SubnetId" --output text)
aws ec2 modify-subnet-attribute --subnet-id "$PUB_SUB_B" --map-public-ip-on-launch --region "$REGION" --profile "$PROFILE"
aws ec2 associate-route-table --route-table-id "$PUB_RTB_ID" --subnet-id "$PUB_SUB_B" --region "$REGION" --profile "$PROFILE"
fi
echo "=== 2. Creating Chained Security Groups ==="
ALB_SG_ID=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=ALB-Public-SG" "Name=vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true)
if [ -z "$ALB_SG_ID" ] || [ "$ALB_SG_ID" = "None" ]; then
ALB_SG_ID=$(aws ec2 create-security-group --group-name "ALB-Public-SG" --description "Public ingress for Application Load Balancer" --vpc-id "$VPC_ID" --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=ALB-Public-SG}]' --region "$REGION" --profile "$PROFILE" --query "GroupId" --output text)
aws ec2 authorize-security-group-ingress --group-id "$ALB_SG_ID" --protocol tcp --port 80 --cidr "0.0.0.0/0" --region "$REGION" --profile "$PROFILE"
fi
APP_SG_ID=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=App-Tier-SG" "Name=vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true)
if [ -z "$APP_SG_ID" ] || [ "$APP_SG_ID" = "None" ]; then
APP_SG_ID=$(aws ec2 create-security-group --group-name "App-Tier-SG" --description "Inbound HTTP restricted strictly to ALB Security Group" --vpc-id "$VPC_ID" --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=App-Tier-SG}]' --region "$REGION" --profile "$PROFILE" --query "GroupId" --output text)
aws ec2 authorize-security-group-ingress --group-id "$APP_SG_ID" --protocol tcp --port 80 --source-group "$ALB_SG_ID" --region "$REGION" --profile "$PROFILE"
fi
echo "=== 3. Setting up IAM Role for Nodes ==="
aws iam get-role --role-name "SSMFleetRole" --profile "$PROFILE" >/dev/null 2>&1 || \
aws iam create-role --role-name "SSMFleetRole" --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' --region "$REGION" --profile "$PROFILE"
aws iam attach-role-policy --role-name "SSMFleetRole" --policy-arn "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" --region "$REGION" --profile "$PROFILE"
if ! aws iam get-instance-profile --instance-profile-name "SSMFleetProfile" --profile "$PROFILE" >/dev/null 2>&1; then
aws iam create-instance-profile --instance-profile-name "SSMFleetProfile" --region "$REGION" --profile "$PROFILE"
aws iam add-role-to-instance-profile --instance-profile-name "SSMFleetProfile" --role-name "SSMFleetRole" --region "$REGION" --profile "$PROFILE"
echo "Waiting 10 seconds for IAM instance profile replication across AWS regions..."
sleep 10
else
aws iam add-role-to-instance-profile --instance-profile-name "SSMFleetProfile" --role-name "SSMFleetRole" --region "$REGION" --profile "$PROFILE" >/dev/null 2>&1 || true
fi
echo "=== 4. Launching Backend Compute Nodes ==="
AMI_ID=$(aws ssm get-parameter --name "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64" --region "$REGION" --profile "$PROFILE" --query "Parameter.Value" --output text)
NODE_A_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=Production-App-Node-A" "Name=instance-state-name,Values=running,pending" --region "$REGION" --profile "$PROFILE" --query "Reservations[0].Instances[0].InstanceId" --output text 2>/dev/null || true)
if [ -z "$NODE_A_ID" ] || [ "$NODE_A_ID" = "None" ]; then
cat <<'USERDATA' > userdata-node-a.sh
#!/bin/bash
dnf update -y
dnf install -y nginx
mkdir -p /usr/share/nginx/html/api
echo "<h1>π Frontend App Cluster (Node A - Zone A)</h1><p>Served from: $(hostname -f)</p>" > /usr/share/nginx/html/index.html
echo "{\"service\": \"api-gateway\", \"cluster\": \"node-a\", \"status\": \"healthy\"}" > /usr/share/nginx/html/api/status
systemctl enable --now nginx
USERDATA
while [ -z "$NODE_A_ID" ] || [ "$NODE_A_ID" = "None" ]; do
NODE_A_ID=$(aws ec2 run-instances --image-id "$AMI_ID" --instance-type "t4g.micro" --security-group-ids "$APP_SG_ID" --subnet-id "$PUB_SUB_A" --iam-instance-profile Name="SSMFleetProfile" --user-data file://userdata-node-a.sh --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=Production-App-Node-A},{Key=Environment,Value=Production}]' --region "$REGION" --profile "$PROFILE" --query "Instances[0].InstanceId" --output text 2>/dev/null || true)
if [ -z "$NODE_A_ID" ] || [ "$NODE_A_ID" = "None" ]; then
echo "Waiting for IAM instance profile replication to EC2..."
sleep 5
fi
done
rm -f userdata-node-a.sh
fi
NODE_B_ID=$(aws ec2 describe-instances --filters "Name=tag:Name,Values=Production-App-Node-B" "Name=instance-state-name,Values=running,pending" --region "$REGION" --profile "$PROFILE" --query "Reservations[0].Instances[0].InstanceId" --output text 2>/dev/null || true)
if [ -z "$NODE_B_ID" ] || [ "$NODE_B_ID" = "None" ]; then
cat <<'USERDATA' > userdata-node-b.sh
#!/bin/bash
dnf update -y
dnf install -y nginx
mkdir -p /usr/share/nginx/html/api
echo "<h1>π Frontend App Cluster (Node B - Zone B)</h1><p>Served from: $(hostname -f)</p>" > /usr/share/nginx/html/index.html
echo "{\"service\": \"api-gateway\", \"cluster\": \"node-b\", \"status\": \"healthy\"}" > /usr/share/nginx/html/api/status
systemctl enable --now nginx
USERDATA
while [ -z "$NODE_B_ID" ] || [ "$NODE_B_ID" = "None" ]; do
NODE_B_ID=$(aws ec2 run-instances --image-id "$AMI_ID" --instance-type "t4g.micro" --security-group-ids "$APP_SG_ID" --subnet-id "$PUB_SUB_B" --iam-instance-profile Name="SSMFleetProfile" --user-data file://userdata-node-b.sh --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=Production-App-Node-B},{Key=Environment,Value=Production}]' --region "$REGION" --profile "$PROFILE" --query "Instances[0].InstanceId" --output text 2>/dev/null || true)
if [ -z "$NODE_B_ID" ] || [ "$NODE_B_ID" = "None" ]; then
echo "Waiting for IAM instance profile replication to EC2..."
sleep 5
fi
done
rm -f userdata-node-b.sh
fi
echo "Waiting for compute nodes to enter running state..."
aws ec2 wait instance-running --instance-ids "$NODE_A_ID" "$NODE_B_ID" --region "$REGION" --profile "$PROFILE"
echo "=== Base Infrastructure Ready for ALB Lab ==="
echo "VPC_ID=$VPC_ID"
echo "PUB_SUB_A=$PUB_SUB_A"
echo "PUB_SUB_B=$PUB_SUB_B"
echo "ALB_SG_ID=$ALB_SG_ID"
echo "APP_SG_ID=$APP_SG_ID"
echo "NODE_A_ID=$NODE_A_ID"
echo "NODE_B_ID=$NODE_B_ID"
EOF
chmod +x setup-alb-lab.sh
./setup-alb-lab.sh
Phase 1: Architecture of Layer 7 ALBs vs. Layer 4 NLBs & Security Group Chaining
1. ELB Type Comparison: Layer 7 vs. Layer 4
AWS provides specialized load balancers optimized for different layers of the OSI model:
| Architectural Feature | Application Load Balancer (ALB) | Network Load Balancer (NLB) | Classic Load Balancer (CLB) |
|---|---|---|---|
| OSI Layer | Layer 7 (Application: HTTP/HTTPS, gRPC, WebSockets) | Layer 4 (Transport: TCP, UDP, TLS) | Layer 4 / Layer 7 (Legacy) |
| Routing Intelligence | Path-based (/api*), Host-based (api.example.com), Query string, HTTP headers | Port / Protocol mapping only | Basic round-robin |
| Performance / Latency | Millisecond latency (~5β15ms) with deep packet inspection | Ultra-low microsecond latency (~1ms), millions of RPS | Legacy performance |
| Static IP Support | Dynamic DNS Name (IPs scale automatically) | Static Elastic IP per Availability Zone | Dynamic DNS Name |
| SSL/TLS Termination | Supports multiple SNI certificates, modern TLS policies | High-performance TLS offloading | Single SSL certificate |
| Security Groups | Directly attaches AWS Security Groups | Attaches Security Groups or passes client IP directly | Attaches Security Groups |
2. The Power of Security Group Chaining
In enterprise multi-tier architecture (similar to how an on-premises F5 BIG-IP load balancer directs external traffic into a pool of internal application servers), your compute instances in the application tier should never be exposed directly to public internet traffic. Opening inbound ports to 0.0.0.0/0:80 on application servers invites automated scanners and brute-force attacks.
With Security Group Chaining, your Application Tier instances (App-Tier-SG) only accept packets originating from the Elastic Load Balancer (ALB-Public-SG):
[ Ingress: Internet 0.0.0.0/0 ]
|
v
[ Security Group: ALB-Public-SG ]
Rule: Inbound Port 80 from 0.0.0.0/0
|
v (Forwarded across AWS Hypervisor Fabric)
[ Security Group: App-Tier-SG ]
Rule: Inbound Port 80 strictly from source "ALB-Public-SG"
|
v
[ Application Tier Compute Instances (Node A & Node B) ]
[!IMPORTANT] Why Security Group Chaining is Superior to IP Whitelisting:
- Zero Maintenance: ALBs dynamically scale their internal IP addresses up and down based on traffic volume. If you whitelist specific IPs, your backend will break when the ALB scales.
- Hypervisor Enforcement: The AWS Nitro hypervisor checks the security group identifier attached to the originating packet. If a request bypasses the ALB and hits the instance directly, the hypervisor drops it instantly.
3. Production Architecture vs. Lab Setup: Why Did Our Nodes Get Public IPs?
A sharp engineer might ask: βIf the Application Load Balancer acts as our public ingress, why did our bootstrap script enable --map-public-ip-on-launch for the subnets?β
The answer lies in Lab Cost Optimization vs. Enterprise Production Topology:
The Lab Shortcut (Zero-Cost NAT):
When our EC2 instances boot up, their user-data.sh script must download Apache (httpd) and Linux updates from external repositories over the Internet.
- In a strict production environment, compute instances reside in Private Subnets and reach the Internet through an AWS NAT Gateway. However, a NAT Gateway costs ~$33/month ($0.045/hour) plus data processing feesβan unnecessary expense for a 1-hour training lab!
- Instead, we deployed compute instances into public subnets with temporary public IPs so they could fetch packages directly through the free Internet Gateway, while relying on Security Group Chaining (
--source-group) to guarantee that 100% of direct inbound internet traffic is dropped at the hypervisor.
The Enterprise Production Topology (On-Prem DMZ Pattern):
In a real enterprise deployment (mirroring the on-prem DMZ model where an F5 load balancer fronts an internal application VLAN):
- Public Subnets (DMZ): Contain ONLY the ALB. These are the only subnets attached to an Internet Gateway with public-facing IPs.
- Private Subnets (App Tier): Host all EC2 compute nodes (
App-Tier-SG) withmap-public-ip-on-launchdisabled (0 public IPs). Outbound internet access (if required) is routed via a NAT Gateway or private VPC Endpoints. - Isolated Data Subnets (Database Tier): Host backend databases (e.g., Amazon RDS / Aurora) with zero internet access, strictly receiving connections from the App Tier.
[ Internet Clients ]
|
v
=========================================================
PUBLIC SUBNETS (DMZ):
- Ingress: Internet Gateway (IGW)
- Resources: Application Load Balancer ONLY
- Public IPs: Yes (Dynamically managed by AWS)
=========================================================
|
| (Port 80/443 forwarded internally)
v
=========================================================
PRIVATE SUBNETS (Internal Application Tier):
- map-public-ip-on-launch: FALSE (Zero Public IPs)
- Resources: EC2 App Tier Fleet (App-Tier-SG)
- Ingress: Strictly permitted from ALB-Public-SG
- Outbound Access: Routed via NAT Gateway / VPC Endpoints
=========================================================
|
| (Port 5432 / 1433 / 3306 Database Traffic)
v
=========================================================
ISOLATED DATABASE SUBNETS (Data Tier):
- Resources: Amazon RDS / Aurora Databases
- Ingress: Strictly permitted from App-Tier-SG
- Internet Access: None (Completely Air-Gapped)
=========================================================
Phase 2: Provisioning the Multi-AZ Application Load Balancer via AWS CLI
In AWS, an Application Load Balancer must be attached to at least two public subnets in different Availability Zones.
+-------------------------------------------------------------------------------+
| ALB Multi-AZ Subnet Allocation |
+-------------------------------------------------------------------------------+
[ Region: ap-southeast-1 (Singapore) ]
|
+---> Public Subnet A (ap-southeast-1a) ===> ALB Elastic Network Interface (ENI-1)
|
+---> Public Subnet B (ap-southeast-1b) ===> ALB Elastic Network Interface (ENI-2)
Step 1: Create the Internet-Facing Application Load Balancer
Deploy the ALB across both public subnets:
ALB_ARN=$(aws elbv2 create-load-balancer \
--name "Production-Public-ALB" \
--subnets "$PUB_SUB_A" "$PUB_SUB_B" \
--security-groups "$ALB_SG_ID" \
--scheme "internet-facing" \
--type "application" \
--ip-address-type "ipv4" \
--tags "Key=Name,Value=Production-Public-ALB" "Key=Environment,Value=Production" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "LoadBalancers[0].LoadBalancerArn" \
--output text)
Step 2: Wait for the Load Balancer to Become Active
ALB DNS provisioning and ENI attachment across Availability Zones takes approximately 60 to 90 seconds:
aws elbv2 wait load-balancer-available \
--load-balancer-arns "$ALB_ARN" \
--region ap-southeast-1 \
--profile cloud-dev-admin
π How to Validate ALB State & Retrieve Public DNS Name:
1. Query and export the public DNS name assigned to your Load Balancer:
ALB_DNS=$(aws elbv2 describe-load-balancers \
--load-balancer-arns "$ALB_ARN" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "LoadBalancers[0].DNSName" \
--output text)
2. Verify the Load Balancer provisioning state and endpoint table:
aws elbv2 describe-load-balancers \
--load-balancer-arns "$ALB_ARN" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "LoadBalancers[*].[LoadBalancerName, State.Code, Scheme, DNSName]" \
--output table
Expected Output:
----------------------------------------------------------------------------------------------------------------------------
| DescribeLoadBalancers |
+------------------------+---------+------------------+--------------------------------------------------------------------+
| Production-Public-ALB | active | internet-facing | Production-Public-ALB-123456789.ap-southeast-1.elb.amazonaws.com |
+------------------------+---------+------------------+--------------------------------------------------------------------+
Phase 3: Target Groups, Custom Health Checks & Instance Registration
An ALB does not route traffic directly to instances; it routes traffic to Target Groups. A Target Group defines:
- The Protocol and Port used to reach registered targets (e.g.,
HTTP:80). - The Target Type (
instance,ip,lambda, oralb). - The Active Health Check Policy that continuously verifies target availability.
Anatomy of an HTTP Health Check:
[ ALB Background Health Checker ]
|
| ---> 1. Sends HTTP GET / every 15 seconds
v
[ Backend Target Node ]
|
| <--- 2. Responds with HTTP 200 OK
v
[ Evaluation Engine ]:
* Healthy Threshold: 2 consecutive successful checks -> Marks target "healthy"
* Unhealthy Threshold: 2 consecutive failed checks -> Marks target "unhealthy" (Traffic stops!)
* Health Check Timeout: 5 seconds maximum response window
Step 1: Create the Primary Frontend Target Group
Create a Target Group with custom health check parameters:
DEFAULT_TG_ARN=$(aws elbv2 create-target-group \
--name "Default-App-TG" \
--protocol HTTP \
--port 80 \
--vpc-id "$VPC_ID" \
--target-type instance \
--health-check-protocol HTTP \
--health-check-port "80" \
--health-check-path "/" \
--health-check-interval-seconds 15 \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--matcher "HttpCode=200-399" \
--tags "Key=Name,Value=Default-App-TG" "Key=Tier,Value=Frontend" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "TargetGroups[0].TargetGroupArn" \
--output text)
Step 2: Register EC2 Instances into the Target Group
Register both compute nodes (Node-A and Node-B) on Port 80:
aws elbv2 register-targets \
--target-group-arn "$DEFAULT_TG_ARN" \
--targets "Id=$NODE_A_ID,Port=80" "Id=$NODE_B_ID,Port=80" \
--region ap-southeast-1 \
--profile cloud-dev-admin
Step 3: Create the Default HTTP Listener on the ALB
Create a listener on Port 80 that forwards all incoming traffic to Default-App-TG:
LISTENER_ARN=$(aws elbv2 create-listener \
--load-balancer-arn "$ALB_ARN" \
--protocol HTTP \
--port 80 \
--default-actions "Type=forward,TargetGroupArn=$DEFAULT_TG_ARN" \
--tags "Key=Name,Value=HTTP-80-Listener" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "Listeners[0].ListenerArn" \
--output text)
π How to Validate Target Health & Multi-AZ Round-Robin Distribution:
1. Query the health status of registered targets:
aws elbv2 describe-target-health \
--target-group-arn "$DEFAULT_TG_ARN" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "TargetHealthDescriptions[*].[Target.Id, Target.Port, TargetHealth.State, TargetHealth.Reason]" \
--output table
Expected Output:
------------------------------------------------------
| DescribeTargetHealth |
+----------------------+-----+----------+------------+
| i-01111111111111111 | 80 | healthy | None |
| i-02222222222222222 | 80 | healthy | None |
+----------------------+-----+----------+------------+
2. Send 4 consecutive requests to the ALB DNS name to test round-robin distribution:
for i in {1..4}; do
curl -s "http://${ALB_DNS}/" | grep -o "Frontend App Cluster.*"
done
Expected Output:
Frontend App Cluster (Node A - Zone A)</h1><p>Served from: ip-10-0-1-xxx.ap-southeast-1.compute.internal</p>
Frontend App Cluster (Node B - Zone B)</h1><p>Served from: ip-10-0-2-yyy.ap-southeast-1.compute.internal</p>
Frontend App Cluster (Node A - Zone A)</h1><p>Served from: ip-10-0-1-xxx.ap-southeast-1.compute.internal</p>
Frontend App Cluster (Node B - Zone B)</h1><p>Served from: ip-10-0-2-yyy.ap-southeast-1.compute.internal</p>
Phase 4: Advanced Path-Based Routing & Microservice Distribution
The Architecture: Decoupled Microservice Ingress
In modern cloud platforms, you donβt need a separate load balancer or domain name for every backend microservice. An Application Load Balancer can inspect the HTTP URI path and dynamically route requests:
[ Request: GET http://alb-dns/ ]
|
v (Matches Default Rule: /*)
[ Default-App-TG ] ===> Frontend Web Cluster (Node A & Node B)
-----------------------------------------------------------------------
[ Request: GET http://alb-dns/api/status ]
|
v (Matches Path Rule: /api*)
[ API-Microservice-TG ] ===> Dedicated API Backend Cluster (Node B)
Step 1: Create the Dedicated Microservice Target Group
Create a Target Group with health checks specifically monitoring the /api/status endpoint:
API_TG_ARN=$(aws elbv2 create-target-group \
--name "API-Microservice-TG" \
--protocol HTTP \
--port 80 \
--vpc-id "$VPC_ID" \
--target-type instance \
--health-check-protocol HTTP \
--health-check-port "80" \
--health-check-path "/api/status" \
--health-check-interval-seconds 15 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--matcher "HttpCode=200" \
--tags "Key=Name,Value=API-Microservice-TG" "Key=Tier,Value=Microservice" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "TargetGroups[0].TargetGroupArn" \
--output text)
Step 2: Register the Dedicated API Instance
Register Node-B into the API Target Group:
aws elbv2 register-targets \
--target-group-arn "$API_TG_ARN" \
--targets "Id=$NODE_B_ID,Port=80" \
--region ap-southeast-1 \
--profile cloud-dev-admin
Step 3: Create the Path-Based Listener Routing Rule
Add a rule to the Port 80 Listener with priority 10 that forwards any request matching /api* to API-Microservice-TG:
RULE_ARN=$(aws elbv2 create-rule \
--listener-arn "$LISTENER_ARN" \
--priority 10 \
--conditions "Field=path-pattern,Values='/api*'" \
--actions "Type=forward,TargetGroupArn=$API_TG_ARN" \
--tags "Key=Name,Value=API-Path-Rule" \
--region ap-southeast-1 \
--profile cloud-dev-admin \
--query "Rules[0].RuleArn" \
--output text)
π How to Validate Path-Based Routing:
1. Test the root path / (Routes to the default frontend cluster):
curl -s "http://${ALB_DNS}/"
Output:
<h1>π Frontend App Cluster (Node A - Zone A)</h1><p>Served from: ip-10-0-1-xxx...</p>
2. Test the API path /api/status (Routes directly to the microservice target group):
curl -s "http://${ALB_DNS}/api/status"
Output:
{"service": "api-gateway", "cluster": "node-b", "status": "healthy"}
Phase 5: HTTP-to-HTTPS Redirection & SSL/TLS Architecture
The Architecture: SSL/TLS Offloading at the Boundary
In secure architectures, TLS encryption is terminated directly at the ALB. This eliminates the CPU overhead of encrypting and decrypting TLS sessions on backend EC2 instances:
[ Client Browser (HTTPS:443) ]
|
| (Encrypted TLS Tunnel)
v
[ Application Load Balancer ] ===> Terminates SSL with ACM Certificate
|
| (Plain HTTP:80 over Private Chained Security Group)
v
[ Backend Compute Fleet ]
Configuring HTTP to HTTPS 301 Permanent Redirect via CLI
When clients make an unencrypted request on Port 80, the ALB can immediately issue an HTTP 301 Permanent Redirect to the secure HTTPS:443 endpoint.
To reconfigure an existing Port 80 listener to enforce HTTPS redirection, run:
aws elbv2 modify-listener \
--listener-arn "$LISTENER_ARN" \
--default-actions '{
"Type": "redirect",
"RedirectConfig": {
"Protocol": "HTTPS",
"Port": "443",
"StatusCode": "HTTP_301"
}
}' \
--region ap-southeast-1 \
--profile cloud-dev-admin
π How to Validate 301 Redirection:
Inspect the HTTP response headers using curl -I:
curl -I "http://${ALB_DNS}/"
Expected Output:
HTTP/1.1 301 Moved Permanently
Server: awselb/2.0
Date: Tue, 01 Sep 2026 10:00:00 GMT
Content-Type: text/html
Content-Length: 134
Location: https://Production-Public-ALB-123456789.ap-southeast-1.elb.amazonaws.com:443/
(To restore default forwarding back to Default-App-TG for lab testing, run):
aws elbv2 modify-listener \
--listener-arn "$LISTENER_ARN" \
--default-actions "Type=forward,TargetGroupArn=$DEFAULT_TG_ARN" \
--region ap-southeast-1 \
--profile cloud-dev-admin
Phase 6: Automated Teardown & Resource Cleanup
To prevent ongoing hourly charges for the Application Load Balancer and EC2 compute instances, tear down all provisioned resources using this dependency-aware cleanup script:
cat <<'EOF' > destroy-alb-lab.sh
#!/usr/bin/env bash
REGION="ap-southeast-1"
PROFILE="cloud-dev-admin"
echo "=== 1. Deleting ALB Listeners, Rules & Load Balancers ==="
ALB_ARN=$(aws elbv2 describe-load-balancers --names "Production-Public-ALB" --region "$REGION" --profile "$PROFILE" --query "LoadBalancers[0].LoadBalancerArn" --output text 2>/dev/null || true)
if [ -n "$ALB_ARN" ] && [ "$ALB_ARN" != "None" ]; then
LISTENERS=$(aws elbv2 describe-listeners --load-balancer-arn "$ALB_ARN" --region "$REGION" --profile "$PROFILE" --query "Listeners[*].ListenerArn" --output text 2>/dev/null || true)
for L in $LISTENERS; do
echo "Deleting Listener: $L"
aws elbv2 delete-listener --listener-arn "$L" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
done
echo "Deleting ALB: $ALB_ARN"
aws elbv2 delete-load-balancer --load-balancer-arn "$ALB_ARN" --region "$REGION" --profile "$PROFILE"
echo "Waiting for ALB deletion to finalize..."
aws elbv2 wait load-balancers-deleted --load-balancer-arns "$ALB_ARN" --region "$REGION" --profile "$PROFILE"
fi
echo "=== 2. Deleting Target Groups ==="
for TG_NAME in "Default-App-TG" "API-Microservice-TG"; do
TG_ARN=$(aws elbv2 describe-target-groups --names "$TG_NAME" --region "$REGION" --profile "$PROFILE" --query "TargetGroups[0].TargetGroupArn" --output text 2>/dev/null || true)
if [ -n "$TG_ARN" ] && [ "$TG_ARN" != "None" ]; then
echo "Deleting Target Group: $TG_NAME"
aws elbv2 delete-target-group --target-group-arn "$TG_ARN" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
fi
done
echo "=== 3. Terminating Backend EC2 Instances ==="
INSTANCE_IDS=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=Production-App-Node-A,Production-App-Node-B" "Name=instance-state-name,Values=running,pending,stopped" \
--region "$REGION" --profile "$PROFILE" \
--query "Reservations[*].Instances[*].InstanceId" --output text 2>/dev/null || true)
if [ -n "$INSTANCE_IDS" ] && [ "$INSTANCE_IDS" != "None" ]; then
echo "Terminating Instances: $INSTANCE_IDS"
aws ec2 terminate-instances --instance-ids $INSTANCE_IDS --region "$REGION" --profile "$PROFILE" >/dev/null
echo "Waiting for instances to terminate completely..."
aws ec2 wait instance-terminated --instance-ids $INSTANCE_IDS --region "$REGION" --profile "$PROFILE"
fi
echo "=== 4. Cleaning up Chained Security Groups ==="
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=Production-VPC" --region "$REGION" --profile "$PROFILE" --query "Vpcs[0].VpcId" --output text 2>/dev/null || true)
if [ -n "$VPC_ID" ] && [ "$VPC_ID" != "None" ]; then
APP_SG=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=App-Tier-SG" "Name=vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true)
ALB_SG=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=ALB-Public-SG" "Name=vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true)
# Break cross-group ingress reference to prevent dependency lock
if [ -n "$APP_SG" ] && [ "$APP_SG" != "None" ] && [ -n "$ALB_SG" ] && [ "$ALB_SG" != "None" ]; then
echo "Revoking chained rule between App-Tier-SG and ALB-Public-SG..."
aws ec2 revoke-security-group-ingress --group-id "$APP_SG" --protocol tcp --port 80 --source-group "$ALB_SG" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
fi
sleep 5
if [ -n "$APP_SG" ] && [ "$APP_SG" != "None" ]; then
echo "Deleting App Tier Security Group: $APP_SG"
aws ec2 delete-security-group --group-id "$APP_SG" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
fi
if [ -n "$ALB_SG" ] && [ "$ALB_SG" != "None" ]; then
echo "Deleting ALB Security Group: $ALB_SG"
aws ec2 delete-security-group --group-id "$ALB_SG" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
fi
fi
echo "=== 5. Cleaning up Public VPC, Subnets & IGW ==="
if [ -n "$VPC_ID" ] && [ "$VPC_ID" != "None" ]; then
for SUB in $(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "Subnets[*].SubnetId" --output text 2>/dev/null || true); do
echo "Deleting Subnet: $SUB..."
aws ec2 delete-subnet --subnet-id "$SUB" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
done
for RTB in $(aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Route-Table" --region "$REGION" --profile "$PROFILE" --query "RouteTables[*].RouteTableId" --output text 2>/dev/null || true); do
echo "Deleting Route Table: $RTB..."
aws ec2 delete-route-table --route-table-id "$RTB" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
done
for IGW in $(aws ec2 describe-internet-gateways --filters "Name=attachment.vpc-id,Values=$VPC_ID" --region "$REGION" --profile "$PROFILE" --query "InternetGateways[*].InternetGatewayId" --output text 2>/dev/null || true); do
echo "Detaching & deleting IGW: $IGW..."
aws ec2 detach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC_ID" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
aws ec2 delete-internet-gateway --internet-gateway-id "$IGW" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
done
echo "Deleting VPC: $VPC_ID..."
aws ec2 delete-vpc --vpc-id "$VPC_ID" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
fi
# 6. Clean up IAM Roles & Local Scripts
aws iam remove-role-from-instance-profile --instance-profile-name "SSMFleetProfile" --role-name "SSMFleetRole" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
aws iam delete-instance-profile --instance-profile-name "SSMFleetProfile" --region "$REGION" --profile "$PROFILE" 2>/dev/null || true
for pol in $(aws iam list-attached-role-policies --role-name "SSMFleetRole" --profile "$PROFILE" --query "AttachedPolicies[].PolicyArn" --output text 2>/dev/null || true); do
aws iam detach-role-policy --role-name "SSMFleetRole" --policy-arn "$pol" --profile "$PROFILE" 2>/dev/null || true
done
aws iam delete-role --role-name "SSMFleetRole" --profile "$PROFILE" 2>/dev/null || true
rm -f setup-alb-lab.sh destroy-alb-lab.sh userdata-node-a.sh userdata-node-b.sh
echo "=== Part 10 Teardown Complete! Zero Resources Remaining. ==="
EOF
chmod +x destroy-alb-lab.sh
./destroy-alb-lab.sh
Key Takeaways & Enterprise Best Practices
- Layer 7 vs. Layer 4 Separation: Use Application Load Balancers when you require intelligent HTTP routing (path/host rules, header inspection, SSL termination). Use Network Load Balancers for ultra-high throughput TCP/UDP applications requiring static IPs and microsecond latency.
- Security Group Chaining: Never open backend EC2 ports to
0.0.0.0/0. Always specify the ALB Security Group ID as the authorized source (--source-group "$ALB_SG_ID"). - Multi-AZ Availability: ALBs mandate subnets in at least two Availability Zones to ensure automatic failover in the event of an AZ-level outage.
- Target Group Health Check Optimization: Configure reasonable health check intervals (e.g., 15s) and thresholds (2 healthy, 2 unhealthy) so faulty nodes are evicted quickly without flapping.
- Public Ingress vs. Private App Tier: Keep the ALB in public subnets (DMZ) while isolating your application and database fleets in private subnets with zero public IPs (
map-public-ip-on-launch: false), routing outbound updates through a NAT Gateway or VPC Endpoints.
Whatβs Next in Part 11?
In Part 11: Elastic High-Availability: EC2 Auto Scaling Groups (ASG), Launch Templates & Self-Healing Fleets, we scale our infrastructure from static compute fleets to dynamic, self-healing elastic clusters with Auto Scaling Groups (ASG), Launch Templates, Spot Fleets (70β90% cost savings), and Predictive Target Tracking Scaling Policies purely via the AWS CLI!