Cloud 16 min read

AWS CLI First #7: EC2 Virtual Machines, Stateful Firewalls (SG vs NACL) & IMDSv2 Hardening

Master AWS compute provisioning from the terminal. Learn how to select Graviton ARM64 vs x86 architectures, generate ED25519 keys, configure stateful Security Groups vs stateless NACLs, bootstrap with User Data, deliver temporary STS credentials via IAM instance profiles, and enforce IMDSv2.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide Part 7 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 Module 2, we laid the virtual network foundation: provisioning custom VPCs, multi-AZ subnets, Internet Gateways, NAT Gateways, and zero-cost S3 Gateway Endpoints.

Now, we enter Module 3: Compute Fleets & Security Firewalls.

Launching an EC2 instance in a web console takes a dozen clicks, but deploying hardened, enterprise-grade virtual machines from the CLI requires mastering the underlying compute mechanics:

  1. Hardware Architecture: Selecting between cost-efficient AWS Graviton (ARM64) vs. traditional Intel/AMD (x86_64).
  2. Layered Firewalls: Understanding the critical architectural difference between Stateful Security Groups and Stateless Network ACLs (NACLs).
  3. Automated Bootstrapping: Authoring self-executing cloud-init User Data scripts to configure software upon first boot.
  4. Credential-Less Compute: Attaching IAM Instance Profiles so servers automatically receive temporary STS credentials without storing hardcoded API keys on disk.
  5. SSRF Defense: Enforcing Token-Based IMDSv2 (HttpTokens=required) to protect against metadata exfiltration.

In this guide, we will build, launch, bootstrap, and validate a secure, hardened EC2 instance entirely from the command line.


💰 Estimated Lab Cost & Resource Architecture

ComponentUsage in LabAWS Free TierOn-Demand Rate
Zero-Cost Public VPC + IGWBase Network Foundation100% Free$0.00
t4g.micro Graviton Compute1 Instance (~1 hour)750 hrs/month Free$0.0084 / hr
Public IPv4 Address1 Public IP (~1 hour)Standard Rate$0.005 / hr
Root EBS Volume (8 GB gp3)OS Boot Storage30 GB/month Free$0.0009 / hr
Total Estimated Cost1–2 Hour Hands-On Session~$0.005 Total~$0.01 – $0.02 Total

[!TIP] This lab runs inside a Zero-Cost Public VPC with an Internet Gateway ($0.00). No NAT Gateway is required! Running the automated Phase 7 Teardown Script terminates the instance and brings ongoing costs immediately back to $0.00.


Prerequisites: Base VPC Verification

Because our web server will be launched into Public Subnet A and connect directly through the Internet Gateway ($0.00), we do not need a NAT Gateway for Part 7!

Option A: If your VPC is already running from Part 5/6

Retrieve your existing VPC and Public Subnet IDs:

VPC_ID=$(aws ec2 describe-vpcs --filters "Name=tag:Name,Values=Production-VPC" --query "Vpcs[0].VpcId" --output text --profile cloud-dev-admin)
PUB_SUB_A=$(aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Name,Values=Public-Subnet-A" --query "Subnets[0].SubnetId" --output text --profile cloud-dev-admin)

echo "Target VPC: $VPC_ID"
echo "Target Public Subnet: $PUB_SUB_A"

Option B: If you tore down your VPC (Zero-Cost 3-Second Rebuild)

If you previously deleted your VPC, spin up this 100% Free Public VPC in 3 seconds (no NAT Gateway required):

cat <<'EOF' > setup-public-vpc.sh
#!/usr/bin/env bash
set -e

PROFILE="cloud-dev-admin"
REGION="ap-southeast-1"

echo "=== Provisioning Zero-Cost Public VPC for EC2 Lab ==="

# 1. Create VPC
VPC_ID=$(aws ec2 create-vpc --cidr-block "10.0.0.0/16" \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=Production-VPC}]' \
  --profile "$PROFILE" --query "Vpc.VpcId" --output text)
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-support '{"Value":true}' --profile "$PROFILE"
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-hostnames '{"Value":true}' --profile "$PROFILE"

