AWS is the broadest and most mature cloud: if a job exists, there is almost certainly a managed service for it. The draw is breadth and a long track record at every scale; the cost is complexity. This track teaches the service-per-job mindset and IAM least-privilege from the first Step, then climbs from a single S3 bucket to Fargate, DynamoDB, infrastructure as code, and GameLift’s dedicated game-server fleets — tagged by level so you read only as deep as you need.
Install the AWS CLI and configure a profile
BeginnerInstall the AWS CLI v2, then run aws configure to store credentials and a default region in a named profile.
Why a named profile and a region from day one
Almost every AWS API call is regional — resources live in a specific region (e.g. us-east-1,
eu-central-1) and don’t automatically exist elsewhere. The CLI reads credentials and a default region from
a profile stored in ~/.aws/credentials and ~/.aws/config. Using a named profile (not the unnamed
default) keeps work and personal accounts apart and makes the active identity explicit on every command
via --profile. Verify who you are with aws sts get-caller-identity before doing anything that creates
resources.
Configure and verify
# Verify the CLI is v2 (this track assumes v2)
aws --version
# Store an access key, secret, region, and output format under a named profile
aws configure --profile platform
# Confirm the identity the CLI is acting as
aws sts get-caller-identity --profile platformCreate an S3 bucket and upload an object
BeginnerCreate a bucket with aws s3api create-bucket, then copy a file into it with aws s3 cp.
Why S3 is the cloud's default storage primitive
S3 (Simple Storage Service) is object storage: you put and get objects (files) by key inside a bucket,
over HTTP, with eleven-nines of durability. It is the backbone of countless AWS architectures — static
assets, backups, data-lake files, build artifacts. Bucket names are globally unique across all AWS
accounts, so pick something specific. Outside us-east-1, create-bucket requires a
LocationConstraint naming the region; us-east-1 is the one region that rejects it. New buckets are
private by default — that default is a feature, not a limitation.
Make a bucket and put an object
BUCKET="platform-demo-$(date +%s)"
# Outside us-east-1 you must pass a LocationConstraint matching your region
aws s3api create-bucket \
--bucket "$BUCKET" \
--region eu-central-1 \
--create-bucket-configuration LocationConstraint=eu-central-1 \
--profile platform
echo "hello from s3" > hello.txt
aws s3 cp hello.txt "s3://$BUCKET/hello.txt" --profile platform
aws s3 ls "s3://$BUCKET/" --profile platformWrite a least-privilege IAM policy for that bucket
BeginnerCreate an IAM policy that grants read/write to only your bucket, then attach it to a user or role.
Least privilege is the whole game with IAM
IAM (Identity and Access Management) is how AWS decides who can do what to which resource. It is the
most powerful — and steepest — part of AWS. The discipline that keeps you safe is least privilege: grant
the narrowest set of Actions on the most specific Resource ARN that the job needs, and nothing more.
Never reach for "Action": "*" or "Resource": "*" in real work. A policy is JSON with Effect, Action,
and Resource; an explicit Deny always wins over any Allow. Object-level actions (s3:GetObject) need
the /* ARN; bucket-level actions (s3:ListBucket) need the bare bucket ARN.
A scoped S3 policy
cat > s3-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListTheBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::platform-demo-BUCKET"
},
{
"Sid": "ReadWriteObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::platform-demo-BUCKET/*"
}
]
}
JSON
aws iam create-policy \
--policy-name platform-s3-rw \
--policy-document file://s3-policy.json \
--profile platformAgent prompt — paste into an agent with repo access
Role: Senior AWS engineer practising least privilege.
Context: An app needs to read and write objects under a single bucket named "platform-uploads",
and list that bucket. It must NOT touch any other bucket.
Task: Write an IAM policy document (JSON) and a script that creates it with the AWS CLI.
Requirements:
- Two statements: s3:ListBucket on the bucket ARN; s3:GetObject/PutObject/DeleteObject on <bucket>/*.
- No wildcard actions (no "s3:*") and no wildcard resource ("*").
- Policy version string is "2012-10-17".
Tests / acceptance:
- `aws iam create-policy --policy-name platform-uploads-rw --policy-document file://policy.json` succeeds.
- `aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://policy.json`
returns zero ERROR-level findings.
Output: a unified diff (policy.json + the script) plus a one-paragraph rationale for the scoping.Run a container on EC2's free tier
BeginnerLaunch a small EC2 instance, then terminate it when you are done so it stops billing.
EC2 is the raw compute primitive — and the cost lever
EC2 (Elastic Compute Cloud) gives you virtual machines you fully control. It is the most flexible compute on
AWS and the foundation many higher-level services run on, but it is also the one you must remember to turn
off — instances bill per second while running. Choose an instance type (t3.micro is small and
free-tier eligible in many regions), an AMI (the OS image), a key pair for SSH, and a security group (a
stateful firewall). For most modern apps you’ll graduate from raw EC2 to a managed runner like Fargate
(next Steps) — but knowing EC2 makes every higher-level service legible.
Launch, then clean up
# Pick a current Amazon Linux 2023 AMI for your region from SSM (no hardcoded IDs)
AMI=$(aws ssm get-parameter \
--name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query 'Parameter.Value' --output text --profile platform)
aws ec2 run-instances \
--image-id "$AMI" \
--instance-type t3.micro \
--key-name my-keypair \
--profile platform
# When finished, ALWAYS terminate to stop billing
aws ec2 terminate-instances --instance-ids i-0123456789abcdef0 --profile platformPush an image to ECR and deploy on Fargate
IntermediateBuild a container image, push it to ECR, then run it as an ECS service on Fargate with no servers to manage.
Fargate is the service-per-job answer for containers
ECS (Elastic Container Service) is AWS’s container orchestrator; Fargate is its serverless launch type — you describe a task (image, CPU, memory, env) and AWS runs the container without you provisioning or patching EC2 hosts. The image lives in ECR (Elastic Container Registry), a private Docker registry in your account. This is the “service-per-job” mindset in action: rather than a hand-managed VM, you pick the managed runner that matches the workload — Fargate for steady containers, Lambda for event-driven bursts. You wire a task execution role (so ECS can pull the image and write logs) separately from the task role (the permissions your app code gets) — keeping them distinct is least privilege again.
Build, push, register
ACCOUNT=$(aws sts get-caller-identity --query Account --output text --profile platform)
REGION=eu-central-1
REPO="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/platform-api"
aws ecr create-repository --repository-name platform-api --region "$REGION" --profile platform
# Authenticate Docker to ECR, then build and push
aws ecr get-login-password --region "$REGION" --profile platform \
| docker login --username AWS --password-stdin "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com"
docker build -t "$REPO:v1" .
docker push "$REPO:v1"Agent prompt — paste into an agent with repo access
Role: Senior AWS platform engineer.
Context: A stateless HTTP container listens on :8080 and answers GET /healthz with 200. The image is
pushed to ECR at <account>.dkr.ecr.<region>.amazonaws.com/platform-api:v1. We deploy with ECS on Fargate.
Task: Create an ECS task definition (JSON) and the CLI calls to register it and run an ECS service on Fargate.
Requirements:
- Fargate launch type; awsvpc networking; CPU 256, memory 512.
- Separate executionRoleArn (pull image + CloudWatch Logs) from taskRoleArn (app permissions, start empty).
- Container maps port 8080; logs go to the awslogs driver.
- Use only parameterised account/region values, no hardcoded account number in committed files.
Tests / acceptance:
- `aws ecs register-task-definition --cli-input-json file://taskdef.json` succeeds.
- After `aws ecs create-service ...`, `aws ecs describe-services` shows runningCount reaching desiredCount.
- `curl -s -o /dev/null -w "%{http_code}" http://<task-public-ip>:8080/healthz` prints 200.
Output: a unified diff (taskdef.json + deploy script) plus a one-paragraph note on the two-role split.Provision Aurora PostgreSQL with RDS
IntermediateCreate an Aurora PostgreSQL cluster with RDS, then connect to it with psql over the connection endpoint.
RDS/Aurora: managed Postgres without the operational toil
RDS (Relational Database Service) runs managed relational databases — patching, backups, failover, and replicas are handled for you. Aurora is AWS’s cloud-native engine that is wire-compatible with PostgreSQL (and MySQL), with storage that auto-scales and replicates across Availability Zones. You get the full Postgres SQL surface — ACID transactions, joins, JSON, indexes — without running the box yourself. Put the cluster in private subnets and reach it only from your app’s security group; never expose a database to the public internet. Store the password in Secrets Manager, not in code.
Create an Aurora PostgreSQL cluster
# Pin a current minor: `aws rds describe-db-engine-versions --engine aurora-postgresql`
# lists what's available — 16.4 ages, so check before you copy it.
aws rds create-db-cluster \
--db-cluster-identifier platform-pg \
--engine aurora-postgresql \
--engine-version 16.4 \
--master-username appadmin \
--manage-master-user-password \
--vpc-security-group-ids sg-0123456789abcdef0 \
--db-subnet-group-name platform-private \
--profile platform
# Add a writer instance to the cluster
aws rds create-db-instance \
--db-cluster-identifier platform-pg \
--db-instance-identifier platform-pg-writer \
--db-instance-class db.r6g.large \
--engine aurora-postgresql \
--profile platformModel a single-table workload in DynamoDB
IntermediateCreate a DynamoDB table with a partition and sort key, then put and query items.
When a key-value/document store beats a relational one
DynamoDB is a fully managed NoSQL key-value and document database with single-digit-millisecond latency at
any scale and no servers to size. It shines for high-throughput, access-pattern-driven workloads — user
sessions, event logs, leaderboards, shopping carts — where you query by known keys rather than ad-hoc
joins. You design around a partition key (which shard the item lives on) and an optional sort key
(ordering within a partition), and you Query by key — DynamoDB deliberately makes full-table Scans
expensive to steer you toward key-based access. It is the opposite trade-off to Aurora: give up flexible
SQL, gain effortless horizontal scale. Pick the store per job, not per habit.
Create, put, query
aws dynamodb create-table \
--table-name Sessions \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=createdAt,AttributeType=S \
--key-schema \
AttributeName=userId,KeyType=HASH \
AttributeName=createdAt,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--profile platform
aws dynamodb put-item --table-name Sessions \
--item '{"userId":{"S":"u1"},"createdAt":{"S":"2026-06-20T10:00:00Z"},"ip":{"S":"203.0.113.7"}}' \
--profile platform
aws dynamodb query --table-name Sessions \
--key-condition-expression "userId = :u" \
--expression-attribute-values '{":u":{"S":"u1"}}' \
--profile platformChat prompt — paste into a chat to get the code
Role: AWS data-modelling teacher. The reader has no repo access — return complete, runnable code.
Task: Show a single-table DynamoDB design for a chat app and the AWS SDK for JavaScript v3 code to write
and read it, choosing the right key schema up front.
Requirements:
- Items: a Conversation and its Messages, queryable as "all messages in a conversation, newest first".
- Partition key PK = "CONV#<id>", sort key SK = "MSG#<iso8601-timestamp>"; messages sort by SK.
- Use @aws-sdk/client-dynamodb (or lib-dynamodb DocumentClient) with PutCommand and QueryCommand.
- Query uses ScanIndexForward=false to return newest-first; no full-table Scan anywhere.
Tests / acceptance (describe, since no repo):
- Writing 3 messages then querying PK="CONV#42" returns 3 items, newest first.
- Querying a missing conversation returns an empty Items array, not an error.
Output: the complete table-creation snippet plus the write/read module, no commentary.Add a Redis cache with ElastiCache
IntermediateCreate an ElastiCache for Redis cluster, then point your app at its endpoint to cache hot reads.
Cache the expensive reads, keep the database for truth
ElastiCache is AWS’s managed in-memory data store, offered with the Redis OSS / Valkey engine. Redis sits in front of your database: cache the results of expensive queries, store ephemeral session state, or back real-time features (counters, pub/sub, leaderboards) that would hammer a relational store. The managed service handles provisioning, patching, failover, and (in cluster mode) sharding. Like a database, keep it in private subnets reachable only from your app’s security group — Redis has no authentication by default beyond the network boundary, so the network is the control. The app talks to it with any standard Redis client over the cluster’s endpoint.
Create a Redis cluster
aws elasticache create-cache-cluster \
--cache-cluster-id platform-cache \
--engine redis \
--cache-node-type cache.t4g.micro \
--num-cache-nodes 1 \
--security-group-ids sg-0123456789abcdef0 \
--cache-subnet-group-name platform-private \
--profile platform
# Read the endpoint your app connects to
aws elasticache describe-cache-clusters \
--cache-cluster-id platform-cache \
--show-cache-node-info \
--query 'CacheClusters[0].CacheNodes[0].Endpoint' \
--profile platformDefine the whole stack as code with the CDK
AdvancedDescribe your infrastructure in a typed CDK app, then cdk diff and cdk deploy it as one CloudFormation stack.
Why infrastructure as code, and CDK over raw CloudFormation
Clicking in the console doesn’t scale and isn’t reproducible. CloudFormation is AWS’s native IaC: you
declare resources in a template and AWS reconciles your account to match, tracking everything as a stack
you can update or delete atomically. The CDK (Cloud Development Kit) lets you write that template in a
real language (TypeScript, Python, Go, Java) with types, loops, and abstractions, then synthesizes it to
CloudFormation. You get review-able diffs (cdk diff), repeatable environments, and one source of truth for
the bucket, table, cluster, and roles you created by hand in earlier Steps. Least privilege scales here too:
CDK grant helpers (e.g. bucket.grantRead(fn)) emit the minimal policy automatically.
Scaffold, diff, deploy
npm install -g aws-cdk
mkdir platform-infra && cd platform-infra
cdk init app --language typescript
# One-time per account+region: create the CDK bootstrap stack
cdk bootstrap --profile platform
cdk diff --profile platform # review the change set before applying
cdk deploy --profile platformAgent prompt — paste into an agent with repo access
Role: Senior AWS engineer fluent in the AWS CDK (TypeScript, v2).
Context: A new CDK app scaffolded with `cdk init app --language typescript`. We want one stack that owns a
private S3 bucket and a Lambda that reads from it, wired with least-privilege IAM.
Task: Implement the stack in lib/ so the Lambda can read objects from the bucket and nothing more.
Requirements:
- Use aws-cdk-lib v2 constructs (aws_s3.Bucket, aws_lambda.Function).
- Bucket: blockPublicAccess ALL, encryption S3_MANAGED, versioned.
- Grant access ONLY via bucket.grantRead(fn) — do not hand-write an IAM policy or use wildcards.
- Pass the bucket name to the Lambda via an environment variable.
Tests / acceptance:
- `cdk synth` produces a template with no errors.
- `cdk diff` lists the bucket, the function, and exactly one read-scoped policy (no "s3:*", no "Resource":"*").
Output: a unified diff of the stack file plus a one-paragraph note on what grantRead emitted.Stand up dedicated game-server fleets with GameLift
AdvancedPackage a dedicated game-server build, upload it to GameLift, and create a fleet that GameLift scales for you.
Why GameLift exists when EC2 already runs servers
You could run game servers on raw EC2 — but session-based multiplayer needs more: placing players onto the least-loaded server with spare slots, scaling fleets up and down with demand, and matchmaking players into balanced sessions. GameLift is the purpose-built service for exactly this: you upload a dedicated server build, GameLift runs it across managed fleets, tracks game sessions and their available player slots, and (via FlexMatch) matches players by rules you define. This is AWS’s breadth advantage made concrete — a managed primitive for a problem most clouds leave you to solve yourself. It’s the load-bearing service behind a real-time arena backend.
Upload a build and create a fleet
# Upload a dedicated server build (compiled for the fleet's OS)
# AMAZON_LINUX_2023 requires a build using GameLift server SDK v5+ (v4 builds reject AL2023)
aws gamelift upload-build \
--name photon-arena-server \
--build-version v1 \
--operating-system AMAZON_LINUX_2023 \
--server-sdk-version 5.1.2 \
--build-root ./server-build \
--profile platform
# Create a fleet that runs that build; GameLift manages the underlying instances
aws gamelift create-fleet \
--name photon-arena-fleet \
--build-id build-0123456789abcdef0 \
--ec2-instance-type c5.large \
--fleet-type ON_DEMAND \
--runtime-configuration 'ServerProcesses=[{LaunchPath=/local/game/server,ConcurrentExecutions=1}]' \
--profile platformAgent prompt — paste into an agent with repo access
Role: Senior AWS engineer building a real-time multiplayer backend on GameLift.
Context: We have a compiled dedicated server binary at ./server-build/server and want players placed onto
the least-loaded session, matched by skill, with fleets scaling on demand.
Task: Write a deploy script (AWS CLI) and a FlexMatch matchmaking ruleset that GameLift can use.
Requirements:
- Upload the build with `aws gamelift upload-build` and create an ON_DEMAND fleet referencing it.
- A FlexMatch ruleset JSON that forms 2-team matches of 4 players each and balances on a "skill" attribute.
- A queue (`create-game-session-queue`) so placement can span fleets/regions.
- No hardcoded build/fleet IDs in committed files — read them back from the CLI output into variables.
Tests / acceptance:
- `aws gamelift validate-matchmaking-rule-set --rule-set-body file://ruleset.json` returns Valid=true.
- After `create-fleet`, `aws gamelift describe-fleet-attributes` eventually shows Status=ACTIVE.
- `aws gamelift describe-game-session-queues --names <queue>` returns the queue with the fleet as a destination.
Output: a unified diff (deploy script + ruleset.json) plus a one-paragraph note on placement vs. raw EC2.Isolate workloads in a VPC with private subnets
AdvancedCreate a VPC with public and private subnets, and reach the database only from the app’s security group.
The network boundary is the outermost layer of least privilege
A VPC (Virtual Private Cloud) is your own isolated network in AWS. The standard pattern: public subnets hold internet-facing pieces (a load balancer, a NAT gateway), while private subnets hold the parts that must never accept inbound traffic from the internet — your Aurora cluster, your ElastiCache node, your Fargate tasks. Security groups are stateful, allow-only firewalls attached to resources; you let the app reach the database by referencing the app’s security group as the source in the database’s group, rather than opening a CIDR range. Private resources reach out (for updates, AWS APIs) through a NAT gateway or VPC endpoints. Getting this layout right is what keeps a database off the public internet — the network is the first and last line of least privilege.
A VPC and a tight security-group rule
VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--query 'Vpc.VpcId' --output text --profile platform)
# Create the app and database security groups
APP_SG=$(aws ec2 create-security-group --group-name app-sg \
--description "app tier" --vpc-id "$VPC" \
--query 'GroupId' --output text --profile platform)
DB_SG=$(aws ec2 create-security-group --group-name db-sg \
--description "db tier" --vpc-id "$VPC" \
--query 'GroupId' --output text --profile platform)
# Allow Postgres from the APP security group only — not from a public CIDR
aws ec2 authorize-security-group-ingress \
--group-id "$DB_SG" --protocol tcp --port 5432 \
--source-group "$APP_SG" --profile platformWhere to take it next
- Put these AWS primitives to work behind any of the guided projects — an ECS/Fargate or Lambda backend with RDS for the database — where AWS’s breadth is the lesson.
- Want a simpler path to a running container and first-class AI/data services? Compare against Google Cloud.
- Need global edge latency with the least operational surface? See Cloudflare.
- Pair the cache tier here with the deeper Redis track for caching and real-time patterns.