JB logo
CoffeeyOUTUBE
Blog
Next

AWS DevOps: A Complete Study Guide

A structured, chapter-by-chapter study guide to AWS DevOps — cloud fundamentals, VPCs, subnets and routing, EC2, RDS and beyond — built while deploying a real-world app end to end. Notes, command snippets and the mental models, as a companion to Boot.dev's AWS DevOps course.

☁️ AWS DevOps Course — Complete Study Guide

Deploying "Patient Ping" — A Real-World AWS Infrastructure Walkthrough

A polished, structured companion guide built from the full course transcript — Boot.dev's AWS DevOps course, taught by Zach Gates. Follow along in your own AWS account using the steps, notes, and command snippets below.


📚 Table of Contents

  1. Course Overview & Goals
  2. Chapter 1 — Cloud Computing Fundamentals
  3. Chapter 2 — Networking: VPCs, Subnets & Routing
  4. Chapter 3 — EC2 (Elastic Compute Cloud)
  5. Chapter 4 — RDS (Relational Database Service)
  6. Chapter 5 — IAM (Identity & Access Management)
  7. Chapter 6 — Monitoring: CloudWatch
  8. Chapter 7 — DNS: Route 53
  9. Chapter 8 — S3 (Simple Storage Service)
  10. Chapter 9 — CDN: CloudFront
  11. Chapter 10 — ECS (Elastic Container Service)
  12. Chapter 11 — Serverless: Lambda
  13. Full Command Cheat Sheet
  14. Cleanup Checklist

1. Course Overview & Goals

This course builds Patient Ping, a fictional healthcare appointment-reminder web app, from a simple Python script into a fully deployed, production-style AWS stack — step by step, in a real AWS account.

What you'll build, end to end

  • A custom VPC with public/private subnets across two Availability Zones
  • An EC2 server running the app, with SSH keys, Elastic IPs, and security groups
  • A PostgreSQL database on RDS, kept private, with backups and a read replica
  • IAM users, groups, roles, and policies (least-privilege access)
  • CloudWatch monitoring, dashboards, alarms, and log shipping
  • Route 53 DNS records (A, CNAME) and S3 object storage
  • CloudFront CDN in front of S3 assets, with pre-signed URLs
  • A Dockerized version of the app running on ECS with Fargate behind an Application Load Balancer
  • A standalone Lambda function behind API Gateway

💰 Cost expectations

  • Most of the course fits inside the AWS Free Tier.
  • Even without free tier, total cost should be well under $10 if completed within a few weeks.
  • Cleanup lessons matter — an idle load balancer, NAT gateway, or forgotten Elastic IP will happily keep billing you forever. Don't skip them.

🧰 Prerequisites

  • A bash/ZSH shell (WSL is fine on Windows)
  • aws-cli v2 installed and configured
  • Docker installed (for the ECS chapter)
  • An AWS account with a root user (used only to bootstrap an admin IAM user — never for daily work)

2. Chapter 1 — Cloud Computing Fundamentals

What is "the cloud," really?

A collection of servers, owned by someone else, that you rent by the hour/month and access over the internet. Nothing magical — it's the evolution of a simple idea:

Physical servers  ->  Virtualization (one big box, many virtual   ->  Cloud (rent someone
(buy hardware,        servers via a hypervisor: Citrix, VMware)       else's virtualized
manage it yourself)                                                    servers, in their
                                                                         data center)

AWS Account Setup — best practice flow

Root Account (god mode)
      |
      | (only used once, to bootstrap)
      v
Create an IAM Admin User  --->  Create an "administrators" Group
      |                              |
      |                              v
      |                         Attach an AdministratorAccess Policy
      v
Generate API Access Keys (for CLI use)
      |
      v
Enable MFA on the root account (and ideally your admin user too)

Key rule: Never do daily work as the root user. Create an IAM user, put it in an admin group, and use that for everything — including CLI access via generated API keys.

# Verify who you're authenticated as at any time:
aws sts get-caller-identity
 
# Common pattern used throughout the course (via aws-vault):
aws-vault exec <profile-name> -- aws sts get-caller-identity

Why choose cloud infrastructure? (Pros)

ReasonWhy it matters
ScalabilityPay only for what you use now; scale up later without buying hardware upfront
ReliabilityCloud data centers have diesel generators, redundant power/network — better than most home/office setups
Off-the-shelf productsYou're not just renting compute — S3, Lambda, CloudFront, etc. are pre-built solutions you'd otherwise have to build yourself

Reasons to think twice (Cons)

ReasonWhy it matters
Vendor lock-inDeep integration with one cloud's proprietary services can make leaving expensive/hard
Cost at scaleVery large, stable workloads are sometimes cheaper on owned hardware (see: DHH/37signals moving off cloud)
ComplexityMany services, many acronyms, steep learning curve
Security surfaceEasy to accidentally leave "the back door open" (public S3 buckets, open security groups, etc.)

OpEx vs. CapEx: Cloud spend is operational expense (pay as you go) vs. buying hardware which is capital expense (large upfront cost, amortized over time).

Regions vs. Availability Zones

              REGION (e.g., us-east-1 / N. Virginia)
   +-------------------------------------------------------+
   |                                                          |
   |   +----------------+        +----------------+          |
   |   |  AZ: us-east-1a |        |  AZ: us-east-1b |          |
   |   |  (data center)  |        |  (data center)  |          |
   |   |  own generators, |       |  own generators, |         |
   |   |  own network     |       |  own network     |         |
   |   +----------------+        +----------------+          |
   |                                                          |
   +-------------------------------------------------------+
  • A Region is a distinct geographic area (e.g., N. Virginia, Ireland, Tokyo). Traffic between regions is treated like traffic to a completely different network.
  • An Availability Zone (AZ) is essentially a separate physical data center within a region — its own power, generators, and network drops. If one AZ has an outage, others in the region keep running.
  • ⚠️ Your AZ name (e.g., us-east-1a) is mapped differently per AWS account. AWS deliberately shuffles which physical AZ maps to "1a" for each customer, so two companies' "us-east-1a" may be different physical locations.
  • Only use multiple AZs if you actually need the redundancy — extra AZs mean extra data-transfer costs and complexity. A small side project usually doesn't need it.
# List all available regions:
aws ec2 describe-regions --output json
 
# Set your default region (used throughout the course: us-east-1):
aws configure set region us-east-1

💡 Bandwidth is often your biggest AWS bill line item — not compute, not storage. Watch data transfer between AZs, regions, and out to the internet.


3. Chapter 2 — Networking: VPCs, Subnets & Routing

VPC = your private network in AWS

A VPC (Virtual Private Cloud) is the top-level network container. You don't have to pre-plan every subnet on day one — you can always add new CIDR ranges later.

# No CLI needed for basic VPC creation in this course — done via console.
# But you could do it with:
aws ec2 create-vpc --cidr-block 10.0.0.0/22 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=patient-ping}]'

⚠️ Never use the AWS "default VPC" that gets auto-created in every account/region. It represents accepted defaults for every networking primitive — do your networking on purpose.

CIDR blocks — the "rule of eights"

A CIDR block like 10.0.0.0/22 designates how many IP addresses live in your network. The number after the / is the number of locked bits; the smaller that number, the more addresses are available.

/32  =        1 address   (fully locked — a single host)
/24  =      256 addresses (last octet open)
/16  =   65,536 addresses (last two octets open)
/8   = 16.7M   addresses (last three octets open)
 
Rule of thumb: every time the CIDR number drops by 8,
the address count multiplies by 256.
Every time it drops by 1, the address count DOUBLES.
  • 10.15.255.0/16 → the /16 (not the 10.15.255.0) tells you how big the block is; the leading numbers tell you where it starts (the first address in the range).
  • 0.0.0.0/0 = literally every IP address on the internet — used as the "default route" catch-all.

Subnetting — splitting the VPC

VPC: patient-ping (10.0.0.0/22 = 1,024 addresses)
        |
        +--- Public  Subnet A  (10.0.0.0/24)   -- AZ us-east-1a
        +--- Public  Subnet B  (10.0.1.0/24)   -- AZ us-east-1b
        +--- Private Subnet A  (10.0.2.0/24)   -- AZ us-east-1a
        +--- Private Subnet B  (10.0.3.0/24)   -- AZ us-east-1b
  • Subnets are free — no cost just for creating them.
  • Split subnets across at least two AZs so a single data-center outage doesn't take down your whole app.
  • Public vs. private is entirely determined by whether the subnet has a route to the internet — nothing more mystical than that.