# 2. Create Public Subnet A
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},{Key=Tier,Value=Public}]' \
  --profile "$PROFILE" --query "Subnet.SubnetId" --output text)
aws ec2 modify-subnet-attribute --subnet-id "$PUB_SUB_A" --map-public-ip-on-launch --profile "$PROFILE"

# 3. Create & Attach Internet Gateway
IGW_ID=$(aws ec2 create-internet-gateway --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=Production-IGW}]' \
  --profile "$PROFILE" --query "InternetGateway.InternetGatewayId" --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" --profile "$PROFILE"

# 4. Create Public Route Table
PUB_RTB_ID=$(aws ec2 create-route-table --vpc-id "$VPC_ID" --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=Public-Route-Table}]' \
  --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" --profile "$PROFILE"
aws ec2 associate-route-table --route-table-id "$PUB_RTB_ID" --subnet-id "$PUB_SUB_A" --profile "$PROFILE"

echo "=== Public VPC Ready ($0.00 Cost) ==="
echo "VPC ID: $VPC_ID"
echo "Public Subnet A: $PUB_SUB_A"
EOF

# Source the script so $VPC_ID and $PUB_SUB_A persist directly in your current terminal:
source setup-public-vpc.sh

[!TIP] Why source setup-public-vpc.sh? Running with source (or . setup-public-vpc.sh) executes the script inside your current shell session rather than a child subshell, ensuring $VPC_ID and $PUB_SUB_A are immediately available for the next commands!


Phase 1: Selecting Hardware Architecture, Instance Types & Key Pairs

The Architecture: Graviton (ARM64) vs. x86_64

When choosing EC2 instance types, AWS offers two primary CPU architectures:

+-----------------------------------------------------------------------------------+
|                        CPU Architecture Comparison                                |
+-----------------------------------------------------------------------------------+

 1. AWS GRAVITON (ARM64 - e.g., t4g, m7g, c7g):
    * Custom silicon designed by AWS using ARM architecture.
    * Up to 40% better price-performance compared to comparable x86 instances.
    * Best for: Linux workloads, microservices, Python, Node.js, Go, Rust, Java.

 2. INTEL / AMD (x86_64 - e.g., t3, m6i, c6a):
    * Traditional CISC architecture.
    * Required for: Legacy proprietary software compiled strictly for x86 without ARM support.

For our production server, we will deploy a modern t4g.micro (AWS Graviton2 ARM64) instance.


Step 1: Query the Latest Amazon Linux 2023 ARM64 AMI Dynamically

Instead of hardcoding an AMI ID (which changes whenever AWS releases security patches), query the official AMI ID dynamically from the AWS Systems Manager Parameter Store:

AMI_ID=$(aws ssm get-parameter \
  --name "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64" \
  --region ap-southeast-1 \
  --profile cloud-dev-admin \
  --query "Parameter.Value" \
  --output text 2>/dev/null || aws ec2 describe-images --owners amazon --filters "Name=name,Values=al2023-ami-2023*-arm64" "Name=state,Values=available" "Name=architecture,Values=arm64" --region ap-southeast-1 --profile cloud-dev-admin --query "reverse(sort_by(Images, &CreationDate))[0].ImageId" --output text)

Step 2: Generate an ED25519 SSH Key Pair

Modern cloud security recommends ED25519 over RSA for faster handshake speeds and shorter, more secure keys:

aws ec2 create-key-pair \
  --key-name "Production-Server-Key" \
  --key-type ed25519 \
  --query "KeyMaterial" \
  --output text \
  --profile cloud-dev-admin > Production-Server-Key.pem

chmod 400 Production-Server-Key.pem

🔍 How to Validate Key Pair:

Verify that AWS registered the key pair and view its cryptographic fingerprint:

aws ec2 describe-key-pairs \
  --key-names "Production-Server-Key" \
  --query "KeyPairs[*].[KeyName, KeyType, KeyFingerprint]" \
  --output table \
  --profile cloud-dev-admin

Expected Output:

--------------------------------------------------------------------------------------------------------------------------------
|                                                       DescribeKeyPairs                                                       |
+------------------------+----------+------------------------------------------------------------------------------------------+
|  Production-Server-Key |  ed25519 |  SHA256:d8a9f...your-key-fingerprint...                                                 |
+------------------------+----------+------------------------------------------------------------------------------------------+

Phase 2: Stateful Security Groups vs. Stateless Network ACLs (NACLs)

The Architecture: Stateful vs. Stateless Firewalls

AWS provides two layers of firewalls inside a VPC. Mastering the difference between them is crucial:

+-----------------------------------------------------------------------------------+
|                   Security Groups vs. Network ACLs Architecture                   |
+-----------------------------------------------------------------------------------+

 1. SECURITY GROUPS (Instance Level - Stateful):
    * Operates at the virtual network interface (ENI) level.
    * STATEFUL: If you allow inbound traffic on Port 80 (HTTP), the outbound response
      is AUTOMATICALLY allowed back to the client, regardless of outbound rules!
    * Evaluates ALL rules before making a decision (Default: Deny all inbound).

 2. NETWORK ACLs (Subnet Level - Stateless):
    * Operates at the subnet boundary.
    * STATELESS: Inbound and outbound traffic are evaluated independently!
    * If you allow inbound Port 80, you MUST explicitly allow outbound return traffic
      on ephemeral ports (1024-65535), or the client will timeout!
    * Evaluates rules in strict numerical order (Rule 100, 200, etc.).

The Power of Security Group Chaining:

In enterprise environments, you never hardcode internal IP addresses in firewall rules. Instead, you chain Security Groups together:

 [ Public Load Balancer (ALB-SG) ]
            |
            | (Allow inbound 443 from 0.0.0.0/0)
            v
 [ Backend Application Server (App-SG) ]
            | (Allow inbound 8080 ONLY if source == "ALB-SG")
            v
 [ Database Server (DB-SG) ]
              (Allow inbound 5432 ONLY if source == "App-SG")

Step 1: Create the Web Server Security Group

SG_ID=$(aws ec2 create-security-group \
  --group-name "WebServer-SG" \
  --description "Security group for public web servers with HTTP and restricted SSH" \
  --vpc-id "$VPC_ID" \
  --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=WebServer-SG}]' \
  --profile cloud-dev-admin \
  --query "GroupId" \
  --output text)

Step 2: Configure Inbound Firewall Rules

1. Fetch your current public IP address:

MY_IP=$(curl -s https://checkip.amazonaws.com)

2. Authorize Inbound HTTP (Port 80) from the world:

aws ec2 authorize-security-group-ingress \
  --group-id "$SG_ID" \
  --protocol tcp \
  --port 80 \
  --cidr "0.0.0.0/0" \
  --profile cloud-dev-admin

3. Authorize Inbound SSH (Port 22) from your IP only (/32):

aws ec2 authorize-security-group-ingress \
  --group-id "$SG_ID" \
  --protocol tcp \
  --port 22 \
  --cidr "${MY_IP}/32" \
  --profile cloud-dev-admin

🔍 How to Validate Security Group Rules:

aws ec2 describe-security-groups \
  --group-ids "$SG_ID" \
  --query "SecurityGroups[0].IpPermissions[*].[IpProtocol, FromPort, ToPort, IpRanges[0].CidrIp]" \
  --output table \
  --profile cloud-dev-admin

Expected Output:

---------------------------------------------
|           DescribeSecurityGroups          |
+-------+-----+-----+-----------------------+
|  tcp  |  80 |  80 |  0.0.0.0/0            |
|  tcp  |  22 |  22 |  203.0.113.50/32      |
+-------+-----+-----+-----------------------+

Why We Only Configure Security Groups and Keep Default NACLs

You might wonder: If AWS has both Security Groups and Network ACLs, why did we only create a Security Group?

+-----------------------------------------------------------------------------------+
|                  Security Group vs. Network ACL Decision Matrix                   |
+-----------------------------------------------------------------------------------+

 [ Subnet Boundary ] ---> [ Default Network ACL: Allow ALL Inbound & Outbound ]
                                    |
                                    v (Hands off granular filtering to instance)
 [ EC2 Instance ] ------> [ Security Group: Allow HTTP (80) & Restricted SSH (22) ]

The 3 Reasons Security Groups Handle 95% of Day-to-Day AWS Security:

  1. Stateful Simplicity: Security Groups automatically track return connections. With stateless NACLs, you must explicitly calculate and allow outgoing ephemeral port ranges (1024–65535), where a single mistake breaks OS package downloads (dnf update) or drops web responses.
  2. Instance-Level Isolation: NACLs apply to the entire subnet indiscriminately. Security Groups apply to individual virtual network interfaces, letting you run web servers and internal workers in the same subnet with totally different rules.
  3. Security Group Chaining: Security Groups can reference other Security Group IDs directly (e.g. App-SGDB-SG). NACLs cannot reference security groups—they only understand raw IP addresses and CIDR blocks.

[!TIP] When Do Architects Actually Customize NACLs?

  • Explicit IP Blacklisting: Security Groups only support ALLOW rules (they cannot block a single rogue IP). If an attacker or botnet at 198.51.100.23 is scanning your site, you add a DENY rule in the NACL to drop their packets at the subnet boundary before they ever touch your EC2 instance.
  • Subnet-Wide Guardrails: Enforcing strict subnet isolation across an entire availability zone.

Phase 3: Automated Shell Bootstrapping with User Data

The Architecture: cloud-init Execution Model

When an EC2 instance boots for the first time, AWS runs a utility called cloud-init.

  • User Data scripts execute once during the initial launch cycle.
  • They execute with root (superuser) privileges, so sudo is unnecessary.
  • The execution log is written to /var/log/cloud-init-output.log for troubleshooting.

Step 1: Create the User Data Bootstrapping Script

Let’s author a script that installs Nginx, queries the server’s own metadata using IMDSv2, and generates an HTML landing page:

cat <<'EOF' > user-data.sh
#!/usr/bin/env bash
# Update OS packages
dnf update -y

# Install and start Nginx web server
dnf install -y nginx
systemctl enable --now nginx

# Retrieve IMDSv2 Token for secure metadata querying
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# Fetch instance metadata
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
PRIV_IP=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4)
ARCH=$(uname -m)

