Cloudflare R2: S3-Compatible Storage Without Egress Fees
Cloudflare R2 has been turning heads in the developer community because it promises S3‑compatible object storage without the dreaded egress fees that can balloon your cloud bill. If you’ve ever wrestled with AWS S3’s “pay for data out” model, you’ll appreciate how R2 flips the script by making outbound traffic essentially free. In this article we’ll dive into the core concepts, walk through practical Python integrations, and explore real‑world scenarios where R2 can save you both money and headaches.
Why R2 Is Different from Traditional Object Stores
At a high level, R2 is a globally distributed storage service built on Cloudflare’s edge network. It inherits the low‑latency, high‑availability characteristics of Cloudflare’s CDN, meaning your objects are cached close to end users by default. The kicker? Unlike most object stores, R2 does not charge for data egress to the public internet, which can be a game‑changer for media‑heavy applications, data analytics pipelines, or any service that serves large files to users worldwide.
R2 also implements the Amazon S3 API, so you can reuse existing SDKs, tools, and libraries without a massive rewrite. The compatibility layer is surprisingly complete: bucket operations, multipart uploads, presigned URLs, and even S3 event notifications work as expected. This means you can swap out an S3 bucket for an R2 bucket with a few configuration changes and start reaping the cost benefits immediately.
Key Benefits Summarized
- No egress fees: Outbound data is free, which dramatically reduces costs for bandwidth‑intensive workloads.
- S3‑compatible API: Leverage existing libraries like
boto3,aws-sdk, or the Cloudflare CLI. - Edge caching built‑in: Frequently accessed objects are automatically cached at Cloudflare POPs.
- Fine‑grained IAM: Use Cloudflare Access and R2-specific tokens for secure, scoped access.
Getting Started: Setting Up an R2 Bucket
Before we write any code, you need an R2 bucket and an API token with the appropriate permissions. Head to the Cloudflare dashboard, select “R2” under the “Storage” section, and click “Create Bucket”. Give it a globally unique name (e.g., my‑app‑assets) and choose the account you’d like it associated with.
Next, generate an API token: go to “My Profile → API Tokens → Create Token”. Use the “Custom token” template and grant “Edit” permissions for “R2 Buckets”. Copy the token; you’ll need it for authentication in your scripts.
Configuring the Python Environment
We’ll use the boto3 library because it natively supports the S3 API. Install it via pip, then configure a session that points to Cloudflare’s endpoint.
import boto3
from botocore.client import Config
# Replace with your own values
R2_ACCOUNT_ID = "your-account-id"
R2_ACCESS_KEY = "your-api-token-id"
R2_SECRET_KEY = "your-api-token-secret"
R2_BUCKET = "my-app-assets"
# Cloudflare's S3‑compatible endpoint
R2_ENDPOINT = f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
session = boto3.session.Session(
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
)
s3 = session.client(
service_name="s3",
endpoint_url=R2_ENDPOINT,
config=Config(signature_version="s3v4"),
)
Notice the endpoint_url points to the R2 domain that includes your account ID. This is how the SDK knows to talk to Cloudflare instead of AWS.
Uploading Files – Simple and Multipart
For small files (under 5 MiB), a single put_object call is enough. Larger assets benefit from multipart uploads, which split the file into chunks, upload them in parallel, and then assemble them server‑side. Both patterns are fully supported by R2.
Single‑Part Upload Example
def upload_small_file(local_path, object_key):
with open(local_path, "rb") as f:
s3.put_object(
Bucket=R2_BUCKET,
Key=object_key,
Body=f,
ContentType="application/octet-stream",
)
print(f"✅ Uploaded {object_key} to R2")
# Usage
upload_small_file("logo.png", "images/logo.png")
After the upload, the object is instantly available at a URL constructed as https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com/{bucket}/{key}. You can also generate a signed URL if you want time‑limited access.
Multipart Upload for Large Media
import os
import math
def upload_large_file(local_path, object_key, part_size=8 * 1024 * 1024):
# Initiate multipart upload
response = s3.create_multipart_upload(
Bucket=R2_BUCKET,
Key=object_key,
ContentType="video/mp4",
)
upload_id = response["UploadId"]
parts = []
file_size = os.path.getsize(local_path)
total_parts = math.ceil(file_size / part_size)
try:
with open(local_path, "rb") as f:
for part_number in range(1, total_parts + 1):
data = f.read(part_size)
part_resp = s3.upload_part(
Bucket=R2_BUCKET,
Key=object_key,
PartNumber=part_number,
UploadId=upload_id,
Body=data,
)
parts.append(
{
"ETag": part_resp["ETag"],
"PartNumber": part_number,
}
)
print(f"Uploaded part {part_number}/{total_parts}")
# Complete multipart upload
s3.complete_multipart_upload(
Bucket=R2_BUCKET,
Key=object_key,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
print(f"✅ Finished multipart upload of {object_key}")
except Exception as e:
s3.abort_multipart_upload(
Bucket=R2_BUCKET,
Key=object_key,
UploadId=upload_id,
)
raise RuntimeError("Upload failed, aborted.") from e
# Usage
upload_large_file("big‑movie.mp4", "videos/big‑movie.mp4")
This function automatically splits the file into 8 MiB chunks (adjustable), uploads them in sequence, and assembles them. If anything goes wrong, the upload is aborted to avoid orphaned parts consuming storage.
Pro tip: For truly massive files (hundreds of gigabytes), consider increasing part_size to 100 MiB or more. Larger parts reduce the number of HTTP requests, which can improve throughput on high‑latency connections.
Serving Assets Directly from R2
One of R2’s hidden strengths is its tight integration with Cloudflare Workers. By attaching a Worker to your bucket, you can implement custom authentication, transform images on the fly, or rewrite URLs—all at the edge, without pulling data back to a origin server.
Simple Worker to Add Cache‑Control Headers
Below is a minimal Workers script that serves objects from R2 while ensuring browsers cache them for a day. Deploy it via the Cloudflare dashboard or using wrangler.
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const url = new URL(request.url)
const objectKey = url.pathname.slice(1) // strip leading /
// Bind the R2 bucket (replace with your bucket name)
const bucket = R2_BUCKET // defined in wrangler.toml bindings
try {
const object = await bucket.get(objectKey)
if (!object) return new Response('Not found', { status: 404 })
const headers = new Headers(object.httpMetadata?.headers ?? {})
headers.set('Cache-Control', 'public, max-age=86400')
return new Response(object.body, {
status: 200,
headers,
})
} catch (err) {
return new Response('Error fetching object', { status: 500 })
}
}
When a request hits https://yourdomain.com/images/logo.png, the Worker fetches the object from R2, adds a Cache-Control header, and returns it. Because the response originates from Cloudflare’s edge, the latency is minimal for users worldwide.
Use Case: Dynamic Image Resizing
Imagine an e‑commerce site that stores product photos in R2. Instead of pre‑generating thumbnails for every size, you can let a Worker resize on demand. The Worker checks if a resized version exists; if not, it uses the sharp library (available in Workers) to transform the image, stores the new version back to R2, and serves it—all within a single request.
import { ImageResponse } from '@cloudflare/kv-asset-handler'
async function resizeImage(request) {
const url = new URL(request.url)
const width = parseInt(url.searchParams.get('w')) || 800
const key = url.pathname.slice(1)
// Try to fetch a cached resized version
const cacheKey = `${key}?w=${width}`
const cached = await R2_BUCKET.get(cacheKey)
if (cached) return new Response(cached.body, { headers: cached.httpMetadata?.headers })
// Fetch original
const original = await R2_BUCKET.get(key)
if (!original) return new Response('Not found', { status: 404 })
// Resize using Sharp (Workers supports native modules)
const resized = await ImageResponse.resize(original.body, { width })
// Store resized version for future hits
await R2_BUCKET.put(cacheKey, resized, {
httpMetadata: { contentType: original.httpMetadata?.contentType },
})
return new Response(resized, {
headers: { 'Content-Type': original.httpMetadata?.contentType },
})
}
With this pattern you pay storage for the original image only, and each resized variant is generated lazily, stored once, and then served instantly from the edge.
Pro tip: Enable “R2 Automatic Replication” in the dashboard to ensure your objects are replicated across Cloudflare’s global PoPs. This eliminates the need for a separate CDN layer and guarantees sub‑second latency for most assets.
Integrating R2 with Existing CI/CD Pipelines
Most teams already have pipelines that publish static assets (JS bundles, CSS, PDFs) to S3 during a release. Switching to R2 is straightforward: replace the S3 endpoint and credentials in your deployment scripts. Below is an example using GitHub Actions to push a build directory to R2.
name: Deploy to R2
on:
push:
branches: [main]
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: pip install boto3
- name: Sync build folder
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }}
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
R2_BUCKET: my-app-assets
run: |
python - <<'PY'
import os, boto3
from botocore.client import Config
ACCOUNT = os.getenv('R2_ACCOUNT_ID')
ACCESS = os.getenv('R2_ACCESS_KEY')
SECRET = os.getenv('R2_SECRET_KEY')
BUCKET = os.getenv('R2_BUCKET')
ENDPOINT = f"https://{ACCOUNT}.r2.cloudflarestorage.com"
session = boto3.session.Session(
aws_access_key_id=ACCESS,
aws_secret_access_key=SECRET,
)
s3 = session.client('s3', endpoint_url=ENDPOINT,
config=Config(signature_version='s3v4'))
for root, _, files in os.walk('dist'):
for f in files:
full_path = os.path.join(root, f)
key = os.path.relpath(full_path, 'dist')
s3.upload_file(full_path, BUCKET, key)
print(f'Uploaded {key}')
PY
The workflow reads the R2 credentials from GitHub Secrets, walks the dist folder (your built assets), and uploads each file. Because egress is free, you can even use this step to push large source maps or video previews without worrying about incremental costs.
Cost Comparison: R2 vs. S3 + Cloudflare CDN
To illustrate the savings, let’s run a quick back‑of‑the‑envelope calculation. Assume you serve 5 TB of video per month to a global audience. With AWS S3 + Cloudflare CDN you’d pay:
- S3 storage: roughly $0.023 / GB → $115 / month for 5 TB.
- S3 egress to Cloudflare: $0.09 / GB → $450 / month.
- Cloudflare CDN egress: $0.02 / GB → $100 / month.
Total ≈ $665 per month.
With R2 you pay only for storage (same $115) and a modest $0.015 / GB for “Class A” operations (uploads) which for a typical upload volume adds less than $10. Egress is free, so the monthly bill drops to roughly $125 – an 80% reduction.
When R2 Might Not Be Ideal
- Regulatory constraints: Some regions require data to reside in specific sovereign clouds. R2 is a global service, so you need to verify compliance.
- Complex lifecycle policies: S3 offers a rich set of lifecycle rules (transition to Glacier, expiration, etc.). R2’s policy engine is still maturing.
- Deep integration with AWS services: If you rely heavily on AWS Glue, Athena, or Redshift Spectrum, staying on S3 may simplify pipelines.
In most web‑centric workloads—static site hosting, media streaming, backup of logs—R2 shines because the cost savings outweigh the modest feature gaps.
Advanced Topics: R2 Event Notifications & Serverless Workflows
R2 can emit events to Cloudflare Workers, Queues, or external webhooks whenever an object is created, deleted, or modified. This opens the door to automated pipelines such as image moderation, virus scanning, or transcoding.
Setting Up an R2 → Queue Notification
First, create a Cloudflare Queue (formerly Durable Objects). Then, in the R2 bucket settings, add a notification that points to the queue’s URL. The payload includes the bucket name, object key, and the event type.
import json
import aiohttp
async def process_r2_event(event):
data = json.loads(event.body)
if data["type"] == "object_created":
key = data["detail"]["key"]
# Example: trigger an async transcoding job
await start_transcode(key)
async def start_transcode(key):
async with aiohttp.ClientSession() as session:
await session.post(
"https://api.transcoder.example.com/jobs",
json={"source_bucket": R2_BUCKET, "object_key": key},
)
This snippet would live inside a Worker that is bound to the Queue. Whenever a new video lands in R2, the Worker fires off a background job to a transcoding service, keeping the upload path fast and non‑blocking.
Pro tip: UseContent-Type: application/octet-streamfor generic binary uploads, but always set the correct MIME type for images, videos, or PDFs. Cloudflare’s edge cache respects theContent-Typeheader when serving cached responses.
Testing and Debugging R2 Interactions
Local development can be tricky because you don’t want to hit production buckets on every test run. Cloudflare provides a “R2 mock server” that mimics