aws ec2 create-subnet --vpc-id <vpc-id> \
  --cidr-block 10.0.0.0/24 --availability-zone us-east-1a \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=patient-ping-public-a}]'

Internet Gateway + Route Tables

      INTERNET
         |
   [Internet Gateway]   <-- attached to the VPC (not a subnet directly)
         |
   [Public Route Table] -- default route 0.0.0.0/0 -> Internet Gateway
         |
   +-----+-----+
   |           |
Public A    Public B  (subnets associated with this route table)
  • An Internet Gateway is like a giant extension cord plugged into your VPC that gives it access to the public internet.
  • A route table is a map of where traffic is allowed to go. Attach a default route (0.0.0.0/0) pointing at the Internet Gateway to make a subnet's traffic internet-routable.
  • A route table only takes effect once associated with a subnet — creating the table alone does nothing.
# Create + attach an Internet Gateway
aws ec2 create-internet-gateway --tag-specifications \
  'ResourceType=internet-gateway,Tags=[{Key=Name,Value=patient-ping-igw}]'
aws ec2 attach-internet-gateway --vpc-id <vpc-id> --internet-gateway-id <igw-id>
 
# Create a route table, add default route, associate with public subnets
aws ec2 create-route-table --vpc-id <vpc-id>
aws ec2 create-route --route-table-id <rtb-id> \
  --destination-cidr-block 0.0.0.0/0 --gateway-id <igw-id>
aws ec2 associate-route-table --route-table-id <rtb-id> --subnet-id <public-subnet-id>

Private subnets & NAT Gateways

Private Subnet  --->  [NAT Gateway]  --->  [Internet Gateway]  --->  Internet
(no direct inbound        (lives in a
 internet access)          PUBLIC subnet)
  • A NAT (Network Address Translation) Gateway lets private-subnet resources reach out to the internet (for updates, package downloads, etc.) without being reachable from the internet.
  • The NAT repackages outbound traffic under its own IP, sends it out, and unpacks the response — so nobody outside can "reply" directly to your private servers.
  • ⚠️ NAT Gateways are one of the biggest AWS cost traps:
    1. They cost money every hour, whether used or not.
    2. They add a per-gigabyte surcharge on top of normal internet data transfer costs.
  • Tear down NAT Gateways as soon as you're done with them in this course (and in general, whenever a private network doesn't need constant outbound internet).
# Requires an Elastic IP + a public subnet to live in:
aws ec2 allocate-address --domain vpc
aws ec2 create-nat-gateway --subnet-id <public-subnet-id> --allocation-id <eip-alloc-id>
 
# Private route table -> default route -> NAT Gateway (not Internet Gateway!)
aws ec2 create-route --route-table-id <private-rtb-id> \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id <natgw-id>

4. Chapter 3 — EC2 (Elastic Compute Cloud)

SSH Keys — your access credential

# Generate a keypair locally:
ssh-keygen -t ed25519 -f ~/.ssh/patient-ping-key
 
# Import the PUBLIC key into AWS:
aws ec2 import-key-pair --key-name patient-ping-key \
  --public-key-material fileb://~/.ssh/patient-ping-key.pub
  • AWS stores your public key; you keep the private key locally to prove identity.
  • Never lose your only copy of the private key — without it, that server becomes inaccessible unless you rebuild it.

Launching an Instance — what each choice means

   AMI (Amazon Machine Image)          Instance Type (e.g., t3.micro)
   "what OS + software is on the        "how much CPU / RAM / network
    disk" — like an ISO/template         is allocated"
            \                                    /
             \                                  /
              v                                v
                    +-------------------+
                    |   EC2 Instance     |
                    +-------------------+
  • AMI = Amazon Machine Image — a template containing the OS (and sometimes pre-installed software). Free AMIs exist for Amazon Linux, Ubuntu, etc. Paid/marketplace AMIs (Windows, Red Hat, PaloAlto firewalls, etc.) add extra cost.
    • You can build your own AMI from a running server — a great way to snapshot a fully-configured machine for repeatable deployments or backups.
  • Instance type naming (e.g., t3.micro, m4.xlarge) breaks down as:
    • Letter = family (purpose): M = general purpose, R = memory-optimized, G = Graviton/ARM, GPU families exist too.
    • Number = generation (higher is usually newer/cheaper/better).
    • Size (.micro, .xlarge, etc.) = the amount of CPU/RAM.
    • T-family instances are burstable — cheap, low baseline performance with the ability to burst higher briefly. Great for spiky/testing workloads.
  • 🔧 Handy tool: EC2 Instance Info (community pricing/spec lookup site) — compare cost and specs across instance families before choosing.
aws ec2 run-instances \
  --image-id <ami-id> --instance-type t3.micro \
  --key-name patient-ping-key --subnet-id <public-subnet-id> \
  --security-group-ids <sg-id> \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=patient-ping-web}]'

Public vs. Private IP addresses

Private IP (10.x / 172.16-31.x / 192.168.x)     Public IP (everything else)
   - RFC 1918 reserved ranges                       - routable across the internet
   - dropped by ISPs if sent externally              - required for internet-facing servers
   - only usable within your own VPC

Elastic IP Addresses

  • A public IP that you can attach ("associate") to any instance or resource — and detach/reattach at will.
  • Historically AWS gave a free public IP per instance; today every allocated public IP costs money, whether attached or not. Only allocate one when you actually need it.
  • Because it's decoupled from any one instance, an Elastic IP lets you swap the underlying server without changing the address clients connect to (huge for zero-downtime replacements).
aws ec2 allocate-address --domain vpc
aws ec2 associate-address --instance-id <instance-id> --allocation-id <eip-alloc-id>
aws ec2 disassociate-address --association-id <assoc-id>
aws ec2 release-address --allocation-id <eip-alloc-id>

Security Groups — your instance-level firewall

  • A software-defined firewall: allow-list by default — if a rule doesn't explicitly permit traffic, it's denied.
  • Define inbound rules (what can reach the server) and outbound rules (what the server can reach).
  • Reference CIDR blocks (specific IP ranges) or other security groups (e.g., "allow traffic from anything in the patient-ping-public SG") — the latter is powerful for tiered architectures (web tier ↔ app tier ↔ database tier).
  • Multiple security groups can be attached to one instance, letting you compose access rules (e.g., a "developer SSH access" group + an "app traffic" group).
aws ec2 create-security-group --group-name patient-ping-public \
  --description "public web access" --vpc-id <vpc-id>
 
aws ec2 authorize-security-group-ingress \
  --group-id <sg-id> --protocol tcp --port 22 --cidr <your-ip>/32
 
# Open to the whole world (needed once you want public traffic on 8080):
aws ec2 authorize-security-group-ingress \
  --group-id <sg-id> --protocol tcp --port 8080 --cidr 0.0.0.0/0

Connecting via SSH

ssh -i ~/.ssh/patient-ping-key ec2-user@<elastic-ip>
  • The default login user depends on the AMI (ec2-user for Amazon Linux, ubuntu for Ubuntu AMIs, etc.).
  • The very first connection triggers a host-key fingerprint prompt — this protects you from silently connecting to an impostor server.
  • 🧠 Use an SSH config file to avoid retyping IPs, users, and key paths every time:
# ~/.ssh/config
Host patient-ping
    HostName 34.xxx.xxx.xxx
    User ec2-user
    IdentityFile ~/.ssh/patient-ping-key
# Now you can just run:
ssh patient-ping
 
# If you replace the server and get a "host key changed" warning
# (expected after using a new AMI/instance), regenerate the known-hosts entry:
ssh-keygen -R <old-ip>

Deploying the app on the server

sudo dnf update -y
sudo dnf install -y git
 
# Install uv (modern Python package/dependency manager, from Astral):
curl -LsSf https://astral.sh/uv/install.sh | sh
 
git clone <repo-url>
cd patient-ping
uv sync
uv run patient_ping.py &      # & backgrounds the process so the shell stays usable
curl localhost:8080           # sanity check from inside the server

