Skip to content
Software Atlas

Cloud Basics

What “the cloud” actually rents you, from raw VMs to serverless functions; regions and availability zones; VPCs, subnets and security groups; IAM roles and policies; the core managed services; who is responsible for what; and where the bill really comes from — with every command run against a local AWS emulator, no account needed.

⏱ ~55 min read13 sections10 interactive widgetsShell · JSON · HCL · Go · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceThe cloud is someone else's data centers, rented by the second, and controlled entirely through an API.

You've probably pushed code that "someone else" deployed. That someone typed a few commands (or a pipeline did), and somewhere a machine started running your program. This topic is about what they rented, where it lives, who may touch it, and what it costs.

What changed: weeks became seconds

Getting one new server
buying your own
you
order
shipping: 2–8 weeks
rack + cable
install OS
renting from a cloud
you
API
running
Not to scale; typical durations. Before the cloud, capacity was something you planned months ahead. Now it's an API call, so you can add ten servers for an afternoon and remove them again.

Here's what that API call looks like. run-instances asks AWS for one small Virtual machine (AWS calls them EC2 instances). Just read it for now; you'll set up the tools to run it yourself at the end of this section.

terminal (against a local emulator)
$ awsl ec2 run-instances --image-id ami-12c6146b --instance-type t3.micro \
    --query 'Instances[0].[InstanceId,InstanceType,State.Name,Placement.AvailabilityZone]' --output text
i-7d1fddceeb78f697a	t3.micro	pending	eu-west-1a

A few seconds later the state would say running. Every button in the web console, every Terraform file and every SDK call ends up as a request like this one. The cloud is an API first; the website is just one client of it.

What you get, and what you take on

1

Elastic

Add machines in seconds when traffic spikes, remove them when it's quiet. You pay for what runs.
2

Managed

Databases, queues and load balancers come ready to use (§07). Someone else patches them at 3 a.m.
3

Everywhere

Data centers on every continent (§03). Putting a copy near your users is a setting, not a project.

The price: a bill that's easy to get wrong (§09), security settings that are now your job (§08), and a lot of new vocabulary. The rest of this topic is that vocabulary, one idea at a time.

Three big providers, one set of ideas

