Cloud 16 min read

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.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide β€’ Part 10 of 17

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:

ResourceQuantityUnit 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 & Listeners2 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 FeatureApplication Load Balancer (ALB)Network Load Balancer (NLB)Classic Load Balancer (CLB)
OSI LayerLayer 7 (Application: HTTP/HTTPS, gRPC, WebSockets)Layer 4 (Transport: TCP, UDP, TLS)Layer 4 / Layer 7 (Legacy)
Routing IntelligencePath-based (/api*), Host-based (api.example.com), Query string, HTTP headersPort / Protocol mapping onlyBasic round-robin
Performance / LatencyMillisecond latency (~5–15ms) with deep packet inspectionUltra-low microsecond latency (~1ms), millions of RPSLegacy performance
Static IP SupportDynamic DNS Name (IPs scale automatically)Static Elastic IP per Availability ZoneDynamic DNS Name
SSL/TLS TerminationSupports multiple SNI certificates, modern TLS policiesHigh-performance TLS offloadingSingle SSL certificate
Security GroupsDirectly attaches AWS Security GroupsAttaches Security Groups or passes client IP directlyAttaches 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:

  1. 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.
  2. 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):

  1. Public Subnets (DMZ): Contain ONLY the ALB. These are the only subnets attached to an Internet Gateway with public-facing IPs.
  2. Private Subnets (App Tier): Host all EC2 compute nodes (App-Tier-SG) with map-public-ip-on-launch disabled (0 public IPs). Outbound internet access (if required) is routed via a NAT Gateway or private VPC Endpoints.
  3. 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:

  1. The Protocol and Port used to reach registered targets (e.g., HTTP:80).
  2. The Target Type (instance, ip, lambda, or alb).
  3. 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

  1. 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.
  2. 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").
  3. Multi-AZ Availability: ALBs mandate subnets in at least two Availability Zones to ensure automatic failover in the event of an AZ-level outage.
  4. 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.
  5. 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!

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.