⚠️ Piping a random install script (curl | sh) straight to your shell is convenient for a throwaway lab server — for anything long-lived, review the script first (supply-chain attacks are real).

Creating your own AMI (backup/cloning pattern)

Running EC2 Instance  --[Create Image]-->  Custom AMI (stored in S3 under the hood)
                                                  |
                                                  v
                                    Launch Template  --[fire N times]-->  N identical servers
aws ec2 create-image --instance-id <instance-id> --name "patient-ping-ami" --no-reboot
  • Creating an AMI performs a filesystem freeze, capturing a consistent snapshot across all attached volumes without necessarily powering off the instance (though powering off first is the safest option).
  • Great as a pre-destroy backup step: snapshot the server before tearing it down, so you can instantly rebuild if something needed it later.

Reserved Instances & Savings Plans

  • Commit to 1 or 3 years of usage in exchange for a 26–72% discount.
  • Best for predictable, long-running workloads. Almost always worth it if you know you'll run something for a year+.
  • Different from capacity reservations (guaranteeing hardware availability), which matters at massive scale (thousands of servers) — most individual users/small teams will never need this.

Launch Templates & Auto Scaling

Launch Template ("the recipe")
     |
     |  (contains: AMI, instance type, key pair, subnet, security group, storage)
     v
Launch N copies on demand  -->  identical, disposable "cattle, not pets" servers
aws ec2 create-launch-template --launch-template-name patient-ping-lt \
  --launch-template-data '{
    "ImageId": "<ami-id>",
    "InstanceType": "t3.micro",
    "KeyName": "patient-ping-key",
    "SecurityGroupIds": ["<sg-id>"]
  }'
 
aws ec2 run-instances --launch-template LaunchTemplateName=patient-ping-lt --count 5
  • A Launch Template is the "recipe card" AWS uses to stamp out identical servers — the EC2 equivalent of an ECS task definition (see Chapter 10).
  • Autoscaling ≠ vertical scaling (bigger instance type). Autoscaling means printing more copies of the same server (horizontal scaling) when load increases, and terminating them when load drops.
  • Autoscaling groups themselves cost nothing — you only pay for the instances they actually launch.

Spot Instances

Spot Instance = discounted "leftover" server capacity
   - up to ~90% cheaper than on-demand
   - AWS can reclaim it with only a 2-minute warning
     if a full-price customer wants that capacity
  • Best for interruption-tolerant workloads: batch jobs, CI runners, stateless worker fleets where losing one node briefly doesn't matter.
  • Not the same thing as a savings plan (long-term commitment) — spot pricing requires no commitment, just tolerance for sudden termination.

Stateful vs. Stateless applications

StatefulStateless
DefinitionThe server itself holds/owns critical dataThe server has no unique data of its own — state lives in an external DB/service
Risk if server diesData loss unless backed up separatelyNone — just replace the server, data is safe elsewhere
Good fit for Spot Instances?❌ risky✅ ideal
ExampleA DB writing to local diskA web server that reads/writes only through an external Postgres DB

💡 Understanding whether your application is stateful or stateless directly determines which AWS cost/reliability levers (Spot Instances, autoscaling, disposable AMIs) are safe to pull.

🧹 EC2 Cleanup Checklist

aws ec2 terminate-instances --instance-ids <id>
aws ec2 disassociate-address --association-id <assoc-id>
aws ec2 release-address --allocation-id <alloc-id>       # Elastic IPs cost money even unattached!
aws ec2 delete-launch-template --launch-template-id <id>
aws ec2 deregister-image --image-id <ami-id>              # + delete associated snapshots

Security groups, VPCs, subnets, and internet gateways are all free — no rush to delete those between lessons.


5. Chapter 4 — RDS (Relational Database Service)

Why use a managed database?

RDS lets AWS (and its database specialists) handle provisioning, patching, backups, and monitoring — the same operational work a dedicated DBA team would do — so you don't have to build that expertise in-house.

# Primary advantage isn't "always cheaper" or "only Postgres" — it's:
# provisioning + backup + patching + monitoring, automated.

Architecture — keep your database private

        Internet
           |
     [Load Balancer / EC2 in PUBLIC subnet]
           |
           |  (only this security group is trusted)
           v
     [RDS Instance in PRIVATE subnet]
  • RDS should almost always live in a private subnet — never expose a database directly to the internet.
  • You must define a DB Subnet Group (which private subnets, across which AZs, the DB is allowed to live in) before creating the database.
aws rds create-db-subnet-group \
  --db-subnet-group-name patient-ping-subnet-group \
  --db-subnet-group-description "private subnets for RDS" \
  --subnet-ids <private-subnet-a-id> <private-subnet-b-id>
 
aws rds create-db-instance \
  --db-instance-identifier patient-ping-db \
  --db-instance-class db.t3.micro \
  --engine postgres \
  --engine-version 18 \
  --master-username postgres \
  --master-user-password '<strong-password>' \
  --allocated-storage 20 \
  --db-subnet-group-name patient-ping-subnet-group \
  --vpc-security-group-ids <rds-sg-id> \
  --no-publicly-accessible

⚠️ "Publicly accessible" should almost always be No. The only traffic that should ever reach RDS is traffic from your application tier, via security group rules — not the open internet.

Connectivity — Security Group chaining

[EC2 Security Group: patient-ping-public]
            |
            | (referenced BY the RDS security group's inbound rule)
            v
[RDS Security Group: patient-ping-rds]
   Inbound: TCP 5432, source = patient-ping-public SG
  • Postgres listens on port 5432 by default.
  • Instead of hardcoding an IP CIDR into the RDS security group, reference the EC2 security group directly — any instance carrying that SG is automatically trusted, regardless of its IP.
aws ec2 authorize-security-group-ingress \
  --group-id <rds-sg-id> --protocol tcp --port 5432 \
  --source-group <ec2-public-sg-id>

Connecting from the server

sudo dnf install -y postgresql   # client tools
 
psql -h <rds-endpoint> -U postgres -d postgres
# once connected, create your app's actual database:
CREATE DATABASE patient_ping;
\q

💡 One RDS instance/cluster can contain many separate logical databases (e.g., dev, staging, prod all on the same underlying server) — you don't need one instance per environment.

Storing the connection string safely

# .env file approach (used early in the course, later replaced by SSM Parameter Store):
echo 'DATABASE_URL=postgres://postgres:<password>@<rds-endpoint>:5432/patient_ping' > .env

This hardcoded .env approach is intentionally revisited and replaced later — see SSM Parameter Store in the IAM chapter, which is the better long-term practice.

Storage & IOPS

General Purpose SSD (gp3)   <- default, good for almost everyone
   baseline: 3,000 IOPS, scales with storage size
 
Provisioned IOPS SSD (io-series)  <- when you need a GUARANTEED,
   fixed, predictable I/O rate regardless of storage size
  • IOPS = Input/Output Operations Per Second — a measure of how many read/write operations the disk can handle.
  • Start with general-purpose (gp3) storage; only move to provisioned IOPS once you have real metrics showing you need guaranteed, predictable throughput (check CloudWatch metrics before guessing).
  • Watch your metrics for a flatlined graph (disk/CPU/memory pegged, can't go higher) or a sawtooth pattern (thrashing) — both signal you need a different storage/instance configuration.

Backups

   Full DB Snapshot                    Transaction Log (Write-Ahead Log)
   "roll the ENTIRE database            "replay every change since
    back to this exact point"            the last snapshot"
            \                                  /
             \                                /
              v                              v
        RDS Automated Backups: restore to ANY point in time
        within your configured retention window
  • Restoring from a backup typically means spinning up a new database instance from the snapshot — you generally can't restore "in place" onto your original instance.
  • If you only need to recover a handful of rows/tables, spin up the restored copy separately, then manually copy just what you need back into production.
  • Decide your retention policy deliberately (e.g., daily backups retained 7 days, one monthly snapshot retained 7 years) — don't just accept defaults.
aws rds create-db-snapshot --db-instance-identifier patient-ping-db \
  --db-snapshot-identifier patient-ping-manual-snapshot

Read Replicas

                    +------------------+
        Writes ---->|  Primary DB      |
                    +--------+---------+
                             |
                     (replication)
                             |
                    +--------v---------+
        Reads  ---->|  Read Replica    |
                    +------------------+
  • A read-only copy of your database that offloads SELECT traffic, keeping the primary free to handle writes at full speed.
  • Great when reads vastly outnumber writes (e.g., a chat app: users read far more messages than they send).
  • Can also be placed in a different region/AZ to reduce read latency for geographically distant users.
  • Read replicas cannot accept write operations (INSERT/UPDATE/DELETE).
aws rds create-db-instance-read-replica \
  --db-instance-identifier patient-ping-replica \
  --source-db-instance-identifier patient-ping-db \
  --no-publicly-accessible

🧹 RDS Cleanup Checklist

aws rds delete-db-instance --db-instance-identifier patient-ping-replica --skip-final-snapshot
aws rds delete-db-instance --db-instance-identifier patient-ping-db --skip-final-snapshot
aws rds delete-db-subnet-group --db-subnet-group-name patient-ping-subnet-group

Security groups don't cost anything — the database itself (billed hourly) is what needs to go before you take an extended break.


6. Chapter 5 — IAM (Identity & Access Management)

The two jobs of IAM

AUTHENTICATION                      AUTHORIZATION
"Are you who you say you are?"      "What are you allowed to do?"
     |                                     |
     v                                     v
 Users / Roles (trust policies)      Policies (permission documents)

Core IAM building blocks

              +-------------+
              |    User     |   <- represents ONE person, long-lived credentials
              +------+------+
                     |
              (belongs to)
                     v
              +-------------+
              |    Group    |   <- a bucket of users, sharing policies
              +------+------+
                     |
              (has attached)
                     v
              +-------------+
              |   Policy    |   <- the actual JSON document granting/denying actions
              +-------------+
 
              +-------------+
              |    Role     |   <- SHORT-LIVED, assumable identity
              +-------------+      (used by services, EC2 instances, Lambdas —
                                     or temporarily by a user needing elevated access)
ConceptWhat it isLifespan
UserAn individual person's identityLong-lived (until deleted)
GroupA container of Users, sharing a set of policiesN/A (management convenience)
PolicyJSON document: Allow/Deny + Action(s) + Resource(s)N/A
RoleA temporary, assumable identity — used by services (EC2, Lambda, ECS) or humans needing short-term elevated accessShort-lived (temporary tokens)

🔐 Why roles matter for security: if an attacker compromises a server using a role-based (temporary) credential, the blast radius is limited to that credential's short lifespan. Compromising a long-lived user credential can grant indefinite access.

Creating a user

aws iam create-user --user-name vincent-vega
# (usernames: alphanumeric + dashes only — no spaces, no leading '@')

Policies — allow/deny JSON

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ec2:Describe*",
      "Resource": "*"
    }
  ]
}
  • Policies support wildcards (ec2:Describe* matches every read-style EC2 API action starting with "Describe").
  • You can scope both the action and the resource (e.g., only this specific security group, only this region).
  • 🧠 Best practice: use Allow statements almost exclusively; keep a separate admin account/role from your daily-driver account so elevated actions are clearly logged and rare.

