Cloudflare R2: S3 Minus the Egress Fees — What Changes in Your Code
R2 is genuinely useful and the migration from S3 is mostly painless — but there are a few rough edges that will bite you if you're not paying attention.
The egress fees on S3 are a quiet tax that most developers don't notice until a client asks why their AWS bill jumped. I moved a media-heavy e-commerce project off S3 and onto Cloudflare R2 last year and the storage line item dropped substantially — but the thing I actually want to talk about is what changes in your code, because the answer is "less than you think, but more than the docs imply."
The Problem R2 Actually Solves
AWS charges you to put data in, and then charges you again — aggressively — to take it out. Egress fees run $0.09/GB in most US regions. For a client serving product images or PDF reports to end users, that adds up fast. S3's free tier covers 100GB of egress per month, which sounds generous until you're running a warehouse management system that's pulling invoices and label images constantly.
R2 charges zero for egress. Zero. You pay for storage ($0.015/GB/month, slightly cheaper than S3 Standard) and for Class A operations (writes, $4.50 per million) and Class B operations (reads, $0.36 per million). For read-heavy workloads with large objects, this pencils out quickly.
The S3-compatible API is the whole pitch. You don't rewrite your application logic — you swap credentials and an endpoint. Mostly.
Setting Up R2 in Laravel
Laravel's Flysystem integration via the league/flysystem-aws-s3-v3 package works with R2 because R2 speaks S3. You don't need a Cloudflare-specific SDK. Here's the config/filesystems.php disk configuration I use:
'r2' => [
'driver' => 's3',
'key' => env('CLOUDFLARE_R2_ACCESS_KEY_ID'),
'secret' => env('CLOUDFLARE_R2_SECRET_ACCESS_KEY'),
'region' => 'auto',
'bucket' => env('CLOUDFLARE_R2_BUCKET'),
'url' => env('CLOUDFLARE_R2_PUBLIC_URL'),
'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'),
'use_path_style_endpoint' => true,
],
The endpoint looks like https://<account_id>.r2.cloudflarestorage.com. You get that from the R2 dashboard. The region field is literally the string 'auto' — Cloudflare ignores it but the AWS SDK requires something there, and 'auto' is what their docs say to use.
In .env:
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_key
CLOUDFLARE_R2_BUCKET=my-bucket
CLOUDFLARE_R2_ENDPOINT=https://abc123def456.r2.cloudflarestorage.com
CLOUDFLARE_R2_PUBLIC_URL=https://pub-abc123.r2.dev
Then usage is exactly what you'd expect:
// Store a file
Storage::disk('r2')->put('invoices/2024-001.pdf', $pdfContents);
// Generate a temporary URL
$url = Storage::disk('r2')->temporaryUrl(
'invoices/2024-001.pdf',
now()->addMinutes(30)
);
// Check existence
if (Storage::disk('r2')->exists('invoices/2024-001.pdf')) {
// ...
}
If you're migrating an existing app, you change 'disk' => 's3' to 'disk' => 'r2' in one place (or swap the default disk in the filesystems config) and most things just work.
The Gotchas That Will Bite You
Temporary URLs require a custom domain or the r2.dev subdomain, and the r2.dev domain is rate-limited.
This one caught me. The r2.dev public URL is for development and low-traffic use. Cloudflare is explicit that it's not for production. If you want signed temporary URLs on a custom domain, you need to set up a Cloudflare Worker or point a custom domain at the bucket. For production, I put a custom domain on the bucket through the R2 dashboard and set CLOUDFLARE_R2_PUBLIC_URL to that domain. The Laravel temporaryUrl() method then generates presigned URLs against it correctly.
The AWS SDK's path-style endpoint flag matters.
You need 'use_path_style_endpoint' => true in the disk config. Without it, the SDK tries to hit my-bucket.abc123.r2.cloudflarestorage.com via virtual-hosted-style routing, and R2 doesn't support that the same way S3 does. This is the single most common reason people get NoSuchBucket errors on a bucket they know exists.
Object lifecycle rules are limited.
S3 has very granular lifecycle policies — expire objects after N days, transition to Glacier, abort multipart uploads, etc. R2 has TTL-based expiration, but as of the time I'm writing this, you can't do storage class transitions (there is only one storage class) and the lifecycle UI in the dashboard is more limited than what you get via the S3 API on AWS. If your workflow depends on moving objects to cold storage automatically, R2 isn't there yet.
CORS configuration lives in the dashboard, not the SDK.
On S3 you can manage CORS rules programmatically via the PutBucketCors API. On R2, CORS is configured in the Cloudflare dashboard per-bucket. The SDK call will return an error. This matters if you're using direct browser uploads (presigned POST) — you need to remember to configure CORS separately, outside your deployment pipeline. I've been burned by this when spinning up a new environment and forgetting to click through the dashboard.
Multipart upload behavior is mostly compatible, but test it.
For large files I use multipart uploads, and R2 handles them correctly through the SDK. But the minimum part size rules and maximum part counts differ slightly from S3 in edge cases. If you're streaming large video files or bulk data exports, test your actual upload sizes against R2 specifically. I haven't hit a hard failure here, but I've seen behavior differences at the boundaries.
No S3 Event Notifications (yet).
S3 can fire off SNS/SQS/Lambda triggers when objects are created or deleted. R2 doesn't have a native equivalent via the S3 API. They have R2 Event Notifications in beta through Cloudflare's own queue system, but if you're relying on s3:ObjectCreated:* events wired to SQS in an existing architecture, that part of your stack doesn't port over cleanly. You'd need to restructure around Cloudflare Queues or Pub/Sub, or just poll.
A Working Presigned Upload Flow
Here's a pattern I use for direct browser-to-R2 uploads with a presigned URL — the client never routes file data through my Laravel app:
use Illuminate\Support\Facades\Storage;
use Aws\S3\S3Client;
public function getUploadUrl(Request $request): JsonResponse
{
$key = 'uploads/' . Str::uuid() . '/' . $request->input('filename');
/** @var S3Client $client */
$client = Storage::disk('r2')->getClient();
$cmd = $client->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.r2.bucket'),
'Key' => $key,
'ContentType' => $request->input('content_type', 'application/octet-stream'),
]);
$presignedRequest = $client->createPresignedRequest($cmd, '+15 minutes');
return response()->json([
'upload_url' => (string) $presignedRequest->getUri(),
'key' => $key,
]);
}
The client does a PUT directly to upload_url, then posts key back to my API to confirm and record the upload. This works identically to S3. Just make sure your CORS config in the R2 dashboard allows PUT from your frontend origin, or you'll spend an hour confused about why it works in Postman but not the browser.
When I'd Reach for R2
R2 is the right call for me when:
- The bucket is serving files directly to end users at volume (images, PDFs, exports) and egress costs are real
- I'm already on Cloudflare for DNS and CDN, so the ecosystem integration is clean
- The project doesn't need S3 event notifications or complex lifecycle transitions
- I want simplicity — one less AWS service to IAM-policy my way through
I'd stick with S3 when:
- The architecture is deeply AWS-native (Lambda triggers, Athena queries on S3 data, Macie for data classification, etc.)
- I need intelligent tiering or Glacier transitions
- The client already has an AWS organization with consolidated billing and the egress is modest enough that switching costs don't justify savings
- I need fine-grained bucket-level ACLs and policies beyond what R2's permissions model offers today
For greenfield projects with no existing AWS investment, R2 plus Cloudflare CDN is genuinely attractive. For clients already deep in AWS, the migration math usually doesn't work unless storage is a meaningful cost center.
Closing Thought
R2 delivers on its core promise: the S3 API, without the egress bill. The rough edges are real but narrow — CORS in the dashboard, no event notifications, r2.dev isn't production-ready — and they're all workable if you know about them going in. I'm using it on production systems now and I don't miss S3 for those projects.
Need help shipping something like this? Get in touch.