# Generate Dynamic Production Landing Page
cat <<HTML > /usr/share/nginx/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>AWS CLI Hardened Compute Node</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
    .card { background: #1e293b; padding: 2.5rem; border-radius: 12px; border: 1px solid #334155; box-shadow: 0 10px 25px rgba(0,0,0,0.5); max-width: 500px; width: 100%; }
    h1 { color: #38bdf8; font-size: 1.5rem; margin-top: 0; border-bottom: 1px solid #334155; padding-bottom: 0.75rem; }
    .stat { display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #1e293b; font-family: monospace; font-size: 0.9rem; }
    .label { color: #94a3b8; }
    .val { color: #4ade80; font-weight: bold; }
    .badge { display: inline-block; padding: 0.25rem 0.75rem; background: #0369a1; color: #e0f2fe; border-radius: 9999px; font-size: 0.75rem; margin-top: 1rem; }
  </style>
</head>
<body>
  <div class="card">
    <h1>🚀 EC2 Node Online (AWS CLI First)</h1>
    <div class="stat"><span class="label">Instance ID:</span><span class="val">${INSTANCE_ID}</span></div>
    <div class="stat"><span class="label">Availability Zone:</span><span class="val">${AZ}</span></div>
    <div class="stat"><span class="label">Private IPv4:</span><span class="val">${PRIV_IP}</span></div>
    <div class="stat"><span class="label">CPU Architecture:</span><span class="val">${ARCH} (Graviton)</span></div>
    <div class="stat"><span class="label">IMDS Security:</span><span class="val">IMDSv2 Enforced</span></div>
    <div class="badge">Bootstrapped via cloud-init User Data</div>
  </div>
</body>
</html>
HTML
EOF

echo "Created user-data.sh"

Phase 4: IAM Instance Profiles & Automated Token Rotation (Credential-Less Compute)

The Architecture: Why Hardcoding Credentials on EC2 is Forbidden

[!CAUTION] The Anti-Pattern: Never run aws configure or store static access keys (AKIA...) inside a server. If the server is compromised or an application log leaks, attackers steal permanent credentials with unrestricted account access.

The Enterprise Pattern: Attach an IAM Instance Profile. The EC2 instance automatically fetches temporary, rotating STS credentials (ASIA...) directly from the local Instance Metadata Service (IMDS).


Understanding the Instance Profile: The Container Bridge

In AWS Identity and Access Management:

  1. An IAM Role cannot be attached directly to an EC2 instance. An IAM Role is an identity definition with trust and permission policies.
  2. An IAM Instance Profile is an AWS IAM container/wrapper that holds the IAM Role and securely exposes it to the EC2 compute hypervisor.
  3. Console Magic vs. CLI Reality: When you attach a role to an EC2 instance in the AWS Web Console, the console secretly creates an Instance Profile with the exact same name behind the scenes. In the AWS CLI and Infrastructure-as-Code (Terraform/CloudFormation), you must explicitly create the IAM Role, create the Instance Profile, and link them together.
+-----------------------------------------------------------------------------------+
|                        IAM Instance Profile Architecture                          |
+-----------------------------------------------------------------------------------+

 [ EC2 Compute Instance ]
           |
           v
 [ Instance Profile: EC2S3ReadOnlyProfile ]  <-- Container wrapper exposed to EC2
           |
           v
 [ IAM Role: EC2S3ReadOnlyRole ]              <-- Defines Permissions & Trust Policy
           |
           v (Mints short-lived temporary credentials)
 [ AWS STS (Security Token Service) ]

How Automated 6-Hour Credential Rotation Works Under the Hood

When you attach an Instance Profile to an EC2 instance, two critical lifecycle behaviors take place:

  1. Persistent Association: The Instance Profile association stays permanently attached to the EC2 instance across reboots, stops, and starts. It remains in place until you explicitly detach or replace it via aws ec2 disassociate-iam-instance-profile.
  2. Transparent 6-Hour Token Rotation:
    • Hour 0 (Boot): The instance boots up. The operating system, AWS SDKs, or AWS CLI query the local link-local metadata address (http://169.254.169.254/latest/meta-data/iam/security-credentials/EC2S3ReadOnlyRole).
    • STS Token Generation: AWS STS mints a temporary credential set (AccessKeyId starting with ASIA..., SecretAccessKey, and Token) with a standard 6-hour validity window.
    • In-Memory Caching: AWS SDKs (Boto3, Node.js, Go SDK, Java) and the AWS CLI cache these credentials in memory. No credentials are ever written to the physical EBS disk!
    • Automated Background Refresh (Hour 5:45): As the 6-hour expiration timestamp approaches (typically 15 minutes before expiry), the AWS SDK or CLI automatically queries IMDS in the background to fetch a fresh 6-hour token pair.
    • Zero Application Downtime: Your backend applications and daemons continue running uninterrupted forever without ever needing manual key rotation or service restarts!
+-----------------------------------------------------------------------------------+
|                  Automated STS Credential Rotation Inside EC2                     |
+-----------------------------------------------------------------------------------+

 1. Initial Boot (Hour 0):
    EC2 / AWS SDK ===> Requests credentials from IMDS (169.254.169.254)
    IMDS          ===> Returns temporary keys: `ASIA...` (Valid for 6 hours)

 2. Running Workload (Hours 0 to 5.75):
    Your applications use the cached `ASIA...` keys in memory.

 3. Auto-Refresh Window (Hour 5:45 - 15 mins before expiry):
    AWS SDK automatically detects approaching expiration.
    Calls IMDS in background & receives a fresh 6-hour token pair!

 4. Zero Downtime:
    Applications run continuously with zero disk storage and zero key leakage risk.

Step 1: Create the IAM Role for EC2

Create the trust policy granting ec2.amazonaws.com permission to assume the role:

cat <<'EOF' > ec2-trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
  --role-name "EC2S3ReadOnlyRole" \
  --assume-role-policy-document file://ec2-trust-policy.json \
  --description "Role attached to EC2 instance for read-only S3 access" \
  --profile cloud-dev-admin

# Attach AWS managed read-only policy
aws iam attach-role-policy \
  --role-name "EC2S3ReadOnlyRole" \
  --policy-arn "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" \
  --profile cloud-dev-admin

rm -f ec2-trust-policy.json

Step 2: Create the IAM Instance Profile & Attach the Role

Now, provision the Instance Profile container and link our IAM Role to it:

# 1. Create the Instance Profile container
aws iam create-instance-profile \
  --instance-profile-name "EC2S3ReadOnlyProfile" \
  --profile cloud-dev-admin

# 2. Add the Role to the Instance Profile container
aws iam add-role-to-instance-profile \
  --instance-profile-name "EC2S3ReadOnlyProfile" \
  --role-name "EC2S3ReadOnlyRole" \
  --profile cloud-dev-admin

echo "Configured IAM Instance Profile: EC2S3ReadOnlyProfile"

Phase 5: Launching & Hardening the EC2 Instance with IMDSv2

The Architecture: The Capital One SSRF Breach & IMDSv2

In 2019, a major financial institution suffered a massive breach due to a Server-Side Request Forgery (SSRF) vulnerability in a web application.

  • The Flaw in IMDSv1: IMDSv1 allowed simple HTTP GET requests (http://169.254.169.254/latest/meta-data/iam/security-credentials/). Attackers exploiting SSRF could trick the web application into fetching the instance’s IAM role keys directly.
  • The IMDSv2 Solution: IMDSv2 mandates a session-oriented token handshake via PUT with a custom HTTP header (X-aws-ec2-metadata-token). Standard SSRF attacks cannot forge PUT requests with custom headers.
  • Hop Limit Restriction: Setting HttpPutResponseHopLimit=1 prevents containers running on the instance from reaching the host’s metadata service.
+-----------------------------------------------------------------------------------+
|                            IMDSv1 vs. IMDSv2 Security                             |
+-----------------------------------------------------------------------------------+

 IMDSv1 (Vulnerable):
 [ Attacker (SSRF) ] ---> GET http://169.254.169.254/meta-data/ ---> [ IAM Keys Leaked! ]

 IMDSv2 (Hardened):
 1. PUT http://169.254.169.254/latest/api/token (Header: X-aws-ec2-metadata-token-ttl: 21600)
    * Returns cryptographic Session Token
 2. GET http://169.254.169.254/latest/meta-data/ (Header: X-aws-ec2-metadata-token: $TOKEN)
    * Blocks all unauthorized SSRF proxy requests!

Step 1: Launch the Hardened EC2 Instance

Launch the instance with all security flags enforced:

INSTANCE_ID=$(aws ec2 run-instances \
  --image-id "$AMI_ID" \
  --instance-type "t4g.micro" \
  --key-name "Production-Server-Key" \
  --security-group-ids "$SG_ID" \
  --subnet-id "$PUB_SUB_A" \
  --user-data file://user-data.sh \
  --iam-instance-profile Name="EC2S3ReadOnlyProfile" \
  --metadata-options "HttpTokens=required,HttpPutResponseHopLimit=1,HttpEndpoint=enabled" \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=Production-WebServer-01},{Key=Environment,Value=Production}]' \
  --profile cloud-dev-admin \
  --query "Instances[0].InstanceId" \
  --output text)

Step 2: Wait for System & Instance Status Checks (2/2 Passed)

AWS performs two automated checks:

  1. System Status Check: Verifies physical AWS hardware and network connectivity.
  2. Instance Status Check: Verifies operating system boot, file system mounting, and network stack initialization.
aws ec2 wait instance-running \
  --instance-ids "$INSTANCE_ID" \
  --region ap-southeast-1 \
  --profile cloud-dev-admin

aws ec2 wait instance-status-ok \
  --instance-ids "$INSTANCE_ID" \
  --region ap-southeast-1 \
  --profile cloud-dev-admin

(Optional) You can also query the 2/2 status checks directly without waiting:

aws ec2 describe-instance-status \
  --instance-ids "$INSTANCE_ID" \
  --region ap-southeast-1 \
  --profile cloud-dev-admin \
  --query "InstanceStatuses[0].[SystemStatus.Status, InstanceStatus.Status]" \
  --output table

🔍 How to Validate Live Ingress Traffic:

Retrieve the allocated public IPv4 address and send a live HTTP request from your terminal:

# 1. Fetch Public IPv4
PUBLIC_IP=$(aws ec2 describe-instances \
  --instance-ids "$INSTANCE_ID" \
  --region ap-southeast-1 \
  --profile cloud-dev-admin \
  --query "Reservations[0].Instances[0].PublicIpAddress" \
  --output text)

echo "Public IPv4 Address: http://${PUBLIC_IP}"

# 2. Curl the Web Server
curl -s "http://${PUBLIC_IP}" | grep -E "Instance ID|CPU Architecture|IMDS Security"

Expected Terminal Output:

<div class="stat"><span class="label">Instance ID:</span><span class="val">i-0123456789abcdef</span></div>
<div class="stat"><span class="label">CPU Architecture:</span><span class="val">aarch64 (Graviton)</span></div>
<div class="stat"><span class="label">IMDS Security:</span><span class="val">IMDSv2 Enforced</span></div>

🌐 View the Live Page in Your Web Browser:

You can also open Chrome, Safari, or Firefox and paste your server’s address into the URL bar:

http://YOUR_PUBLIC_IP

[!IMPORTANT] Make sure to type http:// and NOT https://: Modern browsers automatically attempt to prepend https:// (Port 443). Because our bootstrapped Nginx web server is currently configured for standard HTTP on Port 80 (we cover SSL/TLS certificates and HTTPS in Part 10), make sure you explicitly type http:// so the browser connects on Port 80.

You will see the dark-themed dashboard rendered live by your Graviton EC2 instance in Singapore!

Ingress, User Data bootstrapping, Graviton ARM64 compute, and IMDSv2 token protection verified live!


Phase 6: Advanced Architectural Patterns: Placement Groups & Hibernation

1. EC2 Placement Groups (Physical Hardware Topologies)

When launching multiple instances, you can control how AWS physically distributes them across server racks inside an Availability Zone:

+-----------------------------------------------------------------------------------+
|                             EC2 Placement Groups                                  |
+-----------------------------------------------------------------------------------+

 1. CLUSTER (Low-Latency HPC):
    * Packs instances into the same physical server rack.
    * Delivers <10 microsecond network latency and 100 Gbps network bandwidth.
    * Use Case: High-Performance Computing (HPC), machine learning distributed training.

 2. SPREAD (Maximum Redundancy):
    * Places each instance on strictly distinct physical hardware racks with independent power.
    * Limit: Maximum 7 instances per AZ.
    * Use Case: Critical singleton database nodes or quorum master nodes.

 3. PARTITION (Large-Scale Distributed Systems):
    * Divides an AZ into logical partitions (racks). Instances in Partition 1 do not share
      hardware with Partition 2.
    * Use Case: Apache Kafka, Cassandra, Hadoop HDFS clusters.

2. EC2 Hibernation

Instead of stopping an instance (which wipes volatile memory), EC2 Hibernation freezes the active RAM state and writes it directly to the root EBS volume.

  • Sub-Second Cold Starts: When started, the instance skips operating system kernel initialization and reloads its in-memory processes immediately.
  • Requirement: Must use an encrypted root EBS volume (--block-device-mappings) to protect in-memory cryptographic keys and state.

Phase 7: Automated Teardown & Resource Cleanup

When you finish your lab session, clean up all compute resources created in this guide:

# 1. Terminate the EC2 Instance
echo "Terminating instance $INSTANCE_ID..."
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID" --region ap-southeast-1 --profile cloud-dev-admin
aws ec2 wait instance-terminated --instance-ids "$INSTANCE_ID" --region ap-southeast-1 --profile cloud-dev-admin

# 2. Delete the Security Group
echo "Deleting security group $SG_ID..."
aws ec2 delete-security-group --group-id "$SG_ID" --region ap-southeast-1 --profile cloud-dev-admin

# 3. Delete the Key Pair and Local PEM File
echo "Deleting key pair..."
aws ec2 delete-key-pair --key-name "Production-Server-Key" --region ap-southeast-1 --profile cloud-dev-admin
rm -f Production-Server-Key.pem user-data.sh

# 4. Clean up IAM Instance Profile and Role
echo "Cleaning up IAM instance profile and role..."
aws iam remove-role-from-instance-profile --instance-profile-name "EC2S3ReadOnlyProfile" --role-name "EC2S3ReadOnlyRole" --profile cloud-dev-admin 2>/dev/null || true
aws iam delete-instance-profile --instance-profile-name "EC2S3ReadOnlyProfile" --profile cloud-dev-admin 2>/dev/null || true
aws iam detach-role-policy --role-name "EC2S3ReadOnlyRole" --policy-arn "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" --profile cloud-dev-admin 2>/dev/null || true
aws iam delete-role --role-name "EC2S3ReadOnlyRole" --profile cloud-dev-admin 2>/dev/null || true

# 5. Clean up Public VPC, Subnet, and IGW
echo "Cleaning up VPC and network resources..."
if [ -n "$PUB_SUB_A" ]; then
  aws ec2 delete-subnet --subnet-id "$PUB_SUB_A" --region ap-southeast-1 --profile cloud-dev-admin 2>/dev/null || true
fi
if [ -n "$PUB_RTB_ID" ]; then
  aws ec2 delete-route-table --route-table-id "$PUB_RTB_ID" --region ap-southeast-1 --profile cloud-dev-admin 2>/dev/null || true
fi
if [ -n "$IGW_ID" ] && [ -n "$VPC_ID" ]; then
  aws ec2 detach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" --region ap-southeast-1 --profile cloud-dev-admin 2>/dev/null || true
  aws ec2 delete-internet-gateway --internet-gateway-id "$IGW_ID" --region ap-southeast-1 --profile cloud-dev-admin 2>/dev/null || true
fi
if [ -n "$VPC_ID" ]; then
  aws ec2 delete-vpc --vpc-id "$VPC_ID" --region ap-southeast-1 --profile cloud-dev-admin 2>/dev/null || true
fi

echo "=== Teardown Complete! Zero Compute or Network Resources Remaining. ==="

Quick Reference: Essential EC2 CLI Commands

ActionCLI Command
Query Latest AMI via SSMaws ssm get-parameter --name <path> --query "Parameter.Value"
Create ED25519 Key Pairaws ec2 create-key-pair --key-name <name> --key-type ed25519
Create Security Groupaws ec2 create-security-group --group-name <name> --vpc-id <vpc>
Authorize Inbound Ruleaws ec2 authorize-security-group-ingress --group-id <sg> --protocol tcp --port <port> --cidr <cidr>
Launch Hardened EC2aws ec2 run-instances --image-id <ami> --instance-type <type> --metadata-options "HttpTokens=required"
Wait for Status 2/2 OKaws ec2 wait instance-status-ok --instance-ids <id>
Terminate Instanceaws ec2 terminate-instances --instance-ids <id>

Summary & What’s Next

In this seventh installment of AWS with CLI, we launched our first production-ready compute node:

  1. We evaluated Graviton ARM64 vs. x86_64 and dynamically queried the latest Amazon Linux 2023 AMI via SSM Parameter Store.
  2. We generated modern ED25519 Key Pairs and mastered the difference between Stateful Security Groups and Stateless NACLs.
  3. We automated web server deployment using cloud-init User Data.
  4. We eliminated hardcoded access keys using IAM Instance Profiles.
  5. We enforced Token-Based IMDSv2 to protect our node against SSRF credential theft.

Now that we know how to launch secure individual compute nodes, how do we manage persistent block storage, encrypt data at rest, and bake reusable machine images?

In Part 8, we explore Persistent Block Storage & Golden Images: EBS Volumes, KMS & Custom AMIs, learning how to attach secondary gp3 volumes, expand storage live without downtime, and bake custom Golden AMIs via the CLI!

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.