Inline vs. Group (managed) policies

INLINE POLICY                          GROUP + MANAGED POLICY
- attached directly to ONE user         - policy attached once, to a GROUP
- deleted when the user is deleted      - persists independent of any one user
- must be duplicated per user needing   - update once, applies to everyone in
  the same access (painful to audit)      the group automatically
# The BETTER pattern: create a reusable policy, attach it to a group
aws iam create-policy --policy-name patient-ping-ec2-readers \
  --policy-document file://ec2-readonly-policy.json
 
aws iam create-group --group-name readers
aws iam attach-group-policy --group-name readers --policy-arn <policy-arn>
aws iam add-user-to-group --user-name vincent-vega --group-name readers

Avoid inline policies for anything shared by more than one identity — "what access do all developers have?" should be answerable by checking one group's policies, not auditing every individual user.

Roles — trust policies + attaching to EC2

A role needs two things: (1) a trust policy (who is allowed to assume it) and (2) one or more permission policies (what it can do once assumed).

// Trust policy: "only EC2 instances may assume this role"
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
aws iam create-role --role-name patient-ping-readonly-role \
  --assume-role-policy-document file://ec2-trust-policy.json
aws iam attach-role-policy --role-name patient-ping-readonly-role \
  --policy-arn <ec2-readonly-policy-arn>
 
# Attach the role directly to a running instance:
aws ec2 associate-iam-instance-profile \
  --instance-id <instance-id> \
  --iam-instance-profile Name=patient-ping-readonly-role

Once attached, the instance can run AWS CLI / SDK commands with zero credentials configured — it automatically inherits the role's temporary token:

# On the EC2 instance itself, no keys needed:
aws sts get-caller-identity     # shows the assumed-role identity
aws ec2 describe-instances      # works if the role permits it

⚠️ Once upon a time you couldn't change an instance's attached role after launch — today you can update it live via Security → Modify IAM role in the console (or associate-iam-instance-profile).

Explicit Deny — use sparingly

Deny rules ALWAYS override Allow rules — from ANY policy, group, or role.
{
  "Version": "2012-10-17",
  "Statement": [{ "Effect": "Deny", "Action": "*", "Resource": "*" }]
}
  • A blanket Deny is a fast "kill switch" — e.g., temporarily locking down a compromised role/instance without deleting anything.
  • Generally, prefer granting only the specific Allow permissions needed rather than allowing everything and trying to deny your way back to safety — deny-based architectures get confusing fast and are hard to reason about.
  • Least privilege: regularly audit which permissions are actually used (IAM Access Analyzer / Access Advisor shows last-used timestamps per permission) and trim what's unused.

SSM Parameter Store

/patient-ping/database-url    -> secure string
/patient-ping/cmo-name        -> plain string
  • A simple, mostly-free (up to 10,000 parameters) key-value store for configuration — perfect for things like DB connection strings, feature flags, or any setting you don't want hardcoded into an AMI or .env file.
  • Supports namespacing via path (e.g., /patient-ping/...), which makes writing scoped IAM policies much easier — grant access to a whole prefix instead of every parameter name individually.
  • Supports a SecureString type (encrypted) — use it for secrets like passwords or connection strings.
  • ⚠️ Every value is stored (and returned) as a string. A parameter holding 5432 is the string "5432", not an integer — your application must explicitly cast/parse it before doing arithmetic or type-specific logic.
aws ssm put-parameter --name /patient-ping/database-url \
  --type SecureString --value "postgres://postgres:<pw>@<endpoint>:5432/patient_ping"
 
aws ssm put-parameter --name /patient-ping/cmo-name \
  --type String --value "Dr. Strangelove"
 
# Read it back (server-side, via an IAM role with ssm:GetParameter permission):
aws ssm get-parameter --name /patient-ping/database-url --with-decryption

Example IAM policy scoping access to just these two parameters:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ssm:GetParameter", "ssm:GetParameters"],
      "Resource": [
        "arn:aws:ssm:us-east-1:<account-id>:parameter/patient-ping/database-url",
        "arn:aws:ssm:us-east-1:<account-id>:parameter/patient-ping/cmo-name"
      ]
    }
  ]
}

This replaces the earlier .env-file approach: the app is now stateless — configuration lives in SSM, not baked into any one server's disk. Destroy and recreate the server freely; it will re-fetch its config on boot.

🧹 IAM/SSM Cleanup Checklist

aws iam detach-user-policy / detach-group-policy / detach-role-policy   # then...
aws iam delete-user --user-name vincent-vega
aws iam delete-group --group-name readers
aws iam delete-policy --policy-arn <arn>
aws iam delete-role --role-name patient-ping-readonly-role
aws ssm delete-parameter --name /patient-ping/database-url

IAM resources are free — no cost pressure to delete them quickly, but tidy accounts are easier to audit and reason about.


7. Chapter 6 — Monitoring: CloudWatch

Why monitoring is hard to get right

Too sensitive           -> alert fatigue, real problems get ignored
Not enough detail        -> can't tell WHERE in the app something broke
Hard to test              -> failure scenarios never get simulated ahead of time

Good monitoring should: only alert when it actually matters, include enough detail (trace/transaction IDs) to pinpoint the problem, and be testable via simulated failure conditions.

External vs. Internal metrics

