Our S3 bill tripled in a month with no growth in stored data. The storage line was flat. The cost was in requests and a misconfigured lifecycle rule quietly shredding money.
Finance forwarded me the AWS bill with one word: "explain." Our S3 spend had gone from about $1,400 to $4,100 in a single month. My first instinct was that we'd stored a lot more data, so I checked the storage metric. Flat. We were storing the same 60TB we had the month before. The extra $2,700 wasn't storage at all. It was everything around storage, the part of the S3 pricing model nobody reads until it bites.
S3 charges you on at least four axes: how much you store, which class it's in, how many requests you make, and how much data leaves. People optimize the first and get blindsided by the other three.
Cost Explorer, grouped by usage type, told the story in one chart. The Requests-Tier1 line (PUT, COPY, POST, LIST) had exploded. A new data pipeline was writing millions of tiny objects, a few KB each, one PUT per object.
The math is brutal for small objects. PUT requests on S3 Standard run about $0.005 per 1,000. That sounds like nothing until you're doing 400 million PUTs a month:
400,000,000 PUTs / 1,000 * $0.005 = $2,000
Two grand, just to write files, before storing a single byte of them long-term. The fix wasn't S3 config, it was the pipeline: batch the tiny records into larger objects. We aggregated into ~200MB Parquet files written every few minutes instead of one object per record. Request count dropped by four orders of magnitude and that line item nearly vanished.
The same trap hits reads. A LIST-heavy job or an application doing HEAD on every object before reading it racks up Tier2 request charges. We found a service calling head_object before every get_object as a defensive existence check, doubling the request count for no real benefit.
Someone had set up a lifecycle rule to move objects to S3 Standard-IA (Infrequent Access) after 30 days, reasonable on paper. The problem was the objects. IA has two costs that Standard doesn't: a per-object transition fee, and a 128KB minimum billable size. Our data was millions of 4KB objects.
Two things went wrong at once. First, each transition to IA costs about $0.01 per 1,000 objects, so moving millions of them cost real money. Second, and worse, a 4KB object in IA is billed as if it were 128KB. We were paying for 32x the storage we used on every small object, plus IA has a 30-day minimum storage charge, so anything deleted early still billed for the full month.
The rule meant to save money was losing it on both counts. Storage-class transitions are only worth it for large, genuinely cold objects. For small ones, the minimums and transition fees swamp any savings:
{
"Rules": [{
"ID": "archive-large-cold-logs",
"Filter": { "And": {
"Prefix": "logs/archive/",
"ObjectSizeGreaterThan": 131072
}},
"Transitions": [{
"Days": 90,
"StorageClass": "GLACIER_IR"
}]
}]
}
The ObjectSizeGreaterThan: 131072 filter is the fix: only transition objects above 128KB, so you never pay the small-object minimum penalty. We also pushed the transition to 90 days and went to Glacier Instant Retrieval for data that's truly cold but occasionally needed.
For access patterns you can't predict, S3 Intelligent-Tiering moves objects between tiers automatically based on actual access, and critically it has no retrieval fees and no minimum object size penalty on the frequent/infrequent tiers. It charges a small monitoring fee per object (about $0.0025 per 1,000 objects monitored), which is why you don't want it for billions of tiny objects, but for a bucket of medium-to-large files with unknown access it removes the guesswork. We moved our user-upload bucket to it and stopped trying to hand-tune lifecycle rules for data whose access pattern we didn't actually understand.
Two things we should have had from the start. S3 Storage Lens shows request patterns, object size distribution, and cost per bucket, and its object-size histogram would have flagged the millions-of-tiny-objects problem months earlier. And Cost Explorer grouped by usage type is the single query that separates storage cost from request cost from transfer cost:
aws ce get-cost-and-usage \
--time-period Start=2026-06-01,End=2026-07-01 \
--granularity MONTHLY \
--metrics UnblendedCost \
--group-by Type=DIMENSION,Key=USAGE_TYPE
That grouping is what showed us the cost was Requests-Tier1, not storage, in about thirty seconds. Without it we'd have spent a day guessing.
When an S3 bill jumps, do not assume it's storage. Open Cost Explorer grouped by usage type first, because the surprise is almost always requests or a lifecycle rule, not bytes stored. Fix request cost at the source by batching small objects rather than reaching for a fancy storage class. Only transition to IA or Glacier when objects are large and genuinely cold, and always filter transitions on ObjectSizeGreaterThan so you dodge the 128KB minimum. For unpredictable access, let Intelligent-Tiering do the work instead of hand-tuning rules that, as we learned the expensive way, can cost more than they save.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Static service tokens leaked into logs and never rotated. SPIFFE identities plus SPIRE-issued SVIDs gave us short-lived certs and killed the shared-secret sprawl.
We moved 40 services off the nginx Ingress controller onto Gateway API without a single dropped connection. Here's the routing overlap trick that made it boring.
Explore more articles in this category
We ran secrets three different ways across AWS, GCP, and Vault. External Secrets Operator gave us one Kubernetes-native workflow. Here's the setup and the gotchas.
Moving our fleet from x86 to Graviton promised 20% savings. We got 31%, but only after fixing native dependencies, a broken base image, and one nasty perf regression.
A p99 that jumped to 3.4 seconds during traffic ramps turned out to be cold starts. Here's how we measured them properly and cut the tail, with real init timings.
Evergreen posts worth revisiting.