Notes · Learn AWS · CHAPTER 5

Storage

S3, EBS, EFS, and the cold tiers. The buckets are easy to make - the bills are not. This is the chapter where the per-GB sticker price is the cheap part, and the lifecycle, retrieval, and KMS-call traps actually live.

In Azure, storage is one resource (Storage Account) with four faces (Blob, File, Queue, Table) bolted onto it. In AWS the storage services are distinct top-level services: S3 (object), EBS (block, attached to EC2), EFS (NFS file share), and the FSx family for managed Windows/Lustre/NetApp/OpenZFS filesystems. Each has its own pricing model, its own access model, and its own gotchas. The good news: 80% of the time you only need S3, and once you've mapped Hot/Cool/Archive to Standard/IA/Glacier, the vocabulary clicks.

In this chapter
  1. The cheat table
  2. S3 fundamentals (buckets, keys, regions)
  3. Storage classes & the cost ladder
  4. Lifecycle rules
  5. Versioning & MFA delete
  6. EBS volume types
  7. EFS & the FSx family
  8. Try it: make a bucket, version it, kill it
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: telemetry bucket
  12. Recap & next

The cheat table: Azure Storage → AWS storage

ConceptAzureAWS
Object storageBlob Storage (containers + blobs)S3 (buckets + objects)
Block storage for VMsManaged Disks (Premium SSD, Standard SSD, HDD)EBS volumes (gp3, io2, st1, sc1)
Shared filesystem (NFS/SMB)Azure FilesEFS (NFS) / FSx for Windows (SMB)
Cold/archive tierBlob Archive tierS3 Glacier Flexible / Glacier Deep Archive
Enterprise filesystemAzure NetApp FilesFSx for NetApp ONTAP
HPC/parallel filesystemno first-party equivalent (BeeGFS via marketplace)FSx for Lustre
Time-limited shareable URLSAS token / SAS URLPresigned URL
Storage accountStorage Account (parent for Blob/File/Queue/Table)none - each service is independent, no parent container
Hot tier (frequent access)HotS3 Standard
Cool tier (infrequent)Cool (30-day min)S3 Standard-IA (30-day min)
Cold tierCold (90-day min)S3 Glacier Instant Retrieval (90-day min)
Archive tierArchive (180-day min, hours to rehydrate)S3 Glacier Flexible (90d) / Deep Archive (180d)
Lifecycle policiesStorage lifecycle management rulesS3 Lifecycle configuration (per-bucket JSON/XML)
VersioningBlob versioning (opt-in)S3 versioning (opt-in, irreversible once on)
Object lock / immutabilityImmutable blob policiesS3 Object Lock (governance / compliance modes)
Default encryptionSSE with platform key (always on)SSE-S3 (free) / SSE-KMS (per-call charges)
Cross-region replicationGRS / RA-GRS storageS3 CRR (Cross-Region Replication) - explicit rule
Static websiteStatic website on Storage AccountS3 static website hosting + CloudFront
The row to internalize: Azure has Storage Accounts wrapping four services; AWS does not. There is no parent over S3 + EBS + EFS - they're separate services with separate IAM, separate billing lines, and separate quotas. Tags are how you group them logically. If you're used to "everything in one storage account", expect S3 buckets, EBS volumes, and EFS filesystems to feel scattered until you adopt a tagging discipline.
What's in a name? - the storage glossary
S3
Simple Storage Service. Launched March 14, 2006 - the first AWS service to go public. Original S3 had no server-side encryption, no versioning, no multipart upload, and no folders at all. Every feature that "feels native" today was bolted on later. s3:// URLs predate https:// S3 endpoints.
EBS
Elastic Block Store. Launched August 2008. Block-level storage attached to one EC2 instance at a time (multi-attach exists for io1/io2 but is niche). "Elastic" because you can resize volumes online - back in 2008, that was novel.
EFS
Elastic File System. NFSv4-compatible managed file share, launched 2016 - eight years after EBS. AWS users had been begging for shared filesystems for years; EFS finally answered.
FSx
FileSystem family. The "x" is a placeholder for the underlying engine: FSx for Windows File Server, FSx for Lustre, FSx for NetApp ONTAP, FSx for OpenZFS. AWS uses FSx when they wrap a third-party or specialized filesystem rather than building their own.
Glacier
The cold-storage class. Named for the visceral "freezing your data" metaphor - retrieval used to take hours and felt like chiseling ice. Originally a separate service (Amazon Glacier, 2012); merged into S3 as a storage class in 2018. The "Vault" terminology you sometimes see is the legacy Glacier API.
IA
Infrequent Access. The S3 storage class for data accessed less than once a month. Same milliseconds-fast retrieval as Standard, but cheaper storage and a per-GB retrieval fee. Tradeoff: don't put hot data here.
"Bucket"
An S3 container. The name comes from the early-2000s engineering metaphor of "dumping objects in a bucket". Globally unique across all AWS accounts on earth - this is the row that bites everyone first.
Fun fact S3 stores over 400 trillion objects and routinely handles tens of millions of requests per second. It was the first AWS service to cross a trillion objects (back in 2012) and has roughly doubled every two years since. The folders you see in the console are an illusion - S3 keys are flat strings, and the console just splits on / to render a tree view. The original launch had no per-object ACLs (only bucket-level) and no concept of folders at all.