EXTERNAL (free, automatic, no agent needed)     INTERNAL (needs an agent on the instance)
   - CPU utilization                                - disk usage
   - network in/out                                  - memory usage
   (anything the hypervisor can see                  - application-level logs
    from OUTSIDE the guest OS)                        (anything only the GUEST OS knows)
  • External metrics come for free because AWS's hypervisor inherently has to track them (CPU allotment, bursting, network throughput) — no configuration needed.
  • Internal metrics (disk space, memory, custom app logs) require installing the CloudWatch Agent (or an alternative like Prometheus/Grafana) on the instance, which then ships data via the AWS API — meaning the instance needs an IAM role with permission to write to CloudWatch.
# View built-in (external) metrics without installing anything:
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 --metric-name CPUUtilization \
  --dimensions Name=InstanceId,Value=<instance-id> \
  --start-time <ISO8601> --end-time <ISO8601> \
  --period 60 --statistics Average

Installing the CloudWatch Agent (for internal metrics/logs)

sudo dnf upgrade -y
sudo dnf install -y amazon-cloudwatch-agent
 
sudo mkdir -p /opt/aws/amazon-cloudwatch-agent/etc
sudo vi /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
# (paste your agent config — log file paths, log group name, etc.)
 
sudo systemctl start amazon-cloudwatch-agent
sudo systemctl enable amazon-cloudwatch-agent

The instance needs an IAM role (attached the same way as in Chapter 5) permitting, at minimum:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cloudwatch:PutMetricData",
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogStreams"
      ],
      "Resource": "*"
    }
  ]
}

⚠️ Config files run as root — if you get permission errors creating the agent config directory, double-check you're running as root/sudo, not that the directory doesn't exist.

Dashboards & simulating load

# "Occupy a CPU core" trick — floods the CPU with the `yes` command:
yes > /dev/null &
 
# Watch usage climb:
top
aws cloudwatch put-dashboard --dashboard-name patient-ping-dashboard \
  --dashboard-body file://dashboard.json
  • Dashboards and CloudWatch Alarms both cost money per resource — clean up unused ones. (A real cautionary tale from the course: hundreds of forgotten dashboards silently billing an account, unnoticed.)

CloudWatch Alarms

Metric (e.g., CPUUtilization)
       |
   threshold (e.g., > 20% for 1 minute, Average statistic)
       |
       v
   ALARM state  --->  SNS Topic  --->  Email notification
aws sns create-topic --name patient-ping-alerts
aws sns subscribe --topic-arn <topic-arn> --protocol email --notification-endpoint you@example.com
# (you must click the confirmation link AWS emails you before the subscription is active)
 
aws cloudwatch put-metric-alarm \
  --alarm-name patient-ping-high-cpu \
  --metric-name CPUUtilization --namespace AWS/EC2 \
  --statistic Average --period 60 --threshold 20 \
  --comparison-operator GreaterThanThreshold --evaluation-periods 1 \
  --dimensions Name=InstanceId,Value=<instance-id> \
  --alarm-actions <sns-topic-arn>

Alarms show INSUFFICIENT_DATA until enough metric data points have been collected (usually a few minutes) — that's expected, not an error.

CloudTrail — the "who did what" audit log

CloudWatch                                  CloudTrail
"Is my SERVER/APP healthy?"                  "WHO called which AWS API, and WHEN?"
(performance & health monitoring)            (account-level audit logging)
  • CloudTrail records essentially every AWS API call made in your account — console clicks included (the console is itself just calling APIs under the hood).
  • Critical for security incident investigation: if an attacker spins up a server, exfiltrates data, and deletes their tracks, CloudTrail is your authoritative record of what actually happened and who/what triggered it.
  • Not a 100% real-time guarantee — there can be brief delays or (rarely) out-of-order events, but it's considered authoritative for practical purposes.

🧹 CloudWatch Cleanup Checklist

aws cloudwatch delete-alarms --alarm-names patient-ping-high-cpu
aws cloudwatch delete-dashboards --dashboard-names patient-ping-dashboard
aws logs delete-log-group --log-group-name <group-name>
aws sns delete-topic --topic-arn <topic-arn>
aws iam delete-role --role-name patient-ping-monitoring-role   # after detaching policies

8. Chapter 7 — DNS: Route 53

Why DNS matters (beyond "just" memorable names)

Human-friendly name (patient-ping.example.com)
          |
       [DNS]
          |
          v
   Numeric IP address (34.201.x.x)

DNS gives you a stable, memorable address and the power to redirect traffic — to a new server, a closer regional server, or a maintenance page — without clients needing to change anything.

Private vs. public hosted zones

PRIVATE hosted zone                        PUBLIC hosted zone
- only resolvable from WITHIN a VPC          - resolvable from anywhere on the internet
  you explicitly associate it with           - used for real, internet-facing domains
aws route53 create-hosted-zone \
  --name patient-ping.internal \
  --vpc VPCRegion=us-east-1,VPCId=<vpc-id> \
  --caller-reference "$(date +%s)" \
  --hosted-zone-config Comment="internal zone",PrivateZone=true
 
# You must ALSO enable DNS hostnames/resolution on the VPC:
aws ec2 modify-vpc-attribute --vpc-id <vpc-id> --enable-dns-hostnames
aws ec2 modify-vpc-attribute --vpc-id <vpc-id> --enable-dns-support

A Records — name → IP

www.patient-ping.internal.  --A-->  10.0.50.12
  • The apex/root record (@, i.e., nothing before the domain) can point directly to an IP in Route 53 — many other DNS providers don't allow this at the root, making Route 53 unusually convenient here.
aws route53 change-resource-record-sets --hosted-zone-id <zone-id> \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "www.patient-ping.internal",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [{ "Value": "10.0.50.12" }]
      }
    }]
  }'
# Test resolution from inside the VPC (needs `dig` or `nslookup`):
dig www.patient-ping.internal

TTL (Time To Live) & caching

Client asks for a name once -> caches the answer for <TTL> seconds
                                 -> re-asks only after TTL expires
  • DNS is cache-friendly by design — this avoids a network round trip on every single request.
  • ⚠️ A long TTL means slow rollback if you make a mistake. If you set a 1-hour TTL and then need to fix a typo'd IP, clients may keep using the wrong address for up to that hour.
  • 🧠 Best practice: start with a low TTL while a record is new/likely to change, then raise it later once stable.
  • You pay Route 53 per DNS query (per million), so an aggressively low TTL on a high-traffic domain can add up — but for most projects this cost is negligible.

CNAME Records — alias to another name

blog.patient-ping.internal.  --CNAME-->  www.patient-ping.internal.  --A-->  10.0.50.12
        (points to a NAME,                      (which itself resolves
         not directly to an IP)                  to an IP via an A record)
aws route53 change-resource-record-sets --hosted-zone-id <zone-id> \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "blog.patient-ping.internal",
        "Type": "CNAME",
        "TTL": 15,
        "ResourceRecords": [{ "Value": "www.patient-ping.internal" }]
      }
    }]
  }'
  • A CNAME points to another DNS name (which is then resolved in turn) — great for grouping multiple domains to the same destination, pointing at load balancers, or aliasing to AWS resources like CloudFront distributions.
  • CNAME chains resolve transparently: a client asking for blog.* gets redirected to www.*, which resolves to the final IP — all in one lookup from the client's perspective.

🧹 Route 53 Cleanup Checklist

# Delete all non-default record sets first (you cannot delete the SOA/NS records)
aws route53 change-resource-record-sets --hosted-zone-id <zone-id> --change-batch '{...DELETE...}'
aws route53 delete-hosted-zone --id <zone-id>

9. Chapter 8 — S3 (Simple Storage Service)

