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.
The 30-second version
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
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.
$ 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-1aA 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
Elastic
Managed
Everywhere
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:
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).
IaaS, PaaS, SaaS and serverless
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:
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:
| Model | AWS (GCP · Azure) | You hand over | You still do |
|---|---|---|---|
| IaaS | EC2 (Compute Engine · Virtual Machines) | a VM image or start-up script | patch the OS, copy your compiled binary onto it, restart it on crash, add VMs when busy |
| PaaS / containers | App Runner, ECS on Fargate (Cloud Run · Container Apps) | a Container image | build the image, set CPU/memory and min/max copies |
| Functions | Lambda (Cloud Run functions · Azure Functions) | one handler function, zipped | write the handler; keep it short and stateless |
| SaaS | — | nothing | nothing: 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.
Regions and availability zones
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.
$ 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).
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:
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
- 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.
- 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.
- Price. The same VM can cost 10–30% more in one region than another (approximate; check the price list).
- Services. New services and machine types usually launch in the biggest regions first.
Only after those: pick one region, and spread across its zones.
VPCs, subnets and route tables
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):
| Subnet | Range | What lives there |
|---|---|---|
| public | 10.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/24 | your app servers or containers |
| private (data) | 10.0.3.0/24 | the 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/0matches 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/0row 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:
| destination | target |
|---|---|
| 10.0.0.0/16 | local |
| 0.0.0.0/0 | NAT gateway |
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:
#!/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:
$ 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-ca9a7d1638a0e787cNobody 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.
Security groups: who may connect
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:
| Group | Inbound rule | Means |
|---|---|---|
sg-lb | TCP 443 from 0.0.0.0/0 | anyone may open HTTPS to the load balancer |
sg-app | TCP 8080 from sg-lb | only things in sg-lb may reach the app's port |
sg-db | TCP 5432 from sg-app | only 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:
- Routing: load balancer has a public IP and its subnet routes through the internet gateway. The packet arrives.
- sg-lb inbound: rule “TCP 443 from 0.0.0.0/0” matches.
- 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:
#!/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 rangeThe emulator's answer to the last call shows the rule pointing at the other group:
$ 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.
IAM: identities, roles and policies
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:
{
"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:
- If any matching statement says
Deny→ denied. A Deny always wins. - Otherwise, if any matching statement says
Allow→ allowed. - 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:
// policy avatars-rw { "Effect": "Allow", "Action": ["s3:GetObject","s3:PutObject"], ✓ "Resource": "arn:aws:s3:::atlas-avatars/users/*" ✓ }
- Does any Deny statement match both the action and the resource? No.
- Does any Allow statement match both? Yes (avatars-rw) → allowed.
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:
{
"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":
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:root" },
"Action": "sts:AssumeRole"
}]
}avatar-api. On real AWS the VM does this for you behind the scenes; the SDK simply asks the instance metadata service for credentials.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:
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 4The 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:
$ 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.408440ZNote 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.)
$ 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 DeniedReading 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.
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.
$ go run .
uploaded users/7/avatar.png to region eu-west-1
$ node upload.mjs
uploaded users/8/avatar.png to region eu-west-1On 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:GetObjectonatlas-avatars/users/*, nots3:*on*. - Start narrow and add. An
AccessDeniedin staging tells you exactly which permission is missing. Starting fromAdministratorAccessand "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.
The core managed services
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:
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.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
| Kind | What it is | AWS (GCP · Azure) | Use it for |
|---|---|---|---|
| Object | files by key over HTTP, almost unlimited | S3 (Cloud Storage · Blob Storage) | uploads, images, backups, logs, static sites |
| Block | a virtual disk for one VM | EBS (Persistent Disk · Managed Disks) | a VM's root disk, a self-run database's data |
| File | a shared network drive | EFS (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:
$ 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=1793401504mb = 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:
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 browserTo 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:
$ 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)"
200The 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:
$ 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
| Need | AWS | GCP | Azure |
|---|---|---|---|
| VMs | EC2 | Compute Engine | Virtual Machines |
| Containers, managed | ECS/Fargate, App Runner | Cloud Run | Container Apps |
| Kubernetes | EKS | GKE | AKS |
| Functions | Lambda | Cloud Run functions | Functions |
| Object storage | S3 | Cloud Storage | Blob Storage |
| Block disks | EBS | Persistent Disk | Managed Disks |
| Managed Postgres/MySQL | RDS, Aurora | Cloud SQL, AlloyDB | Azure Database |
| Queue / pub-sub | SQS, SNS | Pub/Sub | Service Bus, Queue Storage |
| CDN | CloudFront | Cloud CDN | Front Door |
| DNS | Route 53 | Cloud DNS | Azure DNS |
| Load balancer | ELB (ALB/NLB) | Cloud Load Balancing | Load Balancer, App Gateway |
| Private network | VPC | VPC | VNet |
| Identity | IAM | IAM | Entra ID + RBAC |
| Secrets | Secrets Manager | Secret Manager | Key Vault |
Kubernetes is its own big subject; see the Kubernetes topic.
Pricing and the egress surprise
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
| Model | Price | The deal |
|---|---|---|
| On-demand pricing | list price | stop any second, no promise |
| Commitment | ≈ 30–60% off | promise to spend a set amount per hour for 1 or 3 years |
| Spot | ≈ 60–90% off | the 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:
| 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 |
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.
Managed or self-run?
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 VMs | Managed (RDS Multi-AZ) | |
|---|---|---|
| Bill per month | 2 × ~$58 VMs + ~$20 disks ≈ $136 | 2 × (~$124 + ~$12 disk) ≈ $272 |
| Install, configure replication and failover | you: days, once | a checkbox |
| OS and Postgres patches | you, monthly | provider, in a window you pick |
| Backups and point-in-time restore | you build and test it | built in; you test restores |
| 3 a.m. failover | your on-call engineer, your runbook | automatic, ~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.
// 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
- Does a managed service do what you need? → Use it.
- Is it a commodity (Postgres, S3-style storage, containers)? → Managed, with little lock-in worry.
- Is it proprietary? → Fine, if it saves real work. Wrap it behind an interface.
- 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: running a cloud estate
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):
{
"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+):
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.
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.
Your app runs on two VMs behind a load balancer, both in eu-west-1a. Zone eu-west-1a loses power. What happens?
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?
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?
Your Go service on a VM needs to read from S3. What's the recommended way to give it credentials?
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?
A three-person team needs Postgres for its product. Which choice usually has the lower total cost of ownership?
Cheat sheet
| I want to… | Reach for |
|---|---|
| Run a web API without managing servers | containers 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 files | object storage (S3 / Cloud Storage / Blob) + presigned URLs |
| A Postgres I don't have to babysit | RDS / Cloud SQL / Azure Database, Multi-AZ on |
| Do slow work in the background | a queue (SQS / Pub/Sub / Service Bus) and a worker |
| Let private servers call out to the internet | a NAT gateway (and a VPC endpoint for S3 to skip it) |
| Let admins in without opening port 22 | Session Manager / IAP / Azure Bastion |
| Keep teams and environments apart | separate accounts + organization guardrails |
| Know who owns a resource and its cost | mandatory tags (team, env, service) |