← All tech

Cloud / edge

AWS

The broadest, most mature cloud — a service for every job, at any scale.

  • Deep service catalogue spanning compute, data, networking, and ML
  • Granular, auditable access control with IAM
  • Purpose-built dedicated game-server fleets with GameLift
  • Infrastructure as code with CloudFormation and CDK
Use it when

Reach for AWS when breadth and maturity matter — you need a specific managed service (Aurora, DynamoDB, GameLift, Kinesis) that may not exist elsewhere, fine-grained IAM, and a track record at any scale.

Reach for something else when

Skip AWS when you want the simplest possible path to a running container or the least operational surface area — its breadth is also its cost in complexity, and IAM has the steepest learning curve of any major cloud.

Official docs ↗


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

Beginner

Install 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
Run these in your terminal / editor
# 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 platform

Create an S3 bucket and upload an object

Beginner

Create 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
Run these in your terminal / editor
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 platform

Write a least-privilege IAM policy for that bucket

Beginner

Create 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
Run these in your terminal / editor
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 platform
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Beginner

Launch 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
Run these in your terminal / editor
# 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 platform

Push an image to ECR and deploy on Fargate

Intermediate

Build 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Create 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
Run these in your terminal / editor
# 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 platform

Model a single-table workload in DynamoDB

Intermediate

Create 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
Run these in your terminal / editor
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 platform
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
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

Intermediate

Create 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
Run these in your terminal / editor
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 platform

Define the whole stack as code with the CDK

Advanced

Describe 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
Run these in your terminal / editor
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 platform
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Package 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
Run these in your terminal / editor
# 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 platform
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Create 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
Run these in your terminal / editor
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 platform

Where 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.