What S3 is (and isn't)

S3 = OBJECT storage, not a real file system
   bucket + key  -->  blob of data
   (no true "folders" — just naming conventions with "/" in the key)
  • Good for: images, PDFs, logs, backups, archives — data that doesn't need millisecond-fast access and isn't relational.
  • Not for: running compute, hosting a relational database, or anything requiring a real POSIX filesystem (utilities like s3fs/FUSE can fake a filesystem, but it's not native).
  • Bucket names must be globally unique across all AWS accounts worldwide (with some newer org-scoped exceptions) — pick something distinctive.
  • Pricing: roughly 2¢/GB/month, cheaper at higher volumes and with lower-access storage tiers.

Creating a bucket & object

aws s3 mb s3://patient-ping-assets
 
# Upload (put) a file — "object storage" = bucket + key:
aws s3 cp favicon.ico s3://patient-ping-assets/favicon.ico
 
# List contents:
aws s3 ls s3://patient-ping-assets
File (favicon.ico)  --stored at-->  Bucket (patient-ping-assets) + Key (favicon.ico)

Making objects public

By default, new buckets block all public access. To serve content publicly (e.g., a favicon), you must:

  1. Uncheck "Block all public access" on the bucket.
  2. Attach a bucket policy explicitly allowing public reads.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::patient-ping-assets/*"
    }
  ]
}
aws s3api put-bucket-policy --bucket patient-ping-assets --policy file://public-read-policy.json

⚠️ This is a well-known footgun. Many real-world data breaches have come from accidentally-public S3 buckets containing private customer data or backups. Always double-check why a bucket needs to be public before flipping that switch.

🧹 S3 Cleanup Checklist

# You must empty a bucket before you can delete it:
aws s3 rm s3://patient-ping-assets --recursive
aws s3 rb s3://patient-ping-assets

An S3 bucket itself is free to keep around — only stored objects incur charges. But large numbers of objects can take a long time to delete individually; plan cleanup time accordingly for big buckets.


10. Chapter 9 — CDN: CloudFront

The problem CDNs solve

Without a CDN:                         With a CDN:
  Every user, worldwide, fetches         One "origin" copy; each region's
  the SAME file from ONE region           nearest edge location caches
  -> high latency far from origin         a copy locally after first fetch
  -> origin server takes full load        -> low latency, spread-out load
  • Latency: physical distance to a single origin server adds real, noticeable delay.
  • Origin load: without a CDN, one server/bucket must serve every request worldwide directly.
  • A CDN sets up a distribution: an origin (e.g., an S3 bucket, a load balancer) + caching rules, replicated across many "points of presence" (PoPs) globally.
   [Origin: S3 bucket / Load Balancer]
              |
        (CloudFront Distribution)
              |
   +----------+----------+----------+
   |          |          |          |
 Edge PoP   Edge PoP   Edge PoP   Edge PoP
 (US)       (EU)       (Asia)     (...)

Creating a distribution

aws cloudfront create-distribution \
  --origin-domain-name patient-ping-assets.s3.amazonaws.com \
  --default-root-object favicon.ico
  • New distributions take a few minutes to fully Deploy across all edge locations before they're usable.
  • Once deployed, you get a *.cloudfront.net domain by default (or you can attach your own custom domain).
# Poll deployment status:
aws cloudfront get-distribution --id <distribution-id> \
  --query 'Distribution.Status'

Cache Invalidation

Upload new file to S3  --->  Old version still cached at every edge PoP
                                until: (a) TTL expires, or
                                       (b) you manually INVALIDATE it
aws cloudfront create-invalidation \
  --distribution-id <distribution-id> \
  --paths "/favicon.ico"
  • Invalidation forces every edge location to discard its cached copy and re-fetch fresh content from the origin on the next request.
  • Scope your invalidation paths precisely (/favicon.ico, not /*) — full-distribution invalidations are slower and (past a free monthly quota) cost more.

Pre-signed URLs — private content via a CDN

Normal S3 object: fully public, OR fully private (no in-between)
Pre-signed URL: a time-limited, signed link that grants TEMPORARY
                access to an otherwise-private object
aws s3 presign s3://patient-ping-assets/private-report.pdf --expires-in 900   # 15 minutes
  • Solves the problem of "I want CDN speed, but I don't want this object public forever."
  • Common use cases: user profile images, gated content behind an email signup, time-limited download links.
  • The signature + expiration are embedded as URL query parameters — anyone with the link can access it until it expires, so keep expiration windows short and avoid leaking these links.

Putting CloudFront behind your own domain

CNAME:  cdn.patient-ping.internal  --->  d123abc.cloudfront.net
aws route53 change-resource-record-sets --hosted-zone-id <zone-id> \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "cdn.patient-ping.internal",
        "Type": "CNAME",
        "TTL": 300,
        "ResourceRecords": [{ "Value": "d123abc.cloudfront.net" }]
      }
    }]
  }'

Serving all assets under your own domain avoids mixed-domain browser warnings and keeps your brand consistent in every URL users see.

🧹 CloudFront Cleanup Checklist

# You must DISABLE a distribution before you can delete it (and sometimes
# cancel any associated pricing/savings plan first):
aws cloudfront update-distribution --id <id> --distribution-config file://disabled-config.json
# ... wait for status = Disabled ...
aws cloudfront delete-distribution --id <id> --if-match <etag>

11. Chapter 10 — ECS (Elastic Container Service)

Why containers, in one picture

1 app per physical server   ->   Virtualization (many VMs per box)   ->   Containers
(expensive, wasteful)             (isolated OSes, still heavy)             (share the host
                                                                             kernel, much
                                                                             lighter, still
                                                                             isolated)

Containers keep the isolation benefits of virtualization (one app crashing doesn't take down its neighbors) while being far lighter weight — no full guest OS to boot, just the minimal pieces your app needs, packaged to run identically anywhere.

ECS vs. Kubernetes (EKS)

ECS  = AWS's own, simpler container orchestrator (less operational overhead)
EKS  = Managed Kubernetes on AWS (more powerful, more complex, more portable)

Choose ECS when you want containers running in AWS without taking on Kubernetes' full complexity. Choose EKS/Kubernetes when you need its ecosystem, portability across clouds, or advanced orchestration features.

The full ECS architecture, end to end

                                    INTERNET
                                       |
                          [Security Group: external — port 80 from 0.0.0.0/0]
                                       |
                          [Application Load Balancer]
                                       |
                             [Target Group :8000]
                                       |
                          [Security Group: internal — port 80 from "external" SG]
                                       |
                    +------------------+------------------+
                    |                                     |
             [ECS Task: Fargate]                   [ECS Task: Fargate]
             (container running                    (container running
              your app image)                        your app image)
                    |                                     |
             pulled from ECR                        pulled from ECR
             (Elastic Container Registry)
 
   Roles:
     Execution Role -> permissions to PULL the image + write logs (the "docker exec" side)
     Task Role      -> permissions the RUNNING container needs (e.g., read SSM params)

Step 1 — ECR: store your container image

# Build the app's Dockerfile:
docker build -t patient-ping-ecs .
 
# Authenticate Docker to your private ECR repo:
aws ecr get-login-password | docker login --username AWS \
  --password-stdin <account-id>.dkr.ecr.us-east-1.amazonaws.com
 
aws ecr create-repository --repository-name patient-ping --image-tag-mutability MUTABLE
 
docker tag patient-ping-ecs:latest \
  <account-id>.dkr.ecr.us-east-1.amazonaws.com/patient-ping:latest
docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/patient-ping:latest

A minimal Dockerfile for a simple Python app:

FROM python:slim
RUN pip install boto3
COPY app.py app.py
CMD ["python", "app.py"]

⚠️ Avoid the :latest tag in real deployments. Since anyone with push access can silently overwrite what latest points to, an attacker (or a mistake) could deploy a malicious image without you noticing which "version" you're really running. Use explicit version tags in production.

Step 2 — ECS Cluster & capacity providers

aws ecs create-cluster --cluster-name patient-ping-ecs \
  --capacity-providers FARGATE FARGATE_SPOT
Capacity Provider = the rules for HOW the cluster provisions compute
   FARGATE       -> AWS-managed serverless compute (no visible EC2 instance)
   FARGATE_SPOT  -> same, but on discounted/interruptible spot capacity

🧯 Troubleshooting tip from the course: if create-cluster mysteriously fails/hangs due to a race condition, check CloudFormation (ECS clusters are provisioned via a CloudFormation stack under the hood). Deleting the failed stack there and retrying the cluster creation resolves it.

Step 3 — IAM: Execution Role vs. Task Role

EXECUTION ROLE                         TASK ROLE
"What ECS needs to START the task"      "What the RUNNING container needs"
  - pull the image from ECR               - read SSM parameters
  - write startup/log data                - talk to other AWS services
# Execution role trust policy: ECS (the SERVICE) can assume it
aws iam create-role --role-name patient-ping-exec-role \
  --assume-role-policy-document file://ecs-service-trust.json
aws iam attach-role-policy --role-name patient-ping-exec-role \
  --policy-arn <ecr-pull-and-logs-policy-arn>
 
# Task role trust policy: ECS TASKS specifically can assume it
aws iam create-role --role-name patient-ping-task-role \
  --assume-role-policy-document file://ecs-tasks-trust.json
aws iam attach-role-policy --role-name patient-ping-task-role \
  --policy-arn <ssm-read-policy-arn>

🧯 A subtle gotcha from the course: the trust policy principal for the execution role is the general ECS service (ecs.amazonaws.com), while the task role's trust principal must specifically be ecs-tasks.amazonaws.com. Mixing these up produces a "failed to assume role" error when the service tries to launch tasks.

Step 4 — Task Definition (the "recipe")

{
  "family": "patient-ping-ecs",
  "requiresCompatibilities": ["FARGATE"],
  "networkMode": "awsvpc",
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "<exec-role-arn>",
  "taskRoleArn": "<task-role-arn>",
  "containerDefinitions": [
    {
      "name": "patient-ping",
      "image": "<account-id>.dkr.ecr.us-east-1.amazonaws.com/patient-ping:latest",
      "portMappings": [{ "containerPort": 8000 }],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/patient-ping",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}
aws logs create-log-group --log-group-name /ecs/patient-ping
 
aws ecs register-task-definition --cli-input-json file://task-definition.json

A task definition can hold more than one container — a common pattern is a "sidecar": a helper container (e.g., a log shipper or proxy) that only exists to support the main application container, not to run standalone.

Step 5 — Networking: two security groups

Security Group: "external"                 Security Group: "internal"
  Inbound:  80 from 0.0.0.0/0                 Inbound:  80 from "external" SG
  Outbound: ALL (needed for the ALB to           (only traffic FROM the load
            reach the target group!)             balancer's own SG is trusted)
aws ec2 create-security-group --group-name patient-ping-external \
  --description "public web" --vpc-id <vpc-id>
aws ec2 authorize-security-group-ingress --group-id <ext-sg-id> \
  --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-egress --group-id <ext-sg-id> \
  --protocol -1 --cidr 0.0.0.0/0    # don't forget outbound, or the ALB can't reach targets!
 
aws ec2 create-security-group --group-name patient-ping-internal \
  --description "ALB -> ECS tasks" --vpc-id <vpc-id>
aws ec2 authorize-security-group-ingress --group-id <int-sg-id> \
  --protocol -1 --source-group <ext-sg-id>

🧯 A real bug from the course walkthrough: forgetting the outbound rule on the external/load-balancer security group silently breaks the whole chain — traffic gets in to the ALB but can never be forwarded out to the target group. Always check both inbound and outbound rules when traffic mysteriously times out.

Step 6 — Application Load Balancer + Target Group

aws elbv2 create-load-balancer --name patient-ping-alb \
  --subnets <public-subnet-a> <public-subnet-b> \
  --security-groups <external-sg-id> --scheme internet-facing
 
aws elbv2 create-target-group --name patient-ping-tg \
  --protocol HTTP --port 8000 --vpc-id <vpc-id> --target-type ip
 
aws elbv2 create-listener --load-balancer-arn <alb-arn> \
  --protocol HTTP --port 80 \
  --default-actions Type=forward,TargetGroupArn=<target-group-arn>
  • Target type ip (rather than instance) is required for Fargate, since there's no persistent EC2 instance to register — ECS registers/deregisters task IPs with the target group automatically as tasks start and stop.
  • A load balancer gives you: traffic distribution across many tasks, graceful handling of unhealthy/restarting containers, and a stable single entry point regardless of how many (or how few) containers are currently running.

Step 7 — ECS Service (keeps N tasks running)

aws ecs create-service \
  --cluster patient-ping-ecs \
  --service-name patient-ping-svc \
  --task-definition patient-ping-ecs \
  --desired-count 1 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[<public-subnet-a>,<public-subnet-b>],securityGroups=[<internal-sg-id>],assignPublicIp=ENABLED}" \
  --load-balancers "targetGroupArn=<target-group-arn>,containerName=patient-ping,containerPort=8000"
ECS Service = "how many tasks should be running, and how do they register with the ALB?"
   - desired count
   - which subnets/security groups new tasks launch into
   - which target group to auto-register/deregister tasks with
# Check what's happening if tasks aren't starting:
aws ecs describe-services --cluster patient-ping-ecs --services patient-ping-svc
aws ecs list-tasks --cluster patient-ping-ecs --service-name patient-ping-svc
aws ecs describe-tasks --cluster patient-ping-ecs --tasks <task-id>

🧹 ECS Cleanup Checklist

aws ecs update-service --cluster patient-ping-ecs --service patient-ping-svc --desired-count 0
aws ecs delete-service --cluster patient-ping-ecs --service patient-ping-svc --force
aws ecs delete-cluster --cluster patient-ping-ecs
aws elbv2 delete-load-balancer --load-balancer-arn <alb-arn>
aws elbv2 delete-target-group --target-group-arn <tg-arn>
aws ecs deregister-task-definition --task-definition patient-ping-ecs:1
aws ecr delete-repository --repository-name patient-ping --force
aws logs delete-log-group --log-group-name /ecs/patient-ping

The cluster itself costs nothing extra with Fargate (you only pay for running tasks) — but delete it anyway if you want zero chance of future accidental spend.


12. Chapter 11 — Serverless: Lambda

When Lambda makes sense (and when it doesn't)

GOOD FIT                                  BAD FIT
- event-driven / unpredictable load        - long-running processes (15 min hard cap)
- file processing (S3 upload triggers)     - large dependency trees / big packages
- scheduled tasks (cron-style, via         - stateful applications needing local disk
  EventBridge)                             - anything needing zero cold-start latency
- prototypes/MVPs (free until used)          every single time
  • You pay only for actual compute time used — no idle server cost, automatic scaling with zero server management.
  • Cold starts: if a function hasn't run recently, AWS must load your code into a fresh container before executing — a small latency penalty on the first invocation after idle time.
  • Hard limit: 15 minutes maximum execution time per invocation.

The Lambda execution flow

   Trigger (API Gateway request, S3 event, EventBridge cron, SQS message, ...)
        |
        v
   Lambda Function (your code + a "handler" entry point)
        |
        v
   Execution Role (IAM) — what AWS APIs can this code call?
        |
        v
   Return value  --->  CloudWatch Logs (automatic)

Creating the execution role

# Trust policy: only the Lambda SERVICE can assume this role
aws iam create-role --role-name patient-ping-lambda-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }]
  }'
 
# AWS-managed policy that grants basic CloudWatch Logs write access:
aws iam attach-role-policy --role-name patient-ping-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Writing and deploying the function

# lambda_function.py
def lambda_handler(event, context):
    request_context = event.get("requestContext", {})
    identity = request_context.get("identity", {})
    ip_address = identity.get("sourceIp", "unknown")
 
    print(f"Request from IP: {ip_address}")
 
    return {
        "statusCode": 200,
        "body": f"Your IP address is {ip_address}"
    }
zip function.zip lambda_function.py
 
aws lambda create-function \
  --function-name patient-ping-ip \
  --runtime python3.13 \
  --role <lambda-role-arn> \
  --handler lambda_function.lambda_handler \
  --zip-file fileb://function.zip

If your function needs external dependencies (anything not in the Python standard library), you must vendor them into the zip yourself — Lambda's default deployment package has no package manager step of its own (though "layers" and container-image deployments offer alternatives).

Testing directly (no HTTP involved)

aws lambda invoke --function-name patient-ping-ip \
  --payload '{"requestContext":{"identity":{"sourceIp":"8.8.8.8"}}}' \
  response.json
cat response.json

The console/CLI "Test" feature sends a mock event object straight to your function — it does not make a real HTTP request or go through API Gateway. It's simulating the shape of an event, not the network path.

Exposing it over HTTP: API Gateway

  Client (browser/curl)
       |
   GET https://<api-id>.execute-api.us-east-1.amazonaws.com/
       |
       v
   [API Gateway: HTTP API]
       |
   (integration: route -> Lambda function)
       |
       v
   Lambda Function  --->  JSON response  --->  back to client
aws apigatewayv2 create-api \
  --name patient-ping-ip-api \
  --protocol-type HTTP \
  --target <lambda-function-arn>
 
# This shortcut command creates the API, integration, default route,
# $default stage, AND the required Lambda invoke permission in one step.
# Manual/explicit version, for more control:
aws apigatewayv2 create-integration --api-id <api-id> \
  --integration-type AWS_PROXY --integration-uri <lambda-arn> \
  --payload-format-version 2.0
 
aws apigatewayv2 create-route --api-id <api-id> \
  --route-key "GET /" --target integrations/<integration-id>
 
aws apigatewayv2 create-stage --api-id <api-id> --stage-name '$default' --auto-deploy
 
# Give API Gateway permission to invoke your function:
aws lambda add-permission --function-name patient-ping-ip \
  --statement-id apigw-invoke --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:<account-id>:<api-id>/*/*/"
curl https://<api-id>.execute-api.us-east-1.amazonaws.com/
# "Your IP address is <your real IP, forwarded via X-Forwarded-For>"

Logs — automatic, via CloudWatch

Every Lambda invocation automatically logs:

  • A START entry (marks billing start)
  • Anything your code prints to stdout/stderr
  • An END entry
  • A REPORT line (duration, billed duration, memory used — tied to a unique Request ID)
aws logs tail /aws/lambda/patient-ping-ip --follow

⚠️ Convenience vs. lock-in trade-off: relying on CloudWatch for Lambda logs (and SSM for config, IAM roles for everything, etc.) is easy and cheap — but it also deepens AWS-specific coupling. Weigh that against portability needs before building a whole logging/ops strategy exclusively on proprietary AWS primitives.

Other common Lambda triggers (beyond API Gateway)

S3 event        -> file uploaded/deleted  -> Lambda (e.g., transcode video, send confirmation email)
EventBridge cron -> scheduled interval    -> Lambda (e.g., nightly per-tenant DB backup script)
SQS message      -> queue has new item    -> Lambda (e.g., process a background job)
Slack/Discord    -> webhook received      -> Lambda (e.g., chatbot command handler)

⚠️ Avoid recursive triggers. A Lambda that (directly or indirectly) triggers itself in a loop can spin up unbounded concurrent executions and produce a very expensive, very fast bill. AWS has added some recursion protections, but it's still your responsibility to design triggers carefully.

🧹 Lambda Cleanup Checklist

aws apigatewayv2 delete-api --api-id <api-id>
aws lambda delete-function --function-name patient-ping-ip
aws logs delete-log-group --log-group-name /aws/lambda/patient-ping-ip
aws iam detach-role-policy --role-name patient-ping-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name patient-ping-lambda-role

13. Full Command Cheat Sheet

Identity & auth

aws sts get-caller-identity
aws configure set region us-east-1
aws-vault exec <profile> -- <command>

Networking (VPC)

aws ec2 create-vpc --cidr-block 10.0.0.0/22
aws ec2 create-subnet --vpc-id <id> --cidr-block 10.0.0.0/24 --availability-zone us-east-1a
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --vpc-id <id> --internet-gateway-id <igw-id>
aws ec2 create-route-table --vpc-id <id>
aws ec2 create-route --route-table-id <id> --destination-cidr-block 0.0.0.0/0 --gateway-id <igw-id>
aws ec2 associate-route-table --route-table-id <id> --subnet-id <id>
aws ec2 allocate-address --domain vpc
aws ec2 create-nat-gateway --subnet-id <id> --allocation-id <alloc-id>

EC2

ssh-keygen -t ed25519 -f ~/.ssh/mykey
aws ec2 import-key-pair --key-name mykey --public-key-material fileb://~/.ssh/mykey.pub
aws ec2 run-instances --image-id <ami> --instance-type t3.micro --key-name mykey ...
aws ec2 associate-address --instance-id <id> --allocation-id <alloc-id>
aws ec2 create-security-group --group-name web --vpc-id <id>
aws ec2 authorize-security-group-ingress --group-id <id> --protocol tcp --port 22 --cidr <ip>/32
aws ec2 create-image --instance-id <id> --name my-ami --no-reboot
aws ec2 create-launch-template --launch-template-name lt --launch-template-data '{...}'
aws ec2 terminate-instances --instance-ids <id>

RDS

aws rds create-db-subnet-group --db-subnet-group-name sg --subnet-ids <a> <b>
aws rds create-db-instance --db-instance-identifier db --engine postgres ...
aws rds create-db-snapshot --db-instance-identifier db --db-snapshot-identifier snap
aws rds create-db-instance-read-replica --db-instance-identifier replica --source-db-instance-identifier db
aws rds delete-db-instance --db-instance-identifier db --skip-final-snapshot

IAM

aws iam create-user --user-name name
aws iam create-group --group-name group
aws iam create-policy --policy-name p --policy-document file://policy.json
aws iam create-role --role-name r --assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name r --policy-arn <arn>
aws ec2 associate-iam-instance-profile --instance-id <id> --iam-instance-profile Name=r
aws ssm put-parameter --name /path --type SecureString --value "secret"
aws ssm get-parameter --name /path --with-decryption

CloudWatch

aws cloudwatch put-metric-alarm --alarm-name a --metric-name CPUUtilization ...
aws sns create-topic --name topic
aws sns subscribe --topic-arn <arn> --protocol email --notification-endpoint you@x.com
aws logs create-log-group --log-group-name /my/group
aws logs tail /my/group --follow

Route 53

aws route53 create-hosted-zone --name example.internal --caller-reference "$(date +%s)"
aws route53 change-resource-record-sets --hosted-zone-id <id> --change-batch file://record.json
dig myname.example.internal

S3 & CloudFront

aws s3 mb s3://bucket
aws s3 cp file s3://bucket/key
aws s3api put-bucket-policy --bucket bucket --policy file://policy.json
aws s3 presign s3://bucket/key --expires-in 900
aws cloudfront create-distribution --origin-domain-name bucket.s3.amazonaws.com
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"

ECS

aws ecr create-repository --repository-name repo
docker push <account>.dkr.ecr.<region>.amazonaws.com/repo:tag
aws ecs create-cluster --cluster-name c --capacity-providers FARGATE FARGATE_SPOT
aws ecs register-task-definition --cli-input-json file://task.json
aws elbv2 create-load-balancer --name alb --subnets <a> <b> --security-groups <sg>
aws elbv2 create-target-group --name tg --protocol HTTP --port 8000 --vpc-id <id> --target-type ip
aws ecs create-service --cluster c --service-name svc --task-definition td --desired-count 1 ...

Lambda

zip function.zip lambda_function.py
aws lambda create-function --function-name f --runtime python3.13 --role <arn> \
  --handler lambda_function.lambda_handler --zip-file fileb://function.zip
aws lambda invoke --function-name f --payload '{}' out.json
aws apigatewayv2 create-api --name api --protocol-type HTTP --target <lambda-arn>

14. Cleanup Checklist

Run through this in order at the end of the course (or before any extended break) to avoid ongoing charges:

1.  ECS       -> delete service (desired-count 0 first) -> delete cluster
2.  ALB       -> delete load balancer -> delete target group
3.  Lambda    -> delete function -> delete API Gateway
4.  RDS       -> delete DB instance(s) + read replicas -> delete subnet group
5.  EC2       -> terminate instances -> release Elastic IPs -> delete launch templates
                 -> deregister AMIs + delete snapshots
6.  CloudFront-> disable distribution -> (wait) -> delete distribution
7.  S3        -> empty bucket -> delete bucket
8.  Route 53  -> delete records -> delete hosted zone
9.  CloudWatch-> delete alarms, dashboards, log groups -> delete SNS topics
10. IAM       -> deactivate + delete access keys -> detach + delete policies
                 -> delete roles -> delete groups -> delete users
11. VPC       -> delete VPC (cascades: subnets, route tables, internet gateway)
                 -- NAT Gateways must be deleted separately BEFORE the VPC

💵 The two most expensive things to forget: a running NAT Gateway (hourly + per-GB charges) and an unattached Elastic IP address (hourly charge just for holding it). Check both every time you take a break from the course.


Guide compiled from the full course transcript — Boot.dev's "AWS DevOps Course," taught by Zach Gates.