AWS (Amazon), Google Cloud (GCP) and Microsoft Azure sell almost the same building blocks under different names. We use AWS names because AWS is the most common, and give the GCP and Azure names next to them. Learn the idea once; the names are a lookup table (there's one in §07).

Following along without an account

Real cloud accounts need a credit card, and a typo can cost money. Instead, we use a Local cloud emulator called moto: a program that answers the same API calls as AWS, on your machine, in Docker.

You need two tools: Docker, and the AWS CLI (aws). Install the CLI with brew install awscli on macOS, or AWS's official installer on Linux and Windows. Then open a fresh terminal just for this page, because the next lines replace any real AWS credentials in that shell:

setup (in a new terminal)
docker run --rm -d --name moto -p 5050:5000 motoserver/moto:5.2.5

# the emulator accepts any credentials; these are fake
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=eu-west-1 AWS_REGION=eu-west-1   # the CLI reads the first, the SDKs the second
awsl() { aws --endpoint-url http://localhost:5050 "$@"; }   # ← "aws, but local"

-p 5050:5000 makes the emulator (which listens on port 5000 inside its container) reachable on port 5050 of your machine. We avoid 5000 itself because macOS often uses it for AirPlay.

The outputs on this page were produced exactly this way, with moto 5.2.5 and AWS CLI 1.46. The commands are the same in CLI v2; a few outputs look slightly different (§07 points one out). Emulators are great for learning and tests, but they don't model everything: no real machines boot, nothing costs money, and permission checks are off unless you turn them on (we do, in §06).

Foundations§02

IaaS, PaaS, SaaS and serverless

In one sentenceEvery cloud service answers one question differently: which layers of the stack do you still run yourself?

The industry shorthand is IaaS / PaaS / SaaS, plus a newer fourth option, Serverless. They're not four different clouds. They're four points on a slider, from "give me a bare machine" to "give me a finished product".

Click through the options. Watch the coloured layers (your job) turn grey as you pick more managed options:

Who runs which layer?
Pick a way to run the same app. Coloured layers are your job; grey ones the provider does for you; amber ones are shared.
Your data & who may access ityou
Your app codeyou
Language runtime & librariesyou
Operating system & security patchesyou
Capacity: how many machines, whenyou
Hardware, network, power, buildingprovider
You run 5 of 6 layers.
Analogy
Renting an empty apartment: the landlord fixes the building; furniture, cleaning and locks inside are yours.
What you hand over
A machine image or a start-up script that installs your app on a fresh VM.
How you're billed
Per second the VM is switched on — busy or idle.
Examples
AWS EC2 · Google Compute Engine · Azure Virtual Machines

The same Go app, four ways

A container is your app packaged with everything it needs to run, so any machine with a container runtime can start it the same way. The Containers topic covers what's inside one; here it's enough to know it's the most common thing people hand to a cloud today.

Say you have a small Go web service. Here's what "deploy it" means at each level:

ModelAWS (GCP · Azure)You hand overYou still do
IaaSEC2 (Compute Engine · Virtual Machines)a VM image or start-up scriptpatch the OS, copy your compiled binary onto it, restart it on crash, add VMs when busy
PaaS / containersApp Runner, ECS on Fargate (Cloud Run · Container Apps)a Container imagebuild the image, set CPU/memory and min/max copies
FunctionsLambda (Cloud Run functions · Azure Functions)one handler function, zippedwrite the handler; keep it short and stateless
SaaS—nothingnothing: you'd use someone's product instead of writing this one

Serverless: the hotel room

With functions, there's no server you can name. The platform starts a copy of your code when a request or event arrives, runs as many copies as there are requests, and runs zero copies when nothing happens. You pay per request plus per millisecond of run time.

The catch is the Cold start: if no copy is warm, the first request waits while one starts — tens of milliseconds for a small Go binary, sometimes seconds for a big Node or Java bundle. Functions also have hard limits (AWS Lambda: at most 15 minutes per run), so long jobs don't fit.

The trade you're making

Moving right on the slider buys you less work and costs you less control — and often a higher price per unit of compute. A VM running flat out all month is usually the cheapest compute per CPU-hour; a function is the cheapest when traffic is low or spiky, because idle time costs nothing. §09 puts numbers on this, and §10 turns it into a decision.

Foundations§03

Regions and availability zones

In one sentenceA region is a city where the provider has data centers. An availability zone is one separate building (or group of buildings) in that city. Spread your copies across zones, and one fire can't take you down.

Regions: pick a city

A Cloud region is a geographic area like Ireland (eu-west-1), Northern Virginia (us-east-1) or Tokyo (ap-northeast-1). Regions are independent: separate power, separate networks, and mostly separate copies of every service. AWS has a few dozen; our emulator lists 38.

When you create something, you create it in one region. A VM in Ireland doesn't appear in Tokyo unless you build a second copy there.

Availability zones: separate buildings in that city

Inside each region are usually three or more availability zones (AZs). Each zone has its own power, cooling and network links, a few kilometers from the others: close enough for a ~1 ms round trip, far enough that a flood or power cut hits only one.

terminal
$ awsl ec2 describe-availability-zones \
    --query 'AvailabilityZones[].[ZoneName,ZoneId,State]' --output table
-----------------------------------------
|       DescribeAvailabilityZones       |
+------------+------------+-------------+
|  eu-west-1a|  euw1-az3  |  available  |
|  eu-west-1b|  euw1-az1  |  available  |
|  eu-west-1c|  euw1-az2  |  available  |
+------------+------------+-------------+

Notice the two names. eu-west-1a is the zone name you'll type every day. euw1-az3 is the zone ID, the actual physical location. AWS shuffles which name points to which ID per account, so your 1a may be someone else's 1b. That matters later (§11).

One region, three zones
region eu-west-1 (Ireland)zone eu-west-1azone eu-west-1bzone eu-west-1capp copy 1db primaryapp copy 2db standby~1 ms(spare zone)
A region (dashed) contains several zones. Copies of the app and database sit in different zones, so losing one building still leaves a working copy. Round-trip times are approximate.

What survives what

The rule is simple: a copy only helps if it's somewhere the failure isn't. Try it. The "Multi-AZ" layout puts app copies in two zones behind a Load balancer, and uses a managed database that keeps a standby copy in a second zone:

Break a zone, break a region
Pick a layout, then a failure. Which layouts keep the site up?
layout
failure
region · eu-west-1 (Ireland)
zone eu-west-1a
app + db
zone eu-west-1b
zone eu-west-1c
region · us-east-1 (N. Virginia)
zone us-east-1a
zone us-east-1b
zone us-east-1c
One VM runs the app and its database. Rough monthly cost compared with one VM: 1×.
✓ Everything is up. Users are served from eu-west-1.

The load balancer sends traffic only to copies that pass its health checks, so a dead zone simply stops getting requests. That's covered in depth in Load Balancing §06.

How to choose a region

  1. Near your users. Every request crosses the distance at least twice. Paris to Ireland is roughly 15–20 ms per round trip; Paris to Virginia roughly 80 ms. A page that makes 10 sequential calls feels the difference.
  2. Where the law says your data may live. Data residency rules (GDPR in the EU, health-data laws, customer contracts) can decide the region for you.
  3. Price. The same VM can cost 10–30% more in one region than another (approximate; check the price list).
  4. Services. New services and machine types usually launch in the biggest regions first.

Only after those: pick one region, and spread across its zones.

Practitioner§04

VPCs, subnets and route tables

In one sentenceA VPC is your own private network inside the provider's data centers. You carve it into subnets, and each subnet's route table decides whether it can reach the internet, and whether the internet can reach it.

Step 1: an address range

When you create a VPC, you give it a block of private IP addresses in CIDR notation: for example 10.0.0.0/16. The /16 means the first 16 bits are fixed (10.0) and the last 16 are yours, which is 65,536 addresses from 10.0.0.0 to 10.0.255.255. (TCP/IP §04 explains the / notation slowly.)

Addresses that start with 10. are private (so are 172.16.x.x–172.31.x.x and 192.168.x.x): they're never used on the public internet, so nobody outside can even address them. GCP calls this a VPC too; Azure calls it a VNet.

Step 2: subnets, one per zone and role

A Subnet is a smaller slice of the VPC's range that lives in one availability zone. Here's a common layout for one zone (real setups repeat it in a second zone, §03):

SubnetRangeWhat lives there
public10.0.1.0/24 (256 addresses; AWS keeps 5 for itself, so 251 usable)the load balancer, the NAT gateway, an admin VM
private (app)10.0.2.0/24your app servers or containers
private (data)10.0.3.0/24the database

What makes a subnet "public" or "private" isn't a checkbox. It's its route table.

Step 3: route tables decide

A Route table is a short list of rows: destination range → where to send it. Every route table starts with one row you can't delete: 10.0.0.0/16 → local, meaning "anything inside the VPC, deliver directly".

  • A Public subnet adds 0.0.0.0/0 → internet gateway. 0.0.0.0/0 matches every address, so it means "everything else goes out the front door". The Internet gateway connects the VPC to the internet.
  • A Private subnet for apps adds 0.0.0.0/0 → NAT gateway. The NAT gateway lets the app start connections out (to call a payment API, download a package) while nothing outside can start one in. It works like the NAT in your home router.
  • The data subnet often has no 0.0.0.0/0 row at all: the database has no business talking to the internet.

When more than one row matches, the most specific one wins (the one with the bigger / number). 10.0.3.30 matches both 0.0.0.0/0 and 10.0.0.0/16; /16 is more specific, so it stays local.

Click a subnet (anywhere inside its box), then send packets to different places:

Route tables: where does a packet go next?
Click a subnet to see its route table, then pick a destination. The most specific matching row wins.
VPC 10.0.0.0/16 · eu-west-1public 10.0.1.0/24private · app 10.0.2.0/24private · data 10.0.3.0/24Internet user203.0.113.7api.stripe.com54.187.1.1internet gatewayload balancer10.0.1.10 + public IPadmin VM10.0.1.40 + public IPNAT gateway10.0.1.20 + public IPapp server10.0.2.15database10.0.3.30
send a packet to
route table · private subnet (app)10.0.2.0/24
destinationtarget
10.0.0.0/16local
0.0.0.0/0NAT gateway
54.187.1.1 matches 0.0.0.0/0 → NAT gateway: everything else: out through the NAT, replies come back the same way.

Doing it for real (against the emulator)

Here are the same steps as a real script. Each command prints an ID that the next one uses. Save it as build-vpc.sh and run it with bash build-vpc.sh:

build-vpc.sh
#!/usr/bin/env bash
set -euo pipefail                                   # stop at the first error
awsl() { aws --endpoint-url http://localhost:5050 "$@"; }

VPC=$(awsl ec2 create-vpc --cidr-block 10.0.0.0/16 --query Vpc.VpcId --output text)
PUB=$(awsl ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.1.0/24 \
        --availability-zone eu-west-1a --query Subnet.SubnetId --output text)

IGW=$(awsl ec2 create-internet-gateway --query InternetGateway.InternetGatewayId --output text)
awsl ec2 attach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC"

RT=$(awsl ec2 create-route-table --vpc-id "$VPC" --query RouteTable.RouteTableId --output text)
awsl ec2 create-route --route-table-id "$RT" --destination-cidr-block 0.0.0.0/0 \
        --gateway-id "$IGW" > /dev/null
awsl ec2 associate-route-table --route-table-id "$RT" --subnet-id "$PUB" > /dev/null  # ← makes it "public"

awsl ec2 describe-route-tables --route-table-ids "$RT" --query 'RouteTables[].Routes[]' --output table
echo "VPC=$VPC"

Running it against the emulator prints the new route table and the VPC's ID:

terminal
$ bash build-vpc.sh
---------------------------------------------------------------------------------
|                              DescribeRouteTables                              |
+----------------------+-------------------------+--------------------+---------+
| DestinationCidrBlock |        GatewayId        |      Origin        |  State  |
+----------------------+-------------------------+--------------------+---------+
|  10.0.0.0/16         |  local                  |  CreateRouteTable  |  active |
|  0.0.0.0/0           |  igw-3783f35e69e252323  |  CreateRoute       |  active |
+----------------------+-------------------------+--------------------+---------+
VPC=vpc-ca9a7d1638a0e787c

Nobody types these by hand in production. Teams describe the network in Infrastructure as code files (§11 shows a short Terraform example) so it's reviewed and repeatable. But underneath, it's exactly these calls.

Practitioner§05

Security groups: who may connect

In one sentenceRouting decides whether a packet can reach a machine. The machine's security group decides whether it's allowed in. Both must say yes.

A firewall on every machine

A Security group is a Firewall attached to each VM, load balancer or database. You write inbound rules: a protocol, a Port, and where traffic may come from. Anything not listed is dropped silently (the caller sees a timeout, not an error). Outbound is "allow everything" by default.

The "where from" can be an address range, like 0.0.0.0/0 (anyone on the internet), or another security group. That second form is the useful one:

GroupInbound ruleMeans
sg-lbTCP 443 from 0.0.0.0/0anyone may open HTTPS to the load balancer
sg-appTCP 8080 from sg-lbonly things in sg-lb may reach the app's port
sg-dbTCP 5432 from sg-apponly the app may reach Postgres

Rules that point at groups don't care about IP addresses. Add ten more app servers to sg-app, and they can all reach the database with no rule changes.

Stateful: you only write the "who may start" rule

Security groups are a Stateful firewall. When the app opens a connection to the database, the database's reply is allowed back automatically, because the firewall remembers the connection. You never write a rule for replies.

Try it

Each connection goes through two checks in order: routing (§04), then the receiver's inbound rules. Then switch on the two classic mistakes and test "user → database" again:

VPC traffic tester
Pick a connection. It must pass routing first, then the receiver's security group. Then try the two classic mistakes.
VPC 10.0.0.0/16 · eu-west-1public 10.0.1.0/24private · app 10.0.2.0/24private · data 10.0.3.0/24Internet user203.0.113.7api.stripe.com54.187.1.1internet gatewayload balancer10.0.1.10 + public IPadmin VM10.0.1.40 + public IPNAT gateway10.0.1.20 + public IPapp server10.0.2.15database10.0.3.30
security group inbound rules
✓ Connection allowed: user → load balancer :443
  1. Routing: load balancer has a public IP and its subnet routes through the internet gateway. The packet arrives.
  2. sg-lb inbound: rule “TCP 443 from 0.0.0.0/0” matches.
  3. Reply: security groups are stateful — they remember this connection, so the answer flows back without needing its own rule.

Notice that the database stayed safe when you made only one mistake. A database in a private subnet with an open security group is still unreachable from the internet; a database with a public IP and a tight security group is still closed. Two independent layers is the point: it takes two mistakes to expose it. That idea is called defense in depth.

The same rules, for real

To keep the script short it has just two groups: web for the servers that face users, and db for the database. It needs the VPC's ID, which build-vpc.sh printed in §04; the script reads it from its first argument, $1:

security-groups.sh
#!/usr/bin/env bash
set -euo pipefail
awsl() { aws --endpoint-url http://localhost:5050 "$@"; }
VPC=$1   # the VPC id printed by build-vpc.sh

WEB=$(awsl ec2 create-security-group --group-name web --description "HTTPS from anywhere" \
        --vpc-id "$VPC" --query GroupId --output text)
awsl ec2 authorize-security-group-ingress --group-id "$WEB" \
        --protocol tcp --port 443 --cidr 0.0.0.0/0 > /dev/null

DB=$(awsl ec2 create-security-group --group-name db --description "Postgres from web only" \
        --vpc-id "$VPC" --query GroupId --output text)
awsl ec2 authorize-security-group-ingress --group-id "$DB" \
        --protocol tcp --port 5432 --source-group "$WEB"   # ← a group, not an IP range

The emulator's answer to the last call shows the rule pointing at the other group:

terminal
$ bash security-groups.sh vpc-ca9a7d1638a0e787c
{
    "Return": true,
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-98ab019f75251c4f8",
            "GroupId": "sg-6d164ad088bbd5694",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 5432,
            "ToPort": 5432,
            "ReferencedGroupInfo": {
                "GroupId": "sg-ca50f57a4f827da32",
                "UserId": "123456789012"
            },
            "Tags": []
        }
    ]
}

IsEgress: false means it's an inbound rule. FromPort/ToPort is a range, here a single port. UserId is the (fake) Cloud account number.

Network ACLs, briefly

AWS also has a Network ACL: a second firewall on the whole subnet that is stateless (replies need their own rule) and supports explicit "deny" rows. Most teams leave it on the default "allow all" and do their real work in security groups. GCP folds both ideas into VPC firewall rules; Azure uses network security groups.

How do admins get in, then?

Opening SSH (port 22) to 0.0.0.0/0 is the most common way a fresh VM gets attacked: bots scan the whole internet for open port 22 around the clock. In the tester above, the admin VM accepts SSH only from the office's address, 198.51.100.4/32 (/32 means exactly one address). Tick the SSH mistake and try user → admin VM :22 again. The old fix was a Bastion host. The modern fix is no inbound port at all: AWS Session Manager, GCP IAP and Azure Bastion open a shell through the cloud's API, checked by IAM (§06) and logged.

Practitioner§06

IAM: identities, roles and policies

In one sentenceEvery call to the cloud's API is made by someone. IAM answers two questions about each call: who is this? and are they allowed to do exactly this, to exactly that?

Who: principals

Whoever makes a request is the Principal. There are three kinds you'll meet:

  • An IAM user: a long-lived identity with a password and/or a permanent Access key. Fine for a break-glass admin, bad for almost everything else, because permanent keys leak.
  • An IAM role: permissions with no password or key attached. A person, program or cloud service assumes it and gets credentials that expire after minutes to hours. GCP calls this a service account, Azure a managed identity.
  • People in a real company sign in through single sign-on (the company login, with MFA) and land in a role. Nobody needs a personal permanent key.

What: policies

An IAM policy is a JSON document. This one lets a role read and write user avatars, and nothing else:

avatars-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "ReadWriteAvatars",
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": "arn:aws:s3:::atlas-avatars/users/*"
  }]
}

How a request is judged

AWS checks every request the same way. It starts from no (Implicit deny), and then:

  1. If any matching statement says Deny → denied. A Deny always wins.
  2. Otherwise, if any matching statement says Allow → allowed.
  3. Otherwise → still denied.

Order doesn't matter, and neither does which statement is "more specific". Try to break it: attach AdministratorAccess, then the no-deletes Deny, and ask for s3:DeleteObject:

IAM policy evaluator
The role avatar-api asks to do something. Attach policies, pick the request, and follow AWS's three questions.
request
identity policies on role avatar-api
// policy avatars-rw
{ "Effect": "Allow",
  "Action": ["s3:GetObject","s3:PutObject"],  ✓
  "Resource": "arn:aws:s3:::atlas-avatars/users/*"  ✓ }
  1. Does any Deny statement match both the action and the resource? No.
  2. Does any Allow statement match both? Yes (avatars-rw) → allowed.
✓ Allowed: s3:GetObject

The evaluator only shows identity policies, the ones attached to the role. Real AWS has a few more kinds, and they don't all combine the same way:

  • A resource policy sits on the thing being accessed, like a bucket policy on an S3 bucket. Within one account, an Allow in either the role's policies or the bucket policy is enough.
  • Guardrails set by the company (§11) and permission boundaries (an upper limit someone puts on a role) work the other way: they can only take away. The request must be allowed by them as well.
  • A matching Deny anywhere still wins over everything.

The trust policy: who may wear the role

A role has two policies. avatars-policy.json above says what the role may do. The Trust policy says who may assume it. In production, the trust policy for a role used by VMs names the EC2 service:

trust-ec2.json (production)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ec2.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}

For the demo below, we will assume the role from the terminal, so the trust policy must name our own (fake) account instead. arn:aws:iam::123456789012:root means "identities in account 123456789012 that are themselves allowed to call sts:AssumeRole":

trust.json (demo)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
    "Action": "sts:AssumeRole"
  }]
}
Two gates: trust, then permissions
Who may wear the role is checked once; what the role may do is checked on every call.
Caller
app on EC2 VM i-0abc
Gate 1 · trust policy
Principal: Service ec2.amazonaws.com
STS credentials
waiting
Gate 2 · permission policy
avatars-rw
An app on a VM wants to use the role avatar-api. On real AWS the VM does this for you behind the scenes; the SDK simply asks the instance metadata service for credentials.
1 / 5

Watching a real AccessDenied

The emulator skips permission checks by default. moto has a switch to turn them on after a few setup calls, so we can see real enforcement. Start a second emulator in the same terminal, save avatars-policy.json and trust.json from above, and let the first five calls through to set things up:

terminal
docker run --rm -d --name moto-iam -p 5051:5000 \
  -e INITIAL_NO_AUTH_ACTION_COUNT=5 motoserver/moto:5.2.5   # ← checks start after 5 calls
awsi() { aws --endpoint-url http://localhost:5051 "$@"; }

echo hello > avatar-42.png                  # a 6-byte stand-in for an image
awsi s3 mb s3://atlas-avatars                                                      # call 1
awsi s3 cp avatar-42.png s3://atlas-avatars/users/42/avatar.png                    # call 2
awsi iam create-role --role-name avatar-api --assume-role-policy-document file://trust.json   # call 3
awsi iam put-role-policy --role-name avatar-api --policy-name avatars-rw \
  --policy-document file://avatars-policy.json                                     # call 4

The fifth call asks STS to let us assume the role. It returns temporary credentials, which we store straight into the three environment variables the CLI reads:

terminal
$ read -r AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN EXPIRES < <(
    awsi sts assume-role --role-arn arn:aws:iam::123456789012:role/avatar-api \
      --role-session-name demo \
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken,Expiration]' --output text)
$ export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
$ echo "$AWS_ACCESS_KEY_ID expires $EXPIRES"
ASIARZPUZDIKNAPPDME5 expires 2026-10-30T23:58:41.408440Z

Note the expiry, one hour later, and that the key starts with ASIA (temporary) instead of AKIA (permanent). From now on this terminal acts as the role. (The emulator now checks every call, so the old test key is rejected with InvalidAccessKeyId; open a fresh terminal for §07.)

terminal (as the role)
$ awsi s3 cp s3://atlas-avatars/users/42/avatar.png -
hello
$ awsi s3 rm s3://atlas-avatars/users/42/avatar.png
delete failed: s3://atlas-avatars/users/42/avatar.png An error occurred (AccessDenied) when calling the DeleteObject operation: Access Denied
$ awsi s3 ls s3://atlas-avatars

An error occurred (AccessDenied) when calling the ListObjectsV2 operation: Access Denied

Reading is allowed. Deleting isn't in the policy, so it's an implicit deny. And even listing the bucket fails: s3 ls calls the ListObjectsV2 operation, which needs s3:ListBucket on the bucket itself, and the policy only covers objects under users/*. Those surprises are normal; you add exactly the permission that's missing.

No keys in your code

When the role is attached to a VM, container or function, your code doesn't need any credentials. The cloud SDK follows the Default credential chain: environment variables, then your local login, then the role of the machine it's on, which it gets from the Instance metadata service. It also renews the temporary credentials before they expire.

upload a file with whatever identity the code is running as
cfg, err := config.LoadDefaultConfig(ctx) // ← finds credentials by itself
if err != nil {
	log.Fatal(err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
	o.UsePathStyle = true // only needed for local emulators
})

_, err = client.PutObject(ctx, &s3.PutObjectInput{
	Bucket: aws.String("atlas-avatars"),
	Key:    aws.String("users/7/avatar.png"),
	Body:   strings.NewReader("fake png bytes"),
})
if err != nil {
	log.Fatal(err)
}
fmt.Println("uploaded users/7/avatar.png to region", cfg.Region)

Both ran against the first emulator (after awsl s3 mb s3://atlas-avatars there) with only environment variables set: the fake keys from §01, AWS_REGION=eu-west-1 (the SDKs ignore AWS_DEFAULT_REGION) and AWS_ENDPOINT_URL=http://localhost:5050, which tells every SDK to talk to the emulator. Versions: aws-sdk-go-v2 (s3 v1.114) and @aws-sdk/client-s3 3.1161.

terminal
$ go run .
uploaded users/7/avatar.png to region eu-west-1
$ node upload.mjs
uploaded users/8/avatar.png to region eu-west-1

On a real VM with a role attached you'd drop the endpoint and the fake keys, and the identical code would use the role.

Least privilege, in practice

Least privilege means each identity gets only what it needs. In practice:

  • One role per workload. The avatar service and the billing job don't share a role, so a bug in one can't touch the other's data.
  • Name actions and resources. s3:GetObject on atlas-avatars/users/*, not s3:* on *.
  • Start narrow and add. An AccessDenied in staging tells you exactly which permission is missing. Starting from AdministratorAccess and "tightening later" usually means never.
  • Use the tools. AWS IAM Access Analyzer can draft a policy from the calls a role actually made; GCP and Azure have similar recommenders.

How secrets that aren't cloud credentials (database passwords, API tokens) reach your app is its own topic: see Secrets §09 for workload identity in depth.

Practitioner§07

The core managed services

In one sentenceA Managed service is infrastructure the provider installs, patches, backs up and keeps running. You get an API and a bill; you never log into its servers.

There are hundreds of cloud services, but almost every app is built from the same eight or so. Follow one photo upload through all of them:

One upload, eight managed services
Follow a profile-photo upload through a typical cloud setup. AWS names first; GCP and Azure in brackets.
Edge (outside your VPC)
DNS · Route 53
CDN · CloudFront
Your VPC · public subnets
Load balancer · ALB
Compute · private subnets
App · containers on Fargate (AZ a + b)
Worker · Lambda function
Managed data services
Postgres · RDS (+ standby)
Files · S3 bucket
Queue · SQS
A user opens app.example.com. The browser asks DNS for the address. The managed DNS service (AWS Route 53 · Google Cloud DNS · Azure DNS) answers with the address of the CDN, not of your servers.
1 / 9

Now each building block on its own.

Compute: where your code runs

You met the three shapes in §02: VMs (EC2), containers on a managed platform (Fargate, Cloud Run), and functions (Lambda). All of them can scale out (add copies) using Autoscaling: you set a rule like "keep average CPU near 60%, between 2 and 10 copies", and the platform adds or removes copies.

Storage: three kinds, not one

KindWhat it isAWS (GCP · Azure)Use it for
Objectfiles by key over HTTP, almost unlimitedS3 (Cloud Storage · Blob Storage)uploads, images, backups, logs, static sites
Blocka virtual disk for one VMEBS (Persistent Disk · Managed Disks)a VM's root disk, a self-run database's data
Filea shared network driveEFS (Filestore · Azure Files)old apps that need a shared folder

Object storage is the default. It is cheap (roughly $0.02 per GB per month), keeps several copies across zones for you, and every language has a client. It is not a disk: you can't open a file and change byte 100; you replace the whole object.

Back in the first terminal from §01 (the emulator on port 5050), with a new bucket name, since §06 already made atlas-avatars there. If you skipped §06, first run echo hello > avatar-42.png to make a small test file:

terminal
$ awsl s3 mb s3://atlas-photos
make_bucket: atlas-photos
$ awsl s3 cp avatar-42.png s3://atlas-photos/users/42/avatar.png
upload: ./avatar-42.png to s3://atlas-photos/users/42/avatar.png
$ awsl s3 ls s3://atlas-photos --recursive
2026-10-30 23:00:04          6 users/42/avatar.png
$ awsl s3 presign s3://atlas-photos/users/42/avatar.png --expires-in 300
http://localhost:5050/atlas-photos/users/42/avatar.png?AWSAccessKeyId=test&Signature=8MWnklgp7dYop%2BjNHpMfmn7jiyk%3D&Expires=1793401504

mb = make bucket. A bucket is a named container for objects. The "folders" in users/42/avatar.png are just part of the key.

The last command makes a Presigned URL for downloading: anyone holding that link can GET this one object for 300 seconds, with no credentials of their own. (CLI v1 prints this older signature format; CLI v2 against real AWS prints a longer X-Amz-Algorithm=AWS4-HMAC-SHA256&… URL. Both work the same way.)

Presigned uploads

aws s3 presign only makes download links. For the upload in the walkthrough above, the app creates a signed PUT URL with the SDK and gives it to the browser:

A presigned upload, step by step
Who talks to whom, and what never happens.
Browser
POST /api/avatar-upload-url
Your app
has role credentials
S3
bucket atlas-avatars
The browser has no cloud credentials, and must never get any. It asks your app: “I'd like to upload my avatar.”
1 / 5
sign an upload URL (no request is sent)
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })

// Signing happens right here, with the app's own credentials. No request is sent.
req, err := s3.NewPresignClient(client).PresignPutObject(ctx, &s3.PutObjectInput{
	Bucket: aws.String("atlas-avatars"),
	Key:    aws.String("users/42/avatar.png"),
}, s3.WithPresignExpires(5*time.Minute))
if err != nil {
	log.Fatal(err)
}
fmt.Println(req.Method, req.URL) // hand this URL to the browser

To prove that signing is local, we pointed the Go program at a port where nothing is listening (AWS_ENDPOINT_URL=http://localhost:1). It still printed a signed URL instantly. Pointed at the emulator, it printed a URL that curl (standing in for the browser) could upload to:

terminal
$ AWS_ENDPOINT_URL=http://localhost:5050 go run . > url.txt
$ cat url.txt
PUT http://localhost:5050/atlas-avatars/users/42/avatar.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20261030%2Feu-west-1%2Fs3%2Faws4_request&X-Amz-Date=20261030T225922Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&x-id=PutObject&X-Amz-Signature=d7ec8be6403483871189cefef5283f58247d14523f7587071990b20980dc796a
$ echo 'fake png bytes' > a.png
$ curl -s -o /dev/null -w '%{http_code}\n' -T a.png "$(cut -d' ' -f2 url.txt)"
200

The emulator doesn't verify signatures, so it would accept a tampered URL too. Real S3 checks the signature and expiry on every request.

Managed databases

A Managed database (RDS, Cloud SQL, Azure Database) gives you a normal Postgres or MySQL with the chores done: installs, minor-version patches, nightly backups, point-in-time restore, and an optional standby Replica in another zone that takes over automatically (Failover) if the main one dies. You still own the schema, the queries, the indexes, and choosing the size. Replication explains what that failover really does.

There are also providers' own databases (DynamoDB, Firestore, Cosmos DB) that scale further but use their own APIs, which ties you more tightly to that provider (§10).

Queues

A managed queue (SQS, Pub/Sub, Azure Service Bus) holds messages until a worker takes them, so slow work happens in the background and survives crashes. Queues covers how they behave; here's the shape:

terminal
$ Q=$(awsl sqs create-queue --queue-name resize-jobs --query QueueUrl --output text)
$ awsl sqs send-message --queue-url $Q --message-body '{"user":42,"key":"users/42/avatar.png"}'
{
    "MD5OfMessageBody": "cc41e16bc84390fc9a815a57089c79af",
    "MessageId": "8740d7ea-5bae-4cf7-a352-71314bc1d76a"
}
$ awsl sqs receive-message --queue-url $Q --query 'Messages[0].Body' --output text
{"user":42,"key":"users/42/avatar.png"}

CDN and DNS: the front door

A CDN (CloudFront, Cloud CDN, Azure Front Door) keeps copies of your files in hundreds of cities, so a user in Lisbon gets the image from Lisbon instead of from Ireland. Managed DNS (Route 53, Cloud DNS, Azure DNS) answers "what's the address of app.example.com?" from servers around the world. See Caching §09 for how CDNs decide what to keep, and DNS for everything about names.

The lookup table

NeedAWSGCPAzure
VMsEC2Compute EngineVirtual Machines
Containers, managedECS/Fargate, App RunnerCloud RunContainer Apps
KubernetesEKSGKEAKS
FunctionsLambdaCloud Run functionsFunctions
Object storageS3Cloud StorageBlob Storage
Block disksEBSPersistent DiskManaged Disks
Managed Postgres/MySQLRDS, AuroraCloud SQL, AlloyDBAzure Database
Queue / pub-subSQS, SNSPub/SubService Bus, Queue Storage
CDNCloudFrontCloud CDNFront Door
DNSRoute 53Cloud DNSAzure DNS
Load balancerELB (ALB/NLB)Cloud Load BalancingLoad Balancer, App Gateway
Private networkVPCVPCVNet
IdentityIAMIAMEntra ID + RBAC
SecretsSecrets ManagerSecret ManagerKey Vault

Kubernetes is its own big subject; see the Kubernetes topic.

Practitioner§08

The shared responsibility model

In one sentenceThe Shared responsibility model model in one line: the provider secures the cloud (buildings, hardware, their software); you secure what you put in it (data, access, network settings, code).

The line moves with the service model from §02. The more managed the service, the more the provider does. But some jobs never move: identity, data, network exposure and your own code are always yours. Test yourself, then switch the service:

Whose job is it?
For each event, guess who must handle it. Then switch the service and see how the answers move.
A physical disk dies in the data center.
The operating system needs an urgent security patch.
Your language runtime or database version reaches end of life.
Something is reachable by anyone on the internet.
An access key leaks in a public GitHub repo.
One availability zone loses power.
A bad migration deletes a table; you need yesterday's data back.
Answered 0 / 7

How cloud breaches actually happen

Providers are very rarely the weak point. The incidents that make the news are almost always on the customer's side of the line:

#1

A public bucket

Someone makes a bucket public "just to test", and it holds customer exports. Bots scan for open buckets constantly. AWS now turns on Block Public Access for new buckets by default (since April 2023), and you should leave it on at the account level. If files must be public, serve them through a CDN (§07) rather than opening the bucket.

#2

A leaked access key

A permanent Access key gets committed to GitHub, pasted into a chat, or baked into a container image. Within minutes, bots use it to start crypto-mining VMs on your bill. The fix is §06: roles and short-lived credentials, so there's no permanent key to leak. Secrets covers scanning and cleanup.

#3

A port open to the world

SSH (22), a database (5432, 3306) or an admin panel reachable from 0.0.0.0/0. §05's two layers — private subnets and tight security groups — mean it takes two mistakes to get here.

#4

Too much permission

A server's role has AdministratorAccess "to make it work". One bug in that server and the attacker can do anything in the account. The classic bug is SSRF: the attacker makes your server fetch http://169.254.169.254/…, the Instance metadata service from §06, and walks away with the role's credentials. Require IMDSv2 on every VM (it needs a token obtained with a PUT request first, which most SSRF bugs can't send) and keep roles small. Least privilege keeps the Blast radius of one bug small.

#5

Backups that were never restored

Managed databases take backups automatically, but a backup you've never restored is a hope, not a plan. Practise a restore into a scratch database every few months, and time it.

A short safety checklist for any new account

  • Turn on MFA for every human, and stop using the account's root login for daily work.
  • Keep Block Public Access on for storage, account-wide.
  • Turn on the API audit log (AWS CloudTrail, GCP Cloud Audit Logs, Azure Activity Log). It's the Audit log you'll need when something goes wrong.
  • Use roles for workloads; no permanent keys in code, images or CI.
  • Set a budget alert (§09), because a surprise bill is often the first sign of a stolen key.
Practitioner§09

Pricing and the egress surprise

In one sentenceYou pay for what runs (per second), what's stored (per GB-month), and what moves (per GB). The first two are easy to predict. The third is where surprise bills come from.

A worked example first

A small app: two VMs (2 vCPU, 8 GB each) running all month, and 500 GB of downloads to users.

  • VMs: 2 × ~$0.08/hour × 730 hours ≈ $117
  • Downloads: (500 GB − 100 GB free) × ~$0.09/GB ≈ $36

Now the product team ships video, and users download 30,000 GB a month. The VMs don't change, but downloads become 29,900 × $0.09 ≈ $2,690. Nobody launched a server; the bill went up nearly 20×.

The three ways to pay for machines

ModelPriceThe deal
On-demand pricinglist pricestop any second, no promise
Commitment≈ 30–60% offpromise to spend a set amount per hour for 1 or 3 years
Spot≈ 60–90% offthe provider can take the machine back with ~2 minutes' warning

A common recipe: cover your steady base load with a commitment, run the peaks on-demand, and run interruptible work (CI runners, batch jobs) on spot.

Data transfer: what moves costs money

Egress pricing has a shape you need to remember:

  • Into the cloud: free.
  • Out to the internet: ~$0.09/GB (the first 100 GB a month are free on AWS; volume tiers lower it).
  • Between zones in one region: ~$0.01/GB each way. Your multi-AZ setup (§03) pays this on every call between services in different zones.
  • Through a NAT gateway (§04): ~$0.045/hour plus ~$0.045 per GB processed, on top of any egress. Private servers pulling large container images or backups through NAT add up fast.
  • Between regions: ~$0.02/GB.

Play with it. Start from Small SaaS, then try Video feature ships and Chatty microservices:

What will this cost per month?
Approximate list prices (us-east-1), simplified: egress uses one flat price, while real prices drop a little above 10 TB. Start from a preset, then move the sliders and watch which line grows.
4
500 GB
800 GB
300 GB
600 GB
≈ per month$576
App VMs (4 × 1-yr commitment (≈ −35%))$152
Load balancer$20.00
Managed Postgres + 100 GB disk (Multi-AZ = 2 copies)$271
Object storage (500 GB stored)$11.50
Data out to the internet (800 GB, flat ~$0.09/GB)· per GB$63.00
NAT gateway (300 GB through it)· per GB$46.35
Traffic between zones (600 GB)· per GB$12.00
Data-transfer lines (egress, NAT, cross-zone) are 21% of this bill. Worth a look before it grows.

Bringing the sneaky lines down

  • Serve downloads through a CDN (§07). Origin-to-CDN transfer is free on AWS, CDN prices can be lower than raw egress, and caching means each file leaves your origin once instead of a million times.
  • Talk to S3 privately. A VPC endpoint for S3 (a "gateway endpoint", which costs nothing extra) keeps that traffic off the NAT gateway.
  • Keep chatty services together. Prefer calling a copy of a service in your own zone when there's one; batch small calls.
  • Turn off what's idle. Dev and staging environments that run nights and weekends are ~70% wasted hours (only 50 of the 168 hours in a week are working hours).
  • Watch the small fixed fees. Every public IPv4 address costs ~$3.60/month on AWS (since February 2024); unattached disks and forgotten snapshots keep billing.
Staff-level§10

Managed or self-run?

In one sentenceCompare the total cost of ownership, not the hourly price. A managed service usually costs more on the bill and less in engineer time, and engineer time is the expensive part.

A worked example: Postgres

You need a production Postgres with a standby in a second zone. Two ways (approximate us-east-1 prices, 2 vCPU / 8 GB, 100 GB disk):

Self-run on two VMsManaged (RDS Multi-AZ)
Bill per month2 × ~$58 VMs + ~$20 disks ≈ $1362 × (~$124 + ~$12 disk) ≈ $272
Install, configure replication and failoveryou: days, oncea checkbox
OS and Postgres patchesyou, monthlyprovider, in a window you pick
Backups and point-in-time restoreyou build and test itbuilt in; you test restores
3 a.m. failoveryour on-call engineer, your runbookautomatic, ~1–2 minutes

The managed one costs ~$135 more per month. If running Postgres yourself takes even one engineer-day a month (patching, checking backups, upgrades, the occasional incident), that day costs far more than $135. This is the TCO argument, and for small and medium teams it wins almost every time.

When running it yourself wins

  • Scale. At hundreds of machines, the managed premium becomes a real number, and you can afford a team that does nothing else.
  • Something the managed version can't do. A Postgres extension it doesn't support, a kernel setting, a specific version.
  • The skill is your product. A database company runs its own databases.
  • Hard portability needs. You must run the same thing on-premises or in several clouds.

Even then, teams often start managed and move one piece off it later, once the numbers and the need are clear.

Lock-in: worth naming, rarely worth fearing

Vendor lock-in is how expensive it would be to leave. It comes in two kinds:

  • Commodity services speak open standards: Postgres and MySQL (RDS, Cloud SQL), the S3 API (copied by many other storage products), containers, Kubernetes, OpenTelemetry for Observability. Moving these is work, but ordinary work.
  • Proprietary services have their own API and behaviour: DynamoDB, Firestore, Step Functions, provider-specific queues and event buses. They can be excellent, and they're the parts you'd rewrite to move.

The pragmatic stance: use proprietary services where they save real effort, but keep them behind a small interface in your code, so the rest of the app doesn't know which cloud it's on.

the app depends on two methods, not on a cloud SDK
// Blobs is all the app knows about file storage.
type Blobs interface {
	Put(ctx context.Context, key string, body io.Reader) error
	Get(ctx context.Context, key string) (io.ReadCloser, error)
}

func saveAvatar(ctx context.Context, store Blobs, userID int, img io.Reader) error {
	return store.Put(ctx, fmt.Sprintf("users/%d/avatar.png", userID), img)
}
// An S3 version, a GCS version and an in-memory fake for tests
// each implement Put and Get; main() picks one.

Multi-cloud (running the same system on two providers at once) is a different and much bigger decision. It doubles the expertise you need and limits you to what both clouds share. It's usually justified by regulation or a very large customer, not by fear of lock-in.

A quick decision guide

  1. Does a managed service do what you need? → Use it.
  2. Is it a commodity (Postgres, S3-style storage, containers)? → Managed, with little lock-in worry.
  3. Is it proprietary? → Fine, if it saves real work. Wrap it behind an interface.
  4. Would self-running save a lot of money and do you have people to run it well, on call, for years? → Then consider self-running, and write down the reasons.
Staff-level§11

Staff-level: running a cloud estate

In one sentenceAt company scale, the hard parts aren't single services. They're boundaries (who can touch what), ownership (who pays for what), and limits (what fails when you grow or when the provider has a bad day).

One account is a trap: use many

Put everything in one Cloud account and every developer's mistake can reach production, every bill is one blob, and one leaked admin key opens everything. Mature setups use a Landing zone: separate accounts (or GCP projects, Azure subscriptions) for production, staging, each team's sandbox, shared networking, and a locked-down account that only collects audit logs.

An account boundary is the strongest wall the cloud offers: nothing crosses it unless someone explicitly allows it. That makes it the natural Blast radius limit.

Guardrails above IAM

An Organization policy sits above every account and caps what any IAM policy inside it can allow. Even an account admin can't get past it. (One exception on AWS: SCPs don't apply to the organization's management account, which is why nobody runs workloads there.) A common one keeps everything in the EU (simplified: real versions exempt global services such as IAM):

deny-non-eu-regions.json (simplified)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "StayInEurope",
    "Effect": "Deny",
    "Action": "*",
    "Resource": "*",
    "Condition": {
      "StringNotEquals": { "aws:RequestedRegion": ["eu-west-1", "eu-central-1"] }
    }
  }]
}

Condition adds a test to the statement: here, "only if the request is for a region other than these two". Because it's a Deny, it beats any Allow below it (§06). Other favourite guardrails: nobody may turn off the audit log, nobody may make buckets public, nobody may leave the organization.

Tags: every resource has an owner

A Tag like team=payments, env=prod, service=avatar-api lets you split the bill by owner ("cost allocation"), find who to ask about a mystery VM, and write automation ("stop everything tagged env=dev at 8 p.m."). Make tags mandatory in your infrastructure code and enforce them with a guardrail; untagged spend becomes nobody's problem, and grows.

Limits you'll hit

  • Service quotas: per-account caps on vCPUs of a type, IP addresses, VPCs per region and so on. Raising them takes a request and can take days. Check them before a launch or a big load test, not during.
  • Capacity: in a busy zone, a specific machine type can simply be unavailable for a while. Allow several instance types and zones in your Autoscaling settings.
  • API rate limits: the cloud's own API throttles you. A deploy script that calls describe-* in a tight loop for 500 servers will start seeing throttling errors.

Zone names lie (a little)

Remember §03: eu-west-1a in your account and eu-west-1a in a partner's account can be different buildings. When two accounts must line up (say, to keep traffic in one zone and avoid cross-zone charges, or to understand whether an outage hits you), compare zone IDs like euw1-az3, not names.

When the provider breaks

Providers fail too, and the painful failures are usually in the Control plane, the API that creates and changes things, rather than in machines that are already running. Large incidents in us-east-1 (for example in December 2021 and October 2025) left many running workloads fine while launching, scaling or changing anything failed, and broke global features that quietly depended on that one region.

Lessons that follow:

  • Design so that already-running capacity can carry you for a while without new launches (some headroom, not autoscaling-to-zero for critical paths).
  • Know which "global" features secretly depend on one region (sign-in, DNS changes, some dashboards).
  • Multi-region is a real option for the few systems that need it, but it's a project, not a setting. Pair it with observability good enough to tell "us" from "them".

The network, as code

None of §04–§06 should be clicked together in production. Here's the public subnet and the database rule from earlier as Terraform (HCL, AWS provider v5+):

network.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags       = { Name = "main", team = "platform", env = "prod" }
}

resource "aws_subnet" "public_a" {
  vpc_id            = aws_vpc.main.id      # ← reference, not a pasted ID
  cidr_block        = "10.0.1.0/24"
  availability_zone = "eu-west-1a"
}

resource "aws_security_group" "db" {
  name   = "db"
  vpc_id = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = aws_security_group.app.id   # "from sg-app"
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
}

resource "type" "name" { … } declares one thing to create; aws_vpc.main.id refers to another resource's ID, so the tool knows the order to create things in. (aws_security_group.app would be declared the same way.) The Infrastructure as Code topic covers plan, apply, state and drift.

Practitioner§12

Check yourself

Decide before you click. Each answer points back to the section that covers it: Q1 in §03, Q2 in §05, Q3–Q4 in §06, Q5 in §09, Q6 in §10.

QUESTION 1 / 6
Two VMs, one zone

Your app runs on two VMs behind a load balancer, both in eu-west-1a. Zone eu-west-1a loses power. What happens?

QUESTION 2 / 6
Why can't I reach my database?

The app server (10.0.2.15) gets a timeout connecting to the database on port 5432. The database's security group allows TCP 5432 from sg-lb. The app's security group is sg-app. What's wrong?

QUESTION 3 / 6
Deny vs Allow

A role has two policies: Allow s3:* on arn:aws:s3:::reports/*, and Deny s3:DeleteObject on *. It calls DeleteObject on reports/old.csv. Result?

QUESTION 4 / 6
Keys in the code?

Your Go service on a VM needs to read from S3. What's the recommended way to give it credentials?

QUESTION 5 / 6
The surprise bill

You launch a feature that serves 30 TB of video per month straight from object storage to users. Nothing else changes. Roughly which line of the bill grows most?

QUESTION 6 / 6
Managed or self-run?

A three-person team needs Postgres for its product. Which choice usually has the lower total cost of ownership?

Answered 0 / 6
Foundations§13

Cheat sheet

🔌
Rented by the second, via an APIEvery console click, Terraform file and SDK call is an API request. Practise against an emulator (moto, LocalStack).
🏢
Pick how much you runVMs → containers on a platform → functions → SaaS. Further right = less work, less control.
🗺️
Region = city, zone = buildingChoose a region for users, law, price. Run at least two copies in two zones.
🚪
Routing, then security groupPrivate subnets have no way in. Security groups are stateful; reference groups, not IPs.
🔑
IAM: Deny › Allow › noA matching Deny wins; else any Allow; else implicit deny. One role per workload, least privilege.
🙅
No keys in codeAttach a role; the SDK's default credential chain picks up short-lived credentials by itself.
🤝
Shared responsibilityThey secure the cloud; you secure your data, access, network settings and code.
💸
Bytes cost moneyInternet egress, NAT processing and cross-zone traffic are billed per GB. Set a budget alert on day one.
I want to…Reach for
Run a web API without managing serverscontainers on Cloud Run / App Runner / Fargate / Container Apps
React to an event (file uploaded, message queued)a function: Lambda / Cloud Run functions / Azure Functions
Store uploads, backups, static filesobject storage (S3 / Cloud Storage / Blob) + presigned URLs
A Postgres I don't have to babysitRDS / Cloud SQL / Azure Database, Multi-AZ on
Do slow work in the backgrounda queue (SQS / Pub/Sub / Service Bus) and a worker
Let private servers call out to the interneta NAT gateway (and a VPC endpoint for S3 to skip it)
Let admins in without opening port 22Session Manager / IAP / Azure Bastion
Keep teams and environments apartseparate accounts + organization guardrails
Know who owns a resource and its costmandatory tags (team, env, service)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DevOps & CloudCI/CD Pipelines
Read →