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.
| Concept | Azure | AWS |
|---|---|---|
| Object storage | Blob Storage (containers + blobs) | S3 (buckets + objects) |
| Block storage for VMs | Managed Disks (Premium SSD, Standard SSD, HDD) | EBS volumes (gp3, io2, st1, sc1) |
| Shared filesystem (NFS/SMB) | Azure Files | EFS (NFS) / FSx for Windows (SMB) |
| Cold/archive tier | Blob Archive tier | S3 Glacier Flexible / Glacier Deep Archive |
| Enterprise filesystem | Azure NetApp Files | FSx for NetApp ONTAP |
| HPC/parallel filesystem | no first-party equivalent (BeeGFS via marketplace) | FSx for Lustre |
| Time-limited shareable URL | SAS token / SAS URL | Presigned URL |
| Storage account | Storage Account (parent for Blob/File/Queue/Table) | none - each service is independent, no parent container |
| Hot tier (frequent access) | Hot | S3 Standard |
| Cool tier (infrequent) | Cool (30-day min) | S3 Standard-IA (30-day min) |
| Cold tier | Cold (90-day min) | S3 Glacier Instant Retrieval (90-day min) |
| Archive tier | Archive (180-day min, hours to rehydrate) | S3 Glacier Flexible (90d) / Deep Archive (180d) |
| Lifecycle policies | Storage lifecycle management rules | S3 Lifecycle configuration (per-bucket JSON/XML) |
| Versioning | Blob versioning (opt-in) | S3 versioning (opt-in, irreversible once on) |
| Object lock / immutability | Immutable blob policies | S3 Object Lock (governance / compliance modes) |
| Default encryption | SSE with platform key (always on) | SSE-S3 (free) / SSE-KMS (per-call charges) |
| Cross-region replication | GRS / RA-GRS storage | S3 CRR (Cross-Region Replication) - explicit rule |
| Static website | Static website on Storage Account | S3 static website hosting + CloudFront |
s3:// URLs predate https:// S3 endpoints./ to render a tree view. The original launch had no per-object ACLs (only bucket-level) and no concept of folders at all.
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:
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.
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.)
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 /.
2025/q1/... are just slashes inside one flat key string.# 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
# 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.
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
}
aws_s3_bucket_public_access_block regardless. Belt and suspenders saves headlines. (See the Capital One horror story below.)
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.
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.
| Class | Storage $/GB/mo | Retrieval $/GB | Request $/1000 | Min duration | Min object size billed | Use case |
|---|---|---|---|---|---|---|
| Standard | $0.023 | $0 | $0.0004 (GET) | none | actual size | Hot data, websites, active workloads |
| Standard-IA | $0.0125 | $0.01 | $0.001 (GET) | 30 days | 128 KB | Backups, monthly reports, infrequent reads |
| One Zone-IA | $0.01 | $0.01 | $0.001 (GET) | 30 days | 128 KB | Recreatable data (one AZ only - 99.5% availability) |
| Glacier Instant Retrieval | $0.004 | $0.03 | $0.01 (GET) | 90 days | 128 KB | Quarterly archives, ms-fast retrieval but pricey GETs |
| Glacier Flexible Retrieval | $0.0036 | $0.01-0.03 | $0.05 (GET) + retrieval req $0.10 | 90 days | 40 KB metadata + 8 KB obj header overhead | Compliance archives, minutes-to-hours retrieval |
| Glacier Deep Archive | $0.00099 | $0.02 | $0.05 (GET) + retrieval req $0.10 | 180 days | 40 KB metadata + 8 KB obj header overhead | Tape-replacement, 7+ year compliance, 12 hr retrieval |
| Intelligent-Tiering | tier-of-the-moment + $0.0025/1000 objs monitoring | $0 (in Frequent/Infrequent) | varies | none | 128 KB monitored | Unknown 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.
| Azure tier | Min duration | AWS equivalent | Min duration |
|---|---|---|---|
| Hot | none | S3 Standard | none |
| Cool | 30 days | S3 Standard-IA | 30 days |
| Cold | 90 days | S3 Glacier Instant Retrieval | 90 days |
| Archive | 180 days | S3 Glacier Flexible Retrieval | 90 days |
| (no exact equivalent) | - | S3 Glacier Deep Archive | 180 days |
| Premium block blobs | - | no direct equivalent (S3 has no "Premium" class) | - |
# 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
$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.
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.
{
"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"
}]
}
/* 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.
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.
{
"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 }
}
}
}
}]
}
{
"Rules": [{
"ID": "tier-and-expire",
"Status": "Enabled",
"Filter": {
"Prefix": "reports/"
},
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 90,
"StorageClass": "GLACIER"
}
],
"Expiration": {
"Days": 365
}
}]
}
# 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
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?
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.
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 state | What it does | Cost impact |
|---|---|---|
Enabled | Every 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. |
Suspended | New PUTs go in with version null; existing versions remain in place. | Stops new accumulation. Old versions still cost money until explicitly cleaned. |
| never enabled | Default state. PUT overwrites, DELETE removes. | Lowest cost, no rollback safety net. |
# 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 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.
GOVERNANCE mode plus tight bucket policies - same effect, more flexible.
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/month | Performance ceiling | Use case | Azure equivalent |
|---|---|---|---|---|
| gp3 (recommended baseline) | $0.08 | 3,000 IOPS + 125 MB/s baseline; up to 16,000 IOPS + 1,000 MB/s by paying for it independently | General-purpose, root volumes, almost any workload | Standard SSD / Premium SSD v2 |
| gp2 (legacy) | $0.10 | 3 IOPS per GB (burst to 3,000 for small volumes) | Don't pick for new workloads - gp3 is cheaper and faster | Standard SSD (P-series) |
| io2 / io2 Block Express | $0.125 + $0.065/IOPS | Up to 256,000 IOPS, sub-ms latency, 4 TB throughput on Block Express | Databases, latency-critical apps, multi-attach (HA cluster shared disk) | Ultra Disk |
| st1 (throughput HDD) | $0.045 | 500 MB/s max throughput, big sequential reads | Log streams, data lakes, large sequential workloads | Standard HDD |
| sc1 (cold HDD) | $0.015 | 250 MB/s max throughput, cheap | Infrequently accessed cold-data volumes (rare - usually S3 is cheaper) | Standard HDD |
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 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
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.
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:
| Service | Protocol | Best for | Approx $/GB/mo | Azure equivalent |
|---|---|---|---|---|
| EFS | NFSv4 | Multi-AZ shared filesystem for Linux workloads, EKS persistent volumes, web farms | $0.30 (Standard), $0.043 (IA) | Azure Files (NFS) |
| FSx for Windows File Server | SMB | Domain-joined Windows workloads, file shares for Active Directory environments | $0.13-0.23 | Azure Files (SMB) |
| FSx for Lustre | POSIX (Lustre client) | HPC, ML training, GB/s+ throughput per TB capacity | $0.14-0.60 | no first-party |
| FSx for NetApp ONTAP | NFS + SMB + iSCSI | NetApp-aware workloads, dedup/compression, snapshots-as-product | $0.16+ | Azure NetApp Files |
| FSx for OpenZFS | NFS | ZFS-native features (snapshots, clones, compression), Linux/Mac shops | $0.084+ | none direct |
# 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
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]
}
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.
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.
export BUCKET="learn-aws-${USER}-$(date +%Y%m%d)"
echo "$BUCKET"
Step 2. Make the bucket.
aws s3 mb "s3://$BUCKET" --region us-east-1
Step 3. Enable versioning and verify.
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.
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.
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.
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)?
2. S3 bucket names must be unique within which scope?
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)?
--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.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?
<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.
aws s3 ls shows all buckets across regions.
aws ec2 describe-volumes --filters Name=status,Values=available monthly to catch them, or enforce DeleteOnTermination=true in your AMIs/launch templates.
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.
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.
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.
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.
# 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
{
"Rules": [{
"ID": "telemetry-tiering",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER" }
],
"Expiration": { "Days": 365 },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 }
}]
}
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.
gfn-reports-role
{
"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-*/*"
}
]
}
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.
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.
(key, body, metadata) triples up to 5 TB.gp3 is the new default; io2 for IOPS-critical; st1/sc1 for sequential/cold workloads.| Trap | Fix |
|---|---|
| "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. |