We moved 40 TB of user media off S3 and cut the bill by 70 percent, mostly by killing egress fees. Here's where R2 won and where we kept S3 anyway.
Our S3 bill for user-uploaded media was $4,100 a month, and only about a quarter of that was storage. The rest was egress: every time a user loaded an image, we paid AWS $0.09 per GB to send it out. We served a lot of images. Moving that 40 TB bucket to Cloudflare R2, which charges nothing for egress, dropped the monthly cost to around $1,200. That's the headline, but the migration had enough sharp edges that it's worth being specific about where R2 actually fits.
S3 storage is $0.023 per GB per month. R2 is $0.015 per GB. That's a modest difference. The real gap is egress: S3 charges $0.09 per GB out to the internet, R2 charges $0. For a bucket that's read far more than it's stored (media, downloads, static assets), egress dwarfs storage, and that's exactly where R2 wins.
For our 40 TB serving roughly 500 TB of downloads a month, S3 egress alone would have been about $45,000 if we hadn't already been fronting it with a CDN. Even with CloudFront in front reducing origin pulls, the origin egress plus request costs were the bulk of the bill. R2 makes that line zero.
R2 implements the S3 API, so most tooling works with an endpoint swap. We migrated with rclone, which handles the copy and verification:
rclone copy s3remote:devopsness-media r2remote:devopsness-media \
--transfers 32 --checkers 64 --fast-list --progress
Config is just two remotes, the R2 one pointed at your account's endpoint:
[r2remote]
type = s3
provider = Cloudflare
access_key_id = <r2-access-key>
secret_access_key = <r2-secret>
endpoint = https://<account-id>.r2.cloudflarestorage.com
Your application SDK code mostly doesn't change either. The AWS SDK works against R2 if you override the endpoint:
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://<account-id>.r2.cloudflarestorage.com",
aws_access_key_id=R2_KEY,
aws_secret_access_key=R2_SECRET,
region_name="auto",
)
That S3 compatibility is R2's best feature. We were serving from R2 in a weekend, not rewriting an object layer.
Three things. First, R2 does not have the full menu of S3 storage classes. There's no Glacier equivalent for deep archival. Our compliance archive, cold data we touch maybe once a year, stayed on S3 Glacier Deep Archive at $0.00099 per GB, which R2 can't beat for pure cold storage. R2 is for hot, frequently-read data. Cold archival is still S3's game.
Second, event notifications and ecosystem depth. S3 has mature event triggers into Lambda, SNS, and a decade of integrations. R2 has event notifications into Cloudflare Queues and Workers, which is fine if you're in that ecosystem, but if your pipeline is built on S3 events into Lambda, that part doesn't port for free. We kept one bucket on S3 specifically because it fed a Lambda thumbnail pipeline we didn't want to rebuild.
Third, consistency and multipart edge cases. R2 is strongly consistent like modern S3, which is good, but we hit a couple of multipart upload quirks with very large files where part-size assumptions differed. Test your large-object path explicitly; don't assume byte-for-byte identical behavior on the edges.
The clean setup is R2 as origin behind Cloudflare's CDN, so reads are served from cache at the edge and cache misses hit R2 at zero egress. You bind the bucket directly to a Worker and skip signed-URL round trips for public assets:
export default {
async fetch(request, env) {
const key = new URL(request.url).pathname.slice(1);
const object = await env.MEDIA.get(key);
if (!object) return new Response("Not found", { status: 404 });
return new Response(object.body, {
headers: { "Cache-Control": "public, max-age=86400" },
});
},
};
Because there's no egress fee, you don't sweat cache hit ratio for cost reasons the way you do with S3 plus CloudFront, where every origin pull costs egress. That changed how we thought about cache tuning: we optimize it for latency now, not to dodge a bill.
If you serve a lot of read-heavy data over the internet (media, downloads, public assets), move it to R2 and pocket the egress savings, which for us was the difference between $4,100 and $1,200 a month. The S3-compatible API makes the migration a weekend, not a quarter. Keep cold archival on S3 Glacier, which R2 can't match on price, and keep any bucket wired into an S3-event pipeline you don't want to rebuild. R2 isn't a total S3 replacement. For the hot, egress-heavy 80 percent of your objects, it's an easy win.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
The Backstage demo always wows leadership. Then six months later the catalog has 400 stale entries and nobody trusts it. Here's what got ours to actually stick.
Reliability arguments used to be shouting matches between SRE and product. An error budget turned them into arithmetic. Here's how we made the number drive the roadmap.
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.