S3 fundamentals: buckets, keys, regions

ELI5: how S3 organizes data
Think of S3 as one giant phone book for the whole planet. A bucket is a chapter name (must be unique across every AWS customer alive). An object is a phone-book entry. The key is the entry's full name including any slashes - 2025/q1/report.pdf looks like a folder path but is actually one flat string. There are no real folders; the console just draws them so your brain stays happy.

Three properties of S3 catch every Azure dev off guard:

Bucket names are global

Not unique-per-region, not unique-per-account - globally unique across all of AWS. my-data is taken. So is backup. So is logs. Prefix with your org name and a UUID-ish suffix.

Buckets pin to a region

You pick the region at create time. The bucket lives there. The console hides buckets from other regions by default - leading to "where did my bucket go?" panic. (Spoiler: it didn't go anywhere; the region picker did.)

Keys are flat strings

No real folders. a/b/c.txt is a single key. You can list with a prefix (aws s3 ls s3://b/a/) but there is no mkdir. "Empty folders" are a console fiction - they create zero-byte objects ending in /.

S3 anatomy: account → bucket → object → key AWS account 123456789012 region: us-east-1 (buckets pin here) Bucket gfn-reports-raw (globally unique) 2025/q1/usage.parquet 2025/q1/errors.json 2025/q2/usage.parquet README.md Bucket gfn-reports-logs app/2025-05-22.log Object key: 2025/q1/usage.parquet body: [bytes, up to 5TB] metadata: Content-Type, x-amz-storage-class etag, versionId
One AWS account holds N buckets. Each bucket is region-pinned and globally name-unique. Each object is a (key, body, metadata) triple. The "folders" in 2025/q1/... are just slashes inside one flat key string.

Make a bucket: CLI side-by-side

Azure (storage account + container)
# 1. Create the storage account (parent)
az storage account create \
  --name mystgacct \
  --resource-group rg-data \
  --location eastus \
  --sku Standard_LRS

# 2. Create a blob container inside it
az storage container create \
  --account-name mystgacct \
  --name reports

# 3. Upload
az storage blob upload \
  --account-name mystgacct \
  --container-name reports \
  --name report.pdf \
  --file ./report.pdf
AWS (S3 - no parent account)
# 1. Create the bucket (no parent to create first)
aws s3 mb s3://my-org-reports-2025-05 \
  --region us-east-1

# 2. Upload
aws s3 cp ./report.pdf \
  s3://my-org-reports-2025-05/reports/report.pdf

# 3. List
aws s3 ls s3://my-org-reports-2025-05/reports/

# Notice: no "container" step. The bucket IS the container.
# The 'reports/' prefix is just part of the key string.

Same bucket in Terraform

s3.tf
resource "aws_s3_bucket" "reports" {
  bucket = "my-org-reports-2025-05"

  tags = {
    Project    = "Compass"
    DataClass  = "internal"
    ManagedBy  = "terraform"
  }
}

# Versioning is a SEPARATE resource since the v4 provider split.
# A common bug source - don't forget to attach it.
resource "aws_s3_bucket_versioning" "reports" {
  bucket = aws_s3_bucket.reports.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "reports" {
  bucket                  = aws_s3_bucket.reports.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
The "Block Public Access" reflex. Since 2018 every new bucket has public access blocked by default at the bucket level - but only the account-level "BPA" setting is universally on. Always attach an aws_s3_bucket_public_access_block regardless. Belt and suspenders saves headlines. (See the Capital One horror story below.)
Real-world incident Capital One: 100M records via a misconfigured WAF and over-permissive role (July 2019) $80M fine + brand damage

A former AWS employee discovered that Capital One's web-application firewall (a ModSecurity instance running on EC2) was vulnerable to a server-side request forgery (SSRF) attack. By crafting requests that the WAF would forward to the EC2 instance metadata service, the attacker harvested temporary credentials from the WAF's IAM role.

That IAM role was wildly over-permissive: it had s3:ListBucket and s3:GetObject on basically all of Capital One's S3 buckets. Once the attacker had those credentials, they listed every bucket and downloaded ~100M customer records - SSNs, credit applications, the works. The buckets themselves had bucket policies that "looked private" because Block Public Access was on. But "private to the internet" is not the same as "private to a confused-deputy IAM role".

Capital One paid an $80M OCC fine, settled a class action for $190M, and lost the case in 2022 (Eleventh Circuit). The attacker got 5 years.

Lessons: (1) Bucket-policy-is-private is not enough - an over-broad IAM role inside your account can read everything. (2) Use IMDSv2 (session-token-bound metadata) to prevent SSRF-style theft. (3) Scope IAM roles to specific buckets and prefixes via Resource: arn:aws:s3:::specific-bucket/specific-prefix/*, not "*". (4) Defense in depth: BPA + bucket policy + tight IAM + IMDSv2 + WAF rules + GuardDuty - any single layer can be misconfigured.

Storage classes & the cost ladder

ELI5: storage classes
Each storage class is the same shelf in the same warehouse, but the warehouse moves further from the front door as the class gets colder. Standard is on the counter, IA is in the back room, Glacier is in the basement, Deep Archive is in offsite cold storage. Cheaper to keep there, more expensive to fetch, and the colder ones have minimum stays - you can't pull a box out the day after putting it in.

S3 offers six "live" storage classes plus Reduced Redundancy (legacy, deprecated). Pick the wrong one and your storage bill goes 80% lower or your retrieval bill goes 10x higher. The trick: storage-class is a per-object attribute, not a per-bucket setting, so you can mix.

ClassStorage
$/GB/mo
Retrieval
$/GB
Request
$/1000
Min durationMin object size billedUse case
Standard$0.023$0$0.0004 (GET)noneactual sizeHot data, websites, active workloads
Standard-IA$0.0125$0.01$0.001 (GET)30 days128 KBBackups, monthly reports, infrequent reads
One Zone-IA$0.01$0.01$0.001 (GET)30 days128 KBRecreatable data (one AZ only - 99.5% availability)
Glacier Instant Retrieval$0.004$0.03$0.01 (GET)90 days128 KBQuarterly archives, ms-fast retrieval but pricey GETs
Glacier Flexible Retrieval$0.0036$0.01-0.03$0.05 (GET) + retrieval req $0.1090 days40 KB metadata + 8 KB obj header overheadCompliance archives, minutes-to-hours retrieval
Glacier Deep Archive$0.00099$0.02$0.05 (GET) + retrieval req $0.10180 days40 KB metadata + 8 KB obj header overheadTape-replacement, 7+ year compliance, 12 hr retrieval
Intelligent-Tieringtier-of-the-moment + $0.0025/1000 objs monitoring$0 (in Frequent/Infrequent)variesnone128 KB monitoredUnknown access pattern, set-and-forget

Prices are us-east-1 list, approximate, accurate to within ~5% as of early 2026. Check the official pricing page before basing a decision on these. Pricing in other regions is higher.

Reading the table: the storage column gets cheaper as you go down, and everything else gets more expensive. Glacier Deep Archive at $0.00099/GB/mo sounds free - $1/TB/month - but the per-GB retrieval and request charges make it the wrong choice if you read the data even occasionally. The rule of thumb: if you'll read it more than once or twice a year per object, Glacier classes lose money.

Tier mapping vs Azure

Azure tierMin durationAWS equivalentMin duration
HotnoneS3 Standardnone
Cool30 daysS3 Standard-IA30 days
Cold90 daysS3 Glacier Instant Retrieval90 days
Archive180 daysS3 Glacier Flexible Retrieval90 days
(no exact equivalent)-S3 Glacier Deep Archive180 days
Premium block blobs-no direct equivalent (S3 has no "Premium" class)-

Set a class on upload, or transition later

terminal
# Set storage class at upload time
aws s3 cp big-report.parquet s3://my-bucket/2025/q1/big-report.parquet \
  --storage-class STANDARD_IA

# Or for many objects with a recursive copy
aws s3 cp ./archive s3://my-bucket/archive/ \
  --recursive \
  --storage-class GLACIER

# Change class of an existing object (server-side copy, NOT free):
aws s3 cp s3://my-bucket/old.log s3://my-bucket/old.log \
  --storage-class GLACIER_IR \
  --metadata-directive COPY

# Check current class:
aws s3api head-object \
  --bucket my-bucket \
  --key 2025/q1/big-report.parquet \
  --query StorageClass
Fun fact Intelligent-Tiering was AWS's response to "I don't know my access pattern". It monitors object access and shuffles between Frequent, Infrequent, Archive Instant, Archive, and Deep Archive tiers automatically. The catch: monitoring costs $0.0025 per 1000 objects per month - tiny per object, brutal at scale. For 200 million small objects that's $500/month just to watch them sit there. Below 128KB, monitoring isn't applied and the object stays in Frequent. Worth it if your objects are bigger than ~1MB and access is genuinely unpredictable; otherwise pick a class manually.
Bug hunt: bucket policy that "should" allow reads

A teammate writes a bucket policy to allow a partner role to read objects from gfn-reports-shared. They test it and aws s3 ls works, but aws s3 cp s3://gfn-reports-shared/file.txt . returns AccessDenied. Find the bug.

bucket-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::444444444444:role/PartnerReader" },
    "Action": ["s3:ListBucket", "s3:GetObject"],
    "Resource": "arn:aws:s3:::gfn-reports-shared"
  }]
}
Click to reveal the bug
Missing /* on the Resource for object-level actions.

S3 has two distinct resource ARN shapes: the bucket ARN (arn:aws:s3:::bucket-name) for bucket-level operations like ListBucket, and the object ARN (arn:aws:s3:::bucket-name/*) for object-level operations like GetObject and PutObject.

The policy above lists only the bucket ARN, so ListBucket resolves correctly (hence aws s3 ls works) but GetObject needs an object ARN to match against - and the policy never declares one. The fix is to include both:

Fix: "Resource": ["arn:aws:s3:::gfn-reports-shared", "arn:aws:s3:::gfn-reports-shared/*"]

If you ever see a policy where the bucket commands work but the object commands fail, this is almost always the cause. AWS won't warn you - both ARNs are syntactically valid.

Lifecycle rules: automate the cooling

Manually downgrading storage class on every object would be madness. Lifecycle rules are JSON declarations on the bucket that say "after N days, transition matching objects to class X; after M days, expire them entirely." Same idea as Azure's lifecycle management rules, slightly different schema.

ELI5: lifecycle rules
Imagine a librarian who walks the shelves once a day and moves any book that hasn't been checked out in 30 days to the back room (cheaper to keep, slightly slower to fetch), and after a year throws old magazines in the recycling. You write the librarian's instructions once as JSON; S3 does the moving for you.
Lifecycle: a typical telemetry-bucket policy S3 Standard day 0 to 30 Standard-IA day 30 to 90 Glacier Flexible day 90 to 365 Expire (delete) day 365 Cost per GB-month drops ~6x by day 90, then another ~3x in Glacier. Per-object transition fees apply.
A typical "hot 30 days, warm 60 days, cold 9 months, gone after a year" lifecycle. The shapes flow left to right; the bill follows in reverse.

The JSON, side-by-side with Azure

Azure blob lifecycle (Storage Account)
{
  "rules": [{
    "name": "tier-and-expire",
    "enabled": true,
    "type": "Lifecycle",
    "definition": {
      "filters": {
        "blobTypes": ["blockBlob"],
        "prefixMatch": ["reports/"]
      },
      "actions": {
        "baseBlob": {
          "tierToCool":    { "daysAfterModificationGreaterThan": 30 },
          "tierToArchive": { "daysAfterModificationGreaterThan": 90 },
          "delete":        { "daysAfterModificationGreaterThan": 365 }
        }
      }
    }
  }]
}
AWS S3 lifecycle
{
  "Rules": [{
    "ID": "tier-and-expire",
    "Status": "Enabled",
    "Filter": {
      "Prefix": "reports/"
    },
    "Transitions": [
      {
        "Days": 30,
        "StorageClass": "STANDARD_IA"
      },
      {
        "Days": 90,
        "StorageClass": "GLACIER"
      }
    ],
    "Expiration": {
      "Days": 365
    }
  }]
}

Apply the lifecycle policy

terminal
# Save the rule to a file (see JSON above), then:
aws s3api put-bucket-lifecycle-configuration \
  --bucket gfn-reports-raw \
  --lifecycle-configuration file://lifecycle.json

# Verify
aws s3api get-bucket-lifecycle-configuration --bucket gfn-reports-raw

# Remove all rules (single call removes the whole config):
aws s3api delete-bucket-lifecycle --bucket gfn-reports-raw
Cost trap: the Glacier transition for billions of tiny files ~$10,000 one-time + ongoing

A team has 200 million CloudFront access-log entries averaging 10 KB each in S3 Standard. To save money they write a lifecycle rule: transition everything older than 30 days to Glacier Flexible Retrieval. The terraform plan looks tidy. The next month's bill makes them re-read the rule. What happened?

Click to reveal the trap
Three multiplicative costs hit at once.

1. Per-object transition fee. Lifecycle transitions to Glacier cost $0.05 per 1,000 objects. 200M objects × $0.05/1000 = $10,000 one-time, payable the month the transition kicks in.

2. Minimum object size billing. Glacier Flexible bills 40 KB of metadata + 8 KB of header per object. For 10 KB source files, you pay for 48 KB instead - 4.8x your actual data. Storage you "saved" by going cold is now ~4x larger than you thought.

3. Restore fees. If you ever need to read these logs again (audit, debugging), you pay $0.10 per 1,000 restore requests + ~$0.01-$0.03 per GB retrieved. Restoring all 200M objects is another $20,000 in request fees alone.

Math: 200M * 10KB = 2 TB at $0.023/GB Standard = $46/mo. After transition: 200M * 48KB = 9.6 TB at $0.0036/GB Glacier = $34/mo. You "saved" $12/month. The transition fee alone takes 800 months to pay back.

Fix: Aggregate small files into larger archives before transitioning. A nightly Lambda or S3 Batch Operations job that packs the day's logs into one ~1 GB tar.gz makes Glacier wildly cheaper. Or use Intelligent-Tiering, which handles the small-object problem better and skips per-transition fees. Or just expire them and rely on CloudFront's standard logs in CloudWatch.

Lifecycle metadata: the rule's clock starts on object creation by default, not last access. There is no last accessed tier predicate the way Azure has - only the access-driven sorting you get inside Intelligent-Tiering. If you need real "haven't been read in 6 months" semantics, you either run Intelligent-Tiering or you instrument your reads and emit a tag-based lifecycle rule yourself.

Versioning + MFA delete: in-place edits are a lie

ELI5: S3 versioning
Without versioning, "save" overwrites the old object - the previous bytes are gone forever. With versioning on, every PUT creates a new version of the key; the old version stays in the bucket with a unique version ID. "Delete" doesn't actually delete - it adds a special "delete marker" version that hides the object from default reads. You can list all versions and roll back. The catch: every version costs storage, so a chatty app + versioning + no lifecycle = surprise bill.

Versioning is opt-in (per bucket), and once turned on it's irreversible: you can only suspend it, not "go back to never having had it". The two states most teams ship with:

Versioning stateWhat it doesCost impact
EnabledEvery PUT/DELETE creates a new version. Old versions stay until explicitly removed or lifecycle expires them.Storage scales with churn. High-write workloads can 10x storage in weeks.
SuspendedNew PUTs go in with version null; existing versions remain in place.Stops new accumulation. Old versions still cost money until explicitly cleaned.
never enabledDefault state. PUT overwrites, DELETE removes.Lowest cost, no rollback safety net.

Turn it on, look around, roll back

terminal
# Enable versioning on a bucket
aws s3api put-bucket-versioning \
  --bucket gfn-reports-raw \
  --versioning-configuration Status=Enabled

# Check current status
aws s3api get-bucket-versioning --bucket gfn-reports-raw

# Upload twice to create two versions
echo "v1" | aws s3 cp - s3://gfn-reports-raw/notes.txt
echo "v2" | aws s3 cp - s3://gfn-reports-raw/notes.txt

# List all versions of a key
aws s3api list-object-versions \
  --bucket gfn-reports-raw \
  --prefix notes.txt

# Restore an older version by copying it over the current one
aws s3api copy-object \
  --bucket gfn-reports-raw \
  --copy-source "gfn-reports-raw/notes.txt?versionId=ABC123..." \
  --key notes.txt

# Delete a specific version (the delete-version op, NOT a delete marker)
aws s3api delete-object \
  --bucket gfn-reports-raw \
  --key notes.txt \
  --version-id ABC123...

MFA delete: don't enable unless you mean it

MFA delete is a bucket-level setting requiring an MFA token to either permanently delete a version or to change the versioning state. It can only be enabled by the AWS account's root user via the API (not the console, not IAM users) and the same root user is required to disable it. Most teams don't enable it because it makes Terraform-driven bucket destruction painful.

When MFA delete makes sense: regulatory environments where deleting evidence must require a human-in-the-loop. For most engineering teams, the safer alternative is S3 Object Lock in GOVERNANCE mode plus tight bucket policies - same effect, more flexible.

EBS volume types: block storage for EC2

ELI5: EBS
EBS volumes are virtual hard drives. You attach one (or a few) to an EC2 instance, format it, mount it - same idea as a Managed Disk attached to an Azure VM. Disk sizes go from 1 GB to 64 TB, you can resize while running, and the data survives the VM dying. The flavor (gp3 vs io2 vs st1 vs sc1) decides whether you're optimizing for cheap, fast, throughput, or cold.

EC2 instance storage comes in two flavors: instance store (ephemeral, on the host, free, dies with the instance) and EBS (network-attached, durable, billed per GB-month). 95% of workloads want EBS for anything important.

Type$/GB/monthPerformance ceilingUse caseAzure equivalent
gp3 (recommended baseline)$0.083,000 IOPS + 125 MB/s baseline; up to 16,000 IOPS + 1,000 MB/s by paying for it independentlyGeneral-purpose, root volumes, almost any workloadStandard SSD / Premium SSD v2
gp2 (legacy)$0.103 IOPS per GB (burst to 3,000 for small volumes)Don't pick for new workloads - gp3 is cheaper and fasterStandard SSD (P-series)
io2 / io2 Block Express$0.125 + $0.065/IOPSUp to 256,000 IOPS, sub-ms latency, 4 TB throughput on Block ExpressDatabases, latency-critical apps, multi-attach (HA cluster shared disk)Ultra Disk
st1 (throughput HDD)$0.045500 MB/s max throughput, big sequential readsLog streams, data lakes, large sequential workloadsStandard HDD
sc1 (cold HDD)$0.015250 MB/s max throughput, cheapInfrequently accessed cold-data volumes (rare - usually S3 is cheaper)Standard HDD
gp3 vs gp2: gp3 is the modern default. It costs 20% less per GB and gives you 3,000 IOPS even at 1 GB (gp2 required a 1 TB volume to hit 3,000 IOPS baseline). If you're still running gp2 on anything created before 2020, migration is essentially free win.

Creating a volume side-by-side

Azure Managed Disk
az disk create \
  --resource-group rg-app \
  --name data-disk \
  --size-gb 100 \
  --sku Premium_LRS

az vm disk attach \
  --resource-group rg-app \
  --vm-name myvm \
  --name data-disk
AWS EBS
aws ec2 create-volume \
  --availability-zone us-east-1a \
  --size 100 \
  --volume-type gp3 \
  --iops 3000 \
  --throughput 125 \
  --tag-specifications "ResourceType=volume,Tags=[{Key=Name,Value=data-disk}]"

aws ec2 attach-volume \
  --volume-id vol-abc123 \
  --instance-id i-xyz789 \
  --device /dev/sdf
AZ-pinning: EBS volumes live in a single Availability Zone and can only attach to EC2 instances in the same AZ. There's no equivalent to "move this volume to another AZ" without snapshot + restore. Plan capacity (and stateful workload placement) accordingly. Compare: Azure Managed Disks are zonal too, but Azure's regional storage redundancy (ZRS) muddles the picture for blob.
Fun fact EBS volumes have lifetimes independent of EC2 instances. If you stop an instance, the volume stays. If you terminate an instance, the volume usually stays too (unless DeleteOnTermination=true is set on the volume attachment - default is true for the root volume and false for additional volumes). This is the #1 source of "orphan" EBS bills - volumes that have outlived their instances for months and are billed quietly. Run aws ec2 describe-volumes --filters Name=status,Values=available to find them.

EFS & the FSx family: shared filesystems

ELI5: EFS
EBS is a hard drive bolted to one VM. EFS is a network-mounted filesystem that many VMs (or containers) can mount at once and see the same files. NFS protocol, Linux-friendly. Same idea as Azure Files NFS shares, scales without you provisioning capacity, billed for actual data stored.

When you need POSIX semantics and multiple compute nodes hitting the same files, you reach for EFS (Linux/NFS) or one of the FSx variants. Each has a niche:

ServiceProtocolBest forApprox $/GB/moAzure equivalent
EFSNFSv4Multi-AZ shared filesystem for Linux workloads, EKS persistent volumes, web farms$0.30 (Standard), $0.043 (IA)Azure Files (NFS)
FSx for Windows File ServerSMBDomain-joined Windows workloads, file shares for Active Directory environments$0.13-0.23Azure Files (SMB)
FSx for LustrePOSIX (Lustre client)HPC, ML training, GB/s+ throughput per TB capacity$0.14-0.60no first-party
FSx for NetApp ONTAPNFS + SMB + iSCSINetApp-aware workloads, dedup/compression, snapshots-as-product$0.16+Azure NetApp Files
FSx for OpenZFSNFSZFS-native features (snapshots, clones, compression), Linux/Mac shops$0.084+none direct

Mount EFS from EC2 or EKS

terminal (on an EC2 instance)
# Install the helper (Amazon Linux: amazon-efs-utils)
sudo dnf install -y amazon-efs-utils

# Mount using the EFS mount helper - handles TLS in transit
sudo mkdir /mnt/shared
sudo mount -t efs -o tls fs-0abc123:/ /mnt/shared

# Test - any other instance mounting the same fs-id sees these files
echo "hello from $(hostname)" | sudo tee /mnt/shared/who.txt
cat /mnt/shared/who.txt
efs.tf
resource "aws_efs_file_system" "shared" {
  creation_token   = "gfn-shared-fs"
  performance_mode = "generalPurpose"
  throughput_mode  = "elastic"

  lifecycle_policy {
    transition_to_ia = "AFTER_30_DAYS"
  }

  tags = { Project = "Compass" }
}

# One mount target per AZ you want to access from
resource "aws_efs_mount_target" "a" {
  file_system_id  = aws_efs_file_system.shared.id
  subnet_id       = aws_subnet.private_a.id
  security_groups = [aws_security_group.efs.id]
}
EFS vs S3 - the decision rule: EFS is for code that wants open(), read(), fcntl(F_SETLK). S3 is for code that wants HTTP and treats files as opaque blobs. Trying to make S3 act like a filesystem (via s3fs, mountpoint-s3, etc.) works for sequential reads but falls apart on random writes and locking. If your workload assumes filesystem semantics, use EFS; if your workload is "fetch this object, process it, write back a new one", use S3.

Try it: make a bucket, version it, kill it

Lab: end-to-end bucket lifecycle in 5 commands $0 if you delete the bucket right away

Goal: create a bucket, enable versioning, copy a small file in, inspect, delete the bucket. Total cost: a fraction of a cent if you do it quickly. S3 free tier covers 5 GB and 20k GET / 2k PUT requests/month for the first year anyway.

Step 1. Pick a globally unique name. Add a timestamp or your initials so it doesn't collide.

terminal
export BUCKET="learn-aws-${USER}-$(date +%Y%m%d)"
echo "$BUCKET"

Step 2. Make the bucket.

terminal
aws s3 mb "s3://$BUCKET" --region us-east-1

Step 3. Enable versioning and verify.

terminal
aws s3api put-bucket-versioning \
  --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled

aws s3api get-bucket-versioning --bucket "$BUCKET"
# Output: { "Status": "Enabled" }

Step 4. Upload a file, upload it again, list all versions.

terminal
echo "version one" > /tmp/lab.txt
aws s3 cp /tmp/lab.txt "s3://$BUCKET/lab.txt"

echo "version two - overwriting" > /tmp/lab.txt
aws s3 cp /tmp/lab.txt "s3://$BUCKET/lab.txt"

aws s3api list-object-versions --bucket "$BUCKET"
# Two distinct versions, each with their own VersionId. The "IsLatest: true" one is the second.

Step 5. Tear it all down. rb --force deletes the bucket plus every object and version inside.

terminal
aws s3 rb "s3://$BUCKET" --force

# Verify it's gone (should 404):
aws s3api head-bucket --bucket "$BUCKET"

What you learned: bucket creation, versioning lifecycle, listing object versions, and the difference between rb (remove bucket - fails if not empty) and rb --force (nuke it all). The --force flag silently iterates over every version, so for a 10M-object bucket this is hours, not seconds - use S3 Batch Operations or lifecycle expirations for big cleanups.

Quick check

Test yourself - 5 questions

Storage questions hide cost surprises. Sit with each before revealing.

1. What is the minimum storage duration for S3 Glacier Deep Archive (after which an object can be deleted without a prorated charge)?

  • 30 days
  • 90 days
  • 180 days
  • 365 days
Show answer
Answer: c. 180 days. Delete a Deep Archive object before 180 days and you pay storage charges as if it had stayed the full duration. The cold classes all have minimums - Standard-IA is 30 days, Glacier Instant Retrieval is 90 days, Glacier Flexible is 90 days, Deep Archive is 180 days. Always check the minimum against your real access pattern before transitioning.

2. S3 bucket names must be unique within which scope?

  • Within a single AWS account
  • Within a single AWS region
  • Globally across all AWS accounts
  • Within an AWS Organization
Show answer
Answer: c. Globally across all AWS accounts. This is the single most-surprising fact for Azure devs. A bucket named backup was claimed by someone in 2007 and is gone forever. Names like data, logs, and test are also long-since taken. Always prefix with your org name + a uniqueness token (env, date, UUID). AWS recently shipped "directory buckets" with a different naming scheme, but classic S3 bucket names are still global.

3. What's the maximum expiration time for an S3 presigned URL signed with SigV4 using session credentials (e.g., an assumed-role)?

  • 1 hour
  • The lifetime of the underlying session credentials (often 1-12 hours)
  • 7 days
  • 30 days
Show answer
Answer: b. The hard ceiling on SigV4-signed presigned URLs is 7 days, but a URL signed with temporary credentials (assumed-role / SSO session) cannot live longer than those credentials. If your role session expires in 1 hour, the URL stops working in 1 hour even if you set --expires-in 604800. To get a true 7-day URL, sign with a long-lived IAM user's access key (which is itself a security tradeoff) or use bucket policies plus CloudFront signed URLs/cookies.

4. You're choosing between EBS gp2 and gp3 for a 500 GB volume that needs 3,000 IOPS. Which is true?

  • gp2 is cheaper and faster.
  • gp3 is cheaper at every size and lets you scale IOPS independently of capacity.
  • gp2 caps at 16,000 IOPS, gp3 caps at 3,000.
  • They cost the same; pick based on throughput.
Show answer
Answer: b. gp3 is roughly 20% cheaper per GB ($0.08 vs $0.10), gives 3,000 IOPS baseline regardless of volume size, and lets you pay for additional IOPS (up to 16,000) and throughput (up to 1,000 MB/s) independently. gp2 ties IOPS to size (3 IOPS/GB) and is strictly worse for almost any workload. AWS still ships gp2 for backward compatibility - it has no remaining advantages. Migrate.

5. When does S3 Intelligent-Tiering automatically move an object to the Infrequent Access sub-tier?

  • 30 days after upload, regardless of access pattern
  • After 30 consecutive days with no access
  • After 90 consecutive days with no access
  • Never - tier movement is manual in Intelligent-Tiering
Show answer
Answer: b. 30 consecutive days with no access. Intelligent-Tiering watches each object's access pattern. After 30 days of no GET/HEAD it moves the object to Infrequent Access. After 90 days, Archive Instant Access. With opt-in for the archival tiers, after 90 days it can move to Archive Access (similar to Glacier Flexible) and after 180 to Deep Archive Access. Access at any point bumps the object back to Frequent. The cost: $0.0025 per 1,000 objects/month for monitoring - cheap unless you have hundreds of millions of objects.

Gotchas for Azure devs

1. Bucket names are globally unique. Not per-region, not per-account - globally. Pick names like <org>-<env>-<purpose>-<yyyymm> from day one and you'll never collide. Short generic names (backup, data, logs) have been gone for over a decade.
2. The region picker in the console hides your buckets. The S3 console does show buckets from all regions in the main bucket list, but every other view (bucket contents, policies, configuration) silently scopes to the picker. If you swear a bucket exists and the console can't find it, you're probably in the wrong region. The CLI has no such bug - aws s3 ls shows all buckets across regions.
3. Lifecycle transitions cost money per object. Each transition to IA or Glacier classes is billed per object ($0.01 per 1,000 to IA, $0.05 per 1,000 to Glacier). For workloads with billions of small files this can easily reach 4-5 figures - see this chapter's cost trap. Aggregate small files before transitioning, or use Intelligent-Tiering which charges monitoring instead of transition fees.
4. EBS volumes outlive their EC2 instances. Stop an EC2 - the volume stays. Terminate an EC2 - non-root volumes stay by default. Months later, "orphan" volumes are quietly billing $0.08/GB/month and nobody knows why. Run aws ec2 describe-volumes --filters Name=status,Values=available monthly to catch them, or enforce DeleteOnTermination=true in your AMIs/launch templates.
5. S3 default encryption with KMS multiplies your bill. Every read/write of a KMS-encrypted object triggers a Decrypt or GenerateDataKey call at $0.03 per 10K calls. At 10M S3 ops/month per service, that's $30/service - manageable. Across 50 services with per-service CMKs, that's $1,500+/month. Enable S3 Bucket Keys (which caches the data key for a day) to cut calls by ~99%, or stick with SSE-S3 unless you need the audit-and-rotation benefits of CMKs. See chapter 2's KMS cost-trap for the full math.
6. Object-level vs bucket-level Resource ARNs in policies. arn:aws:s3:::my-bucket matches bucket-level actions (ListBucket); arn:aws:s3:::my-bucket/* matches object-level actions (GetObject, PutObject). Listing only one will silently fail half your S3 calls. Always include both in any S3 policy unless you're explicitly scoping to one or the other.

Project Compass: telemetry bucket

The Compass service needs a place to dump raw GeForce NOW session telemetry before the Lambda we deployed in chapter 4 processes it. In this slice we create that bucket, turn on versioning (so a misbehaving producer can't silently corrupt history), set a lifecycle policy that keeps recent data hot and old data cheap, and finally extend gfn-reports-role with the bucket-specific S3 permissions it needs.

Project Compass · Step 5 of 12 Create gfn-reports-raw + lifecycle

Why now: chapter 4's Lambda needs somewhere to drop incoming telemetry. We add the bucket, the lifecycle policy that controls cost over the data's lifetime, and the IAM permissions to let the role read and write to it.

Step 5a · create the bucket
terminal
# Bucket name must be globally unique - add account ID for safety.
ACCT=$(aws sts get-caller-identity --query Account --output text --profile compass)
BUCKET="gfn-reports-raw-${ACCT}"

aws s3 mb "s3://${BUCKET}" \
  --region us-east-1 \
  --profile compass

# Block all public access (defense-in-depth)
aws s3api put-public-access-block \
  --bucket "${BUCKET}" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true \
  --profile compass

# Enable versioning
aws s3api put-bucket-versioning \
  --bucket "${BUCKET}" \
  --versioning-configuration Status=Enabled \
  --profile compass

# Tag it so we can find / clean up later
aws s3api put-bucket-tagging \
  --bucket "${BUCKET}" \
  --tagging 'TagSet=[{Key=Project,Value=Compass},{Key=ManagedBy,Value=learn-aws}]' \
  --profile compass
Step 5b · apply the lifecycle policy
/tmp/compass-lifecycle.json
{
  "Rules": [{
    "ID": "telemetry-tiering",
    "Status": "Enabled",
    "Filter": { "Prefix": "" },
    "Transitions": [
      { "Days": 30,  "StorageClass": "STANDARD_IA" },
      { "Days": 90,  "StorageClass": "GLACIER" }
    ],
    "Expiration": { "Days": 365 },
    "NoncurrentVersionExpiration": { "NoncurrentDays": 30 }
  }]
}
terminal
aws s3api put-bucket-lifecycle-configuration \
  --bucket "${BUCKET}" \
  --lifecycle-configuration file:///tmp/compass-lifecycle.json \
  --profile compass

# Verify
aws s3api get-bucket-lifecycle-configuration \
  --bucket "${BUCKET}" \
  --profile compass

The rule transitions every object to IA after 30 days, to Glacier after 90 days, expires the current version after 365 days, and cleans up old versions 30 days after they stop being current. The NoncurrentVersionExpiration is critical with versioning on - without it, old versions pile up forever.

Step 5c · extend gfn-reports-role
/tmp/compass-s3-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BucketLevel",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::gfn-reports-raw-*"
    },
    {
      "Sid": "ObjectLevel",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:GetObjectVersion"
      ],
      "Resource": "arn:aws:s3:::gfn-reports-raw-*/*"
    }
  ]
}
terminal
aws iam put-role-policy \
  --role-name gfn-reports-role \
  --policy-name compass-s3-access \
  --policy-document file:///tmp/compass-s3-policy.json \
  --profile compass

# Verify
aws iam list-role-policies --role-name gfn-reports-role --profile compass
aws iam get-role-policy \
  --role-name gfn-reports-role \
  --policy-name compass-s3-access \
  --profile compass

Notice the wildcard in gfn-reports-raw-* - it matches our account-suffixed bucket without us having to hardcode the account ID into the policy. The role can now list the bucket and read/write objects, but only within that name prefix. It cannot touch any other bucket.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Keep the bucket. Chapter 6's DynamoDB work and chapter 7's SQS-driven ingestion both assume gfn-reports-raw-* exists. If you want to start fresh later, aws s3 rb --force will tear it down - but lifecycle-managed versions can make that slow at scale. Chapter 12 will rebuild everything declaratively in Terraform.

Recap & next

What stuck?

The mental model in one sentence

Azure has Storage Account as the parent, four faces inside. AWS has independent storage services - S3, EBS, EFS, FSx - each with its own ARN namespace, IAM, and pricing. Once you internalize that, the rest is mapping Hot/Cool/Archive to Standard/IA/Glacier and watching the per-object transition fee in lifecycle rules.

Common pitfalls so far

TrapFix
"My bucket name was rejected"Add an org prefix + account ID/UUID suffix. Generic names are gone.
"Bucket disappears from the console"Check the region picker. The CLI aws s3 ls shows everything.
"My role can list but not read the bucket"Add both arn:aws:s3:::bucket and arn:aws:s3:::bucket/* to Resource.
"Glacier transition for billions of small files exploded the bill"Aggregate small files into archives before transitioning, or use Intelligent-Tiering.
"Old EBS volume still billing"Set DeleteOnTermination=true by default; sweep for available volumes monthly.
"KMS bill spiked after enabling default encryption"Turn on S3 Bucket Keys, or switch to SSE-S3 unless you need CMK-specific features.
NEXT CHAPTER
6. Databases
RDS vs Azure SQL/PG Flex, Aurora's I/O-billing quirk, and the DynamoDB partition-key surprise that bites every Cosmos DB veteran.