Emmanuel Akinfulubi

AWS compute & networking all 4 written

IAM, VPC and EC2, run for real against a free local AWS. Least privilege from the start, not bolted on later.

18 IAM: identities, policies and least privilege

The default answer to "can this identity do this" is no. Nothing is allowed until a policy explicitly allows it, and that default is the single most important fact about IAM -- it means a forgotten permission fails safely, and a forgotten Deny does not.

A real user, with no permissions at all

A brand-new IAM user starts with nothing granted. Confirming that -- not assuming it -- is the first step.

Deny-by-default, confirmed on a real new user

ran at build time
aws iam create-user --user-name course-demo-user > /dev/null

echo "policies attached to a brand-new user:"
inline=$(aws iam list-user-policies --user-name course-demo-user --query 'PolicyNames' --output text)
managed=$(aws iam list-attached-user-policies --user-name course-demo-user --query 'AttachedPolicies' --output text)

echo "inline policies:  '$inline'"
echo "managed policies: '$managed'"
echo "(both empty -- deny-by-default means a new identity can do nothing at all)"

check "a brand-new user has no inline policies" "$inline" ""
check "a brand-new user has no managed policies attached" "$managed" ""

What it printed, when the build ran it

policies attached to a brand-new user:
inline policies:  ''
managed policies: ''
(both empty -- deny-by-default means a new identity can do nothing at all)
ok   a brand-new user has no inline policies
ok   a brand-new user has no managed policies attached

Granting exactly one action, on exactly one resource

Least privilege in practice: a policy naming one action against one specific bucket, not s3:* against *. The difference is not stylistic -- it is the entire content of "least privilege" as a real, checkable thing.

A least-privilege policy: one action, one resource, attached for real

ran at build time
cat > /tmp/cd-course-readonly-policy.json << 'JSON'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::course-data-bucket/*"
    }
  ]
}
JSON

echo "the policy being attached:"
cat /tmp/cd-course-readonly-policy.json

aws iam put-user-policy --user-name course-demo-user --policy-name read-only \
  --policy-document file:///tmp/cd-course-readonly-policy.json

echo
echo "reading the SAME policy back from IAM, not from the local file:"
stored=$(aws iam get-user-policy --user-name course-demo-user --policy-name read-only --output json)
echo "$stored"

rm -f /tmp/cd-course-readonly-policy.json

check "the stored policy grants exactly one action" \
  "$(echo "$stored" | grep -c 's3:GetObject')" "1"
check "the stored policy names one specific bucket, not a wildcard resource" \
  "$(echo "$stored" | grep -c 'course-data-bucket')" "1"
check "the policy does NOT grant a wildcard action" \
  "$(echo "$stored" | grep -c '"s3:\*"')" "0"

What it printed, when the build ran it

the policy being attached:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::course-data-bucket/*"
    }
  ]
}

reading the SAME policy back from IAM, not from the local file:
{
    "UserName": "course-demo-user",
    "PolicyName": "read-only",
    "PolicyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": "s3:GetObject",
                "Resource": "arn:aws:s3:::course-data-bucket/*"
            }
        ]
    }
}
ok   the stored policy grants exactly one action
ok   the stored policy names one specific bucket, not a wildcard resource
ok   the policy does NOT grant a wildcard action

Evaluating a policy the way AWS actually does: explicit Deny always wins

AWS's own evaluation order, checkable without any special API: collect every applicable statement across every attached policy; if any of them is an explicit Deny, the request is denied, full stop, no matter how many Allow statements also matched. This is the exact mechanism a permissions boundary and an SCP both rely on.

AWS's real evaluation order: Deny beats Allow, and nothing beats the default

ran at build time
# A minimal, honest re-implementation of AWS's own evaluation order --
# not a metaphor for it. Real policy JSON in, the real verdict out.
evaluate_action() {
  local action="$1"
  local policies_json="$2"   # a JSON array of policy documents, as text

  # Deny beats Allow, unconditionally -- checked FIRST, exactly as AWS specifies.
  if echo "$policies_json" | grep -q "\"Effect\": \"Deny\".*\"Action\": \"$action\"" ; then
    echo "DENY (explicit)"
    return
  fi
  if echo "$policies_json" | grep -q "\"Effect\": \"Allow\".*\"Action\": \"$action\"" ; then
    echo "ALLOW"
    return
  fi
  echo "DENY (default -- nothing granted it)"
}

# One policy allows read access; a SECOND, separate policy explicitly denies it --
# exactly the shape of "a permissions boundary overriding a generous role policy."
allow_policy='{"Effect": "Allow", "Action": "s3:GetObject"}'
deny_policy='{"Effect": "Deny", "Action": "s3:GetObject"}'
combined="[$allow_policy, $deny_policy]"

verdict_with_deny=$(evaluate_action "s3:GetObject" "$combined")
verdict_without_deny=$(evaluate_action "s3:GetObject" "[$allow_policy]")
verdict_ungranted=$(evaluate_action "s3:DeleteObject" "[$allow_policy]")

echo "Allow + explicit Deny together : $verdict_with_deny"
echo "Allow alone                    : $verdict_without_deny"
echo "Never granted at all           : $verdict_ungranted"

check "an explicit Deny overrides an Allow for the same action" "$verdict_with_deny" "DENY (explicit)"
check "an Allow with no competing Deny actually allows it" "$verdict_without_deny" "ALLOW"
check "an action nobody granted denies by default, not by explicit rule" "$verdict_ungranted" "DENY (default -- nothing granted it)"

What it printed, when the build ran it

Allow + explicit Deny together : DENY (explicit)
Allow alone                    : ALLOW
Never granted at all           : DENY (default -- nothing granted it)
ok   an explicit Deny overrides an Allow for the same action
ok   an Allow with no competing Deny actually allows it
ok   an action nobody granted denies by default, not by explicit rule

Why this order matters in practice. An organization's Service Control Policy (SCP) or a role's permissions boundary is exactly a Deny statement sitting alongside whatever generous Allow a role's own policy grants. Someone who only reads their own role's policy and sees broad access can still be denied everything at runtime by a boundary they never looked at -- which is a feature, not a bug, once you know to look for it.

Roles vs users: a policy attached to something that is not a person

A role is the same permissions mechanism as a user, minus a fixed identity -- something else (an EC2 instance, a Lambda function, another AWS account) assumes it temporarily. This is how a service gets permissions without a password ever existing to leak.

A role: permissions with no fixed identity, assumed by a service

ran at build time
aws iam create-role --role-name course-ec2-role --assume-role-policy-document '{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "ec2.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}' > /dev/null

role_info=$(aws iam get-role --role-name course-ec2-role --output json)
echo "the role, and specifically WHO is allowed to assume it:"
echo "$role_info"

check "the role exists and names ec2.amazonaws.com as its trusted principal" \
  "$(echo "$role_info" | grep -c 'ec2.amazonaws.com')" "1"

What it printed, when the build ran it

the role, and specifically WHO is allowed to assume it:
{
    "Role": {
        "Path": "/",
        "RoleName": "course-ec2-role",
        "RoleId": "AROARZPUZDIKO4WTRV2UI",
        "Arn": "arn:aws:iam::123456789012:role/course-ec2-role",
        "CreateDate": "2026-09-15T20:18:01.159710Z",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ec2.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        },
        "MaxSessionDuration": 3600,
        "RoleLastUsed": {}
    }
}
ok   the role exists and names ec2.amazonaws.com as its trusted principal

19 VPC: your own private network in the cloud

"Public subnet" and "private subnet" are not a special AWS feature -- they are a subnet with a route to an internet gateway, and a subnet without one. Nothing else distinguishes them. This lesson builds that exact distinction from raw parts and then proves it by reading the route table back.

A network, and two subnets inside it

A VPC and two subnets, neither reachable from anywhere yet

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "created VPC: $vpc_id, CIDR 10.0.0.0/16 -- 65,536 addresses, all private until routed otherwise"

pub=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.0.1.0/24 --availability-zone us-east-1a --output json)
pub_id=$(echo "$pub" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "public-intended subnet: $pub_id, in us-east-1a"

priv=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.0.2.0/24 --availability-zone us-east-1b --output json)
priv_id=$(echo "$priv" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "private-intended subnet: $priv_id, in us-east-1b"

echo
echo "right now, before any routing exists, both are equally unreachable from the internet."

check "the VPC was really created" "$(echo "$vpc_id" | grep -Ec '^vpc-')" "1"
check "both subnets were created inside it" \
  "$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$vpc_id --query 'length(Subnets)' --output text)" "2"
check "the two subnets landed in two different AZs, on purpose" \
  "$([ "$(aws ec2 describe-subnets --subnet-ids $pub_id --query 'Subnets[0].AvailabilityZone' --output text)" != \
      "$(aws ec2 describe-subnets --subnet-ids $priv_id --query 'Subnets[0].AvailabilityZone' --output text)" ] && echo yes)" "yes"

What it printed, when the build ran it

created VPC: vpc-45bf9db5a339ca880, CIDR 10.0.0.0/16 -- 65,536 addresses, all private until routed otherwise
public-intended subnet: subnet-829a2a1d6db50a8ad, in us-east-1a
private-intended subnet: subnet-0e857196be66d3adc, in us-east-1b

right now, before any routing exists, both are equally unreachable from the internet.
ok   the VPC was really created
ok   both subnets were created inside it
ok   the two subnets landed in two different AZs, on purpose

What actually makes a subnet "public": one route, to one gateway

An internet gateway attached to the VPC, and a route table sending 0.0.0.0/0 (everything not already inside the VPC) to it -- associated with only one of the two subnets. That association is the entire, complete mechanism.

The exact route that makes a subnet public -- and the private one, left without it

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.1.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)

pub=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.1.1.0/24 --availability-zone us-east-1a --output json)
pub_id=$(echo "$pub" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
priv=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.1.2.0/24 --availability-zone us-east-1b --output json)
priv_id=$(echo "$priv" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)

igw=$(aws ec2 create-internet-gateway --output json)
igw_id=$(echo "$igw" | grep -o '"InternetGatewayId": "[^"]*"' | head -1 | cut -d'"' -f4)
aws ec2 attach-internet-gateway --internet-gateway-id "$igw_id" --vpc-id "$vpc_id" > /dev/null

rt=$(aws ec2 create-route-table --vpc-id "$vpc_id" --output json)
rt_id=$(echo "$rt" | grep -o '"RouteTableId": "[^"]*"' | head -1 | cut -d'"' -f4)
aws ec2 create-route --route-table-id "$rt_id" --destination-cidr-block 0.0.0.0/0 --gateway-id "$igw_id" > /dev/null

echo "associating the internet-routed table with ONLY the public subnet:"
aws ec2 associate-route-table --route-table-id "$rt_id" --subnet-id "$pub_id" > /dev/null

echo
echo "the route table this subnet actually uses:"
aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$pub_id" \
  --query 'RouteTables[0].Routes' --output json

echo
echo "does the PRIVATE subnet have any association with an internet-routed table?"
priv_assoc=$(aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$priv_id" --query 'length(RouteTables)' --output text)
echo "route tables associated with the private subnet: $priv_assoc"

check "the public subnet's route table really does send 0.0.0.0/0 to the gateway" \
  "$(aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$pub_id" --query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].GatewayId' --output text)" "$igw_id"
check "the private subnet has no such route, because nobody associated one" "$priv_assoc" "0"

What it printed, when the build ran it

associating the internet-routed table with ONLY the public subnet:

the route table this subnet actually uses:
[
    {
        "DestinationCidrBlock": "10.1.0.0/16",
        "GatewayId": "local",
        "Origin": "CreateRouteTable",
        "State": "active"
    },
    {
        "DestinationCidrBlock": "0.0.0.0/0",
        "GatewayId": "igw-96e800a602692e9fc",
        "Origin": "CreateRoute",
        "State": "active"
    }
]

does the PRIVATE subnet have any association with an internet-routed table?
route tables associated with the private subnet: 0
ok   the public subnet's route table really does send 0.0.0.0/0 to the gateway
ok   the private subnet has no such route, because nobody associated one

"Public subnet" is a description of a route, not a property you set. There is no --make-public true flag anywhere in this panel. A subnet with a default route to an internet gateway is what everyone calls "public"; one without it is what everyone calls "private." The vocabulary is a shorthand for exactly the routing state just inspected above.

The firewall: a security group that opens exactly one port

A security group is stateful, allow-only, and denies everything not explicitly listed -- the same deny-by-default posture as IAM, applied to network traffic instead of API calls.

A security group: deny-by-default, one port opened on purpose

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.2.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)

sg=$(aws ec2 create-security-group --group-name web-sg --description "web tier" --vpc-id "$vpc_id" --output json)
sg_id=$(echo "$sg" | grep -o '"GroupId": "[^"]*"' | head -1 | cut -d'"' -f4)

echo "before any rule is added, what does this security group allow inbound?"
before=$(aws ec2 describe-security-groups --group-ids "$sg_id" --query 'SecurityGroups[0].IpPermissions' --output json)
echo "$before"

echo
echo "opening exactly port 443, from anywhere:"
aws ec2 authorize-security-group-ingress --group-id "$sg_id" --protocol tcp --port 443 --cidr 0.0.0.0/0 > /dev/null

after=$(aws ec2 describe-security-groups --group-ids "$sg_id" --query 'SecurityGroups[0].IpPermissions' --output json)
echo "$after"

port_22_open=$(echo "$after" | grep -c '"FromPort": 22')

check "before any rule, nothing is allowed inbound at all" "$before" "[]"
check "port 443 is now explicitly open" "$(echo "$after" | grep -c '"FromPort": 443')" "1"
check "port 22 was never opened, because nobody asked for it" "$port_22_open" "0"

What it printed, when the build ran it

before any rule is added, what does this security group allow inbound?
[]

opening exactly port 443, from anywhere:
[
    {
        "IpProtocol": "tcp",
        "FromPort": 443,
        "ToPort": 443,
        "UserIdGroupPairs": [],
        "IpRanges": [
            {
                "CidrIp": "0.0.0.0/0"
            }
        ],
        "Ipv6Ranges": [],
        "PrefixListIds": []
    }
]
ok   before any rule, nothing is allowed inbound at all
ok   port 443 is now explicitly open
ok   port 22 was never opened, because nobody asked for it

The mistake this prevents, when done right. The single most common real misconfiguration in cloud security scans is a security group allowing 0.0.0.0/0 on port 22 (SSH) or 3389 (RDP) -- opened once for convenience during setup and never closed. The panel above opened 443 and only 443, on purpose, which is the entire discipline: name the port, name the source, and open nothing else.

20 EC2: virtual machines, launched for real

An EC2 instance is IaaS made concrete: you pick an image, a size, and hand it a startup script, and everything after boot is between the instance and that script. This lesson launches a real one, gives it real user data, and puts it through the exact lifecycle transitions a running fleet goes through every day.

User data: a script that runs itself, on first boot, with no one watching

User data is not a Docker-style entrypoint override -- it is a shell script (or cloud-init directive) the instance runs automatically once, the first time it boots. The instance stores it as base64, and the AWS CLI handles the encoding for you: give it plain text, get plain text back.

A real startup script, verified round-trip through the actual instance

ran at build time
script='#!/bin/bash
echo "server ready" > /var/log/course-app.log
echo "started at boot, not by a human"'

echo "the exact script being handed to the instance:"
echo "$script"

instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro \
  --count 1 --user-data "$script" --output json)
instance_id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo
echo "launched: $instance_id"

echo
echo "reading the user data straight back from AWS -- not from our own variable:"
stored_b64=$(aws ec2 describe-instance-attribute --instance-id "$instance_id" \
  --attribute userData --query 'UserData.Value' --output text)
decoded=$(echo "$stored_b64" | base64 --decode)
echo "$decoded"

check "the decoded user data matches exactly what was sent" "$decoded" "$script"
check "the instance really exists with a real i-xxxx id" "$(echo "$instance_id" | grep -Ec '^i-')" "1"

What it printed, when the build ran it

the exact script being handed to the instance:
#!/bin/bash
echo "server ready" > /var/log/course-app.log
echo "started at boot, not by a human"

launched: i-242a187e22f7dd19b

reading the user data straight back from AWS -- not from our own variable:
#!/bin/bash
echo "server ready" > /var/log/course-app.log
echo "started at boot, not by a human"
ok   the decoded user data matches exactly what was sent
ok   the instance really exists with a real i-xxxx id

The real lifecycle: running, stopped, running again, gone

Four states, four different cost and availability implications: running bills for compute, stopped only bills for the attached storage, terminated is permanent (the instance ID never comes back). Watching the actual state after each transition -- not assuming the command's own success message is the ground truth -- is the habit this panel is really teaching.

An instance's real state, checked after every transition

ran at build time
instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)

state_of() {
  aws ec2 describe-instances --instance-ids "$1" \
    --query 'Reservations[0].Instances[0].State.Name' --output text
}

echo "just launched:     $(state_of "$id")"

aws ec2 stop-instances --instance-ids "$id" > /dev/null
echo "after stop:        $(state_of "$id")"

aws ec2 start-instances --instance-ids "$id" > /dev/null
echo "after start again: $(state_of "$id")"

aws ec2 terminate-instances --instance-ids "$id" > /dev/null
echo "after terminate:   $(state_of "$id")"

check "after the full sequence, the instance ends up terminated" "$(state_of "$id")" "terminated"
check "terminating really is the final, irreversible state" \
  "$([ "$(state_of "$id")" = "terminated" ] && echo yes)" "yes"

What it printed, when the build ran it

just launched:     running
after stop:        stopped
after start again: running
after terminate:   terminated
ok   after the full sequence, the instance ends up terminated
ok   terminating really is the final, irreversible state

Why the first check above compares to "terminated," not "running." By the time that check runs, every transition in the panel has already happened, so asking for the state now correctly returns the LAST one -- confirming the full sequence completed for real, not just that the launch succeeded. The echo lines above it are where each individual transition is actually visible.

Tags: how a real account keeps thousands of instances comprehensible

An instance with no tags is a string of hex characters and nothing else. Tags are the only thing that turns "i-0a1b2c3d4e5f" into "the checkout service's web tier, owned by the payments team" -- and cost reports, automation, and access policies all key off them.

Tags: the difference between a hex string and a managed resource

ran at build time
instance=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id=$(echo "$instance" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)

echo "before tagging, what does this instance's tag list contain?"
aws ec2 describe-instances --instance-ids "$id" --query 'Reservations[0].Instances[0].Tags' --output json

aws ec2 create-tags --resources "$id" --tags \
  Key=Name,Value=course-web-server \
  Key=Team,Value=payments \
  Key=Environment,Value=staging

echo
echo "after tagging:"
tags=$(aws ec2 describe-instances --instance-ids "$id" --query 'Reservations[0].Instances[0].Tags' --output json)
echo "$tags"

echo
echo "finding this exact instance again, by tag alone, with no ID involved:"
found=$(aws ec2 describe-instances --filters "Name=tag:Team,Values=payments" \
  --query 'Reservations[0].Instances[0].InstanceId' --output text)
echo "found by tag search: $found"

check "all three tags were actually stored" "$(echo "$tags" | grep -c '"Key"')" "3"
check "the instance can be found again by tag alone, without knowing its ID" "$found" "$id"

What it printed, when the build ran it

before tagging, what does this instance's tag list contain?
null

after tagging:
[
    {
        "Key": "Name",
        "Value": "course-web-server"
    },
    {
        "Key": "Team",
        "Value": "payments"
    },
    {
        "Key": "Environment",
        "Value": "staging"
    }
]

finding this exact instance again, by tag alone, with no ID involved:
found by tag search: i-61702daeb04011fbe
ok   all three tags were actually stored
ok   the instance can be found again by tag alone, without knowing its ID

21 Load balancing and auto scaling

Two servers behind one address, and a rule for which one answers any given request -- that is the entire concept. The part worth actually seeing happen is what occurs when one of those servers stops being healthy: the load balancer's job is to notice and stop sending it traffic, automatically, without anyone paging anyone.

An ALB, a target group, and a listener: the three parts, wired together

An Application Load Balancer does not know about instances directly. It has listeners (which port, which protocol) that forward to a target group (a pool of backends with a health check), and the target group is what actually tracks instances.

Listener -> target group -> (eventually) instances

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.6.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)

sub1=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.6.1.0/24 --availability-zone us-east-1a --output json)
sub1_id=$(echo "$sub1" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)
sub2=$(aws ec2 create-subnet --vpc-id "$vpc_id" --cidr-block 10.6.2.0/24 --availability-zone us-east-1b --output json)
sub2_id=$(echo "$sub2" | grep -o '"SubnetId": "[^"]*"' | head -1 | cut -d'"' -f4)

alb=$(aws elbv2 create-load-balancer --name course-alb --subnets "$sub1_id" "$sub2_id" --output json)
alb_arn=$(echo "$alb" | grep -o '"LoadBalancerArn": "[^"]*"' | head -1 | cut -d'"' -f4)
alb_dns=$(echo "$alb" | grep -o '"DNSName": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "load balancer created, spanning two AZs, DNS name: $alb_dns"

tg=$(aws elbv2 create-target-group --name course-tg --protocol HTTP --port 80 \
  --vpc-id "$vpc_id" --health-check-path /health --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "target group created, health check path: /health"

aws elbv2 create-listener --load-balancer-arn "$alb_arn" --protocol HTTP --port 80 \
  --default-actions Type=forward,TargetGroupArn="$tg_arn" > /dev/null
echo "listener created: port 80 forwards to the target group above"

check "the load balancer spans more than one Availability Zone" \
  "$(aws elbv2 describe-load-balancers --load-balancer-arns "$alb_arn" --query 'length(LoadBalancers[0].AvailabilityZones)' --output text)" "2"
check "the listener really forwards to the target group just created" \
  "$(aws elbv2 describe-listeners --load-balancer-arn "$alb_arn" --query 'Listeners[0].DefaultActions[0].TargetGroupArn' --output text)" "$tg_arn"

What it printed, when the build ran it

load balancer created, spanning two AZs, DNS name: course-alb-1.us-east-1.elb.amazonaws.com
target group created, health check path: /health
listener created: port 80 forwards to the target group above
ok   the load balancer spans more than one Availability Zone
ok   the listener really forwards to the target group just created

Two real instances, registered, and both reporting healthy

Registering real instances, and reading real health status back

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.7.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
tg=$(aws elbv2 create-target-group --name course-tg2 --protocol HTTP --port 80 --vpc-id "$vpc_id" --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)

i1=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id1=$(echo "$i1" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
i2=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id2=$(echo "$i2" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
echo "two instances launched: $id1 and $id2"

aws elbv2 register-targets --target-group-arn "$tg_arn" --targets Id="$id1" Id="$id2"

echo
echo "the target group's own view of health, for both:"
health=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --output json)
echo "$health"

healthy_count=$(echo "$health" | grep -c '"State": "healthy"')

check "both registered instances report healthy" "$healthy_count" "2"
check "the target group really tracks both instance IDs" \
  "$(echo "$health" | grep -c "$id1\|$id2" )" "2"

What it printed, when the build ran it

two instances launched: i-854294e89aa62d39c and i-a4bcf243e65b035d5

the target group's own view of health, for both:
{
    "TargetHealthDescriptions": [
        {
            "Target": {
                "Id": "i-854294e89aa62d39c",
                "Port": 80
            },
            "HealthCheckPort": "traffic-port",
            "TargetHealth": {
                "State": "healthy"
            }
        },
        {
            "Target": {
                "Id": "i-a4bcf243e65b035d5",
                "Port": 80
            },
            "HealthCheckPort": "traffic-port",
            "TargetHealth": {
                "State": "healthy"
            }
        }
    ]
}
ok   both registered instances report healthy
ok   the target group really tracks both instance IDs

The actual feature: removing one, and watching the pool update

This is the entire point of the exercise. A target that stops being registered (whether by a real failed health check, or by deliberate removal, as here) simply disappears from what the load balancer will send traffic to -- no restart, no manual step, no ticket.

Removing a target: the load balancer stops sending it traffic, immediately

ran at build time
created=$(aws ec2 create-vpc --cidr-block 10.8.0.0/16 --output json)
vpc_id=$(echo "$created" | grep -o '"VpcId": "[^"]*"' | head -1 | cut -d'"' -f4)
tg=$(aws elbv2 create-target-group --name course-tg3 --protocol HTTP --port 80 --vpc-id "$vpc_id" --output json)
tg_arn=$(echo "$tg" | grep -o '"TargetGroupArn": "[^"]*"' | head -1 | cut -d'"' -f4)

i1=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id1=$(echo "$i1" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)
i2=$(aws ec2 run-instances --image-id ami-03cf127a --instance-type t2.micro --count 1 --output json)
id2=$(echo "$i2" | grep -o '"InstanceId": "[^"]*"' | head -1 | cut -d'"' -f4)

aws elbv2 register-targets --target-group-arn "$tg_arn" --targets Id="$id1" Id="$id2"

before_count=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --query 'length(TargetHealthDescriptions)' --output text)
echo "targets in the pool before removing anything: $before_count"

echo
echo "deregistering $id1, the way a failed health check would trigger automatically:"
aws elbv2 deregister-targets --target-group-arn "$tg_arn" --targets Id="$id1"

after=$(aws elbv2 describe-target-health --target-group-arn "$tg_arn" --output json)
echo "$after"
after_count=$(echo "$after" | grep -c '"State"')

check "the pool really had two targets before" "$before_count" "2"
check "the pool has exactly one target after removing one" "$after_count" "1"
check "the remaining target is the one that was never removed" \
  "$(echo "$after" | grep -c "$id2")" "1"
check "the removed instance no longer appears in the pool at all" \
  "$(echo "$after" | grep -c "$id1")" "0"

What it printed, when the build ran it

targets in the pool before removing anything: 2

deregistering i-5fbc3c22af7263ae1, the way a failed health check would trigger automatically:
{
    "TargetHealthDescriptions": [
        {
            "Target": {
                "Id": "i-db88648ebb0944f1e",
                "Port": 80
            },
            "HealthCheckPort": "traffic-port",
            "TargetHealth": {
                "State": "healthy"
            }
        }
    ]
}
ok   the pool really had two targets before
ok   the pool has exactly one target after removing one
ok   the remaining target is the one that was never removed
ok   the removed instance no longer appears in the pool at all

This is what "auto scaling" actually plugs into. An Auto Scaling Group does not talk to a load balancer through anything special -- it registers new instances into the same target group as they launch, and deregisters them as they terminate, using exactly the two API calls just run by hand above. Scaling out is "launch another instance, register it here"; scaling in is "deregister it, then terminate it." Nothing about that changes at any fleet size.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.