Cloudflare R2: Serve Images and Files from the Edge (No Egress Fees)
Get new tutorials every Tuesday
Join developers reading CoreCodery Weekly — Flutter, Cloudflare & Supabase.
No spam. Unsubscribe anytime.
Cloudflare R2: Serve Images and Files from the Edge (No Egress Fees)
Keywords: cloudflare r2 tutorial, cloudflare r2 vs s3, cloudflare r2 workers
Why File Storage Matters for Edge Apps
When you're building on Cloudflare Workers, compute is cheap and fast. But at some point your app needs to store and serve files — profile images, PDFs, build artifacts, or an entire media library. The traditional answer is AWS S3. It works fine, but it comes with a hidden tax: egress fees.
Every gigabyte of data leaving S3 costs money. For a high-traffic site, that bill adds up fast.
Cloudflare R2 eliminates egress fees entirely. It's S3-compatible, globally distributed, and integrates directly with Workers — making it the obvious choice if you're already on the Cloudflare stack.
This tutorial shows you exactly how to set it up.
R2 vs S3: Cost Comparison
Before diving in, let's look at what you're actually saving.
| Feature | AWS S3 | Cloudflare R2 | |---|---|---| | Storage (per GB/month) | ~$0.023 | ~$0.015 | | PUT / POST / COPY (per 1M ops) | $5.00 | $4.50 | | GET (per 1M ops) | $0.40 | $0.36 | | Egress to internet | $0.09/GB | $0.00 | | Free tier | 5 GB storage, 15 GB egress | 10 GB storage, unlimited egress |
The storage and operation costs are comparable. The egress difference is everything. A CDN-backed app that serves 500 GB per month pays AWS around $45 just in transfer fees. With R2, that line item disappears.
For CoreCodery.com, we use R2 as our asset pipeline — uploaded via a build script, served via a Worker. Zero egress. No surprises on the bill.
Prerequisites
- A Cloudflare account (free tier is fine to start)
- Node.js 18+ installed
- Wrangler CLI: `npm install -g wrangler`
- Authenticated: `wrangler login`
Step 1: Create an R2 Bucket
Creating a bucket takes one command:
wrangler r2 bucket create my-assetsVerify it exists:
wrangler r2 bucket listYou should see `my-assets` in the output. Buckets are not region-specific — Cloudflare handles geographic distribution automatically.
Step 2: Configure `wrangler.toml`
Bind the bucket to your Worker in `wrangler.toml`:
name = "assets-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "my-assets"The `binding` value is the variable name available inside your Worker. Use whatever makes sense for your project — we use `ASSETS_BUCKET` on corecodery.com.
Step 3: Upload Files via the R2 API
Option A: Wrangler CLI (for scripts and CI)
# Upload a single file
wrangler r2 object put my-assets/images/logo.png --file ./public/logo.png
# Upload with content type
wrangler r2 object put my-assets/images/hero.webp \
--file ./public/hero.webp \
--content-type image/webpThis works well in a CI pipeline. Add it to your GitHub Actions workflow after a build step:
- name: Upload assets to R2
run: |
wrangler r2 object put my-assets/build/${{ github.sha }}/app.js \
--file ./dist/app.js \
--content-type application/javascript
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Option B: Workers API (for user-generated uploads)
Inside a Worker, the R2 binding exposes a `put()` method:
interface Env {
ASSETS_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "PUT") {
return new Response("Method not allowed", { status: 405 });
}
const url = new URL(request.url);
const key = url.pathname.slice(1); // strip leading "/"
if (!key) {
return new Response("Missing key", { status: 400 });
}
const contentType = request.headers.get("Content-Type") ?? "application/octet-stream";
await env.ASSETS_BUCKET.put(key, request.body, {
httpMetadata: { contentType },
});
return new Response(JSON.stringify({ key, uploaded: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
};Call it with:
curl -X PUT https://your-worker.your-subdomain.workers.dev/images/photo.jpg \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpgIn production, add authentication before exposing this endpoint publicly. A simple approach is to check an `Authorization` header against a secret stored in a Workers secret:
const token = request.headers.get("Authorization");
if (token !== `Bearer ${env.UPLOAD_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}Step 4: Serve Files from a Worker with Caching Headers
Reading from R2 in a Worker is straightforward:
interface Env {
ASSETS_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1);
if (!key) {
return new Response("Not found", { status: 404 });
}
const object = await env.ASSETS_BUCKET.get(key);
if (!object) {
return new Response("Not found", { status: 404 });
}
const contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
return new Response(object.body, {
headers: {
"Content-Type": contentType,
// Cache at the edge for 1 year for immutable assets
"Cache-Control": "public, max-age=31536000, immutable",
// Cache at browser for 1 hour
"CDN-Cache-Control": "max-age=3600",
// ETag for conditional requests
"ETag": object.etag,
},
});
},
};Why These Headers Matter
- `Cache-Control: immutable` — tells browsers (and Cloudflare's CDN) never to revalidate. Use this for content-addressed assets (e.g., files named with a hash).
- `CDN-Cache-Control` — Cloudflare-specific header that controls edge caching independently from browser caching. You can expire CDN caches more aggressively than browser caches.
- `ETag` — R2 returns an ETag automatically. Pass it through so clients can use conditional `If-None-Match` requests to avoid downloading unchanged files.
For mutable files (like a `latest.json` manifest), swap to:
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",Step 5: Deploy the Worker
wrangler deployWrangler handles everything: uploading the Worker script, linking the R2 binding, and routing requests. Your files are now globally available via Cloudflare's edge network — served from the closest data center to your user, with no egress fees.
Real-World Use Case: corecodery.com Asset Pipeline
On corecodery.com we use R2 for static assets that don't fit well in Git:
1. Build output — compiled JS/CSS bundles are uploaded to `ASSETS_BUCKET` at key `builds/{git-sha}/` during CI 2. Images — product screenshots and blog images live at `images/` 3. Downloads — any user-downloadable files go to `downloads/`
Our `wrangler.toml` maps the same bucket for both the main site Worker and a dedicated assets Worker at `assets.corecodery.com`. Both Workers share one binding, one bucket, one bill.
The serving Worker sets `Cache-Control: immutable` for everything under `builds/` (content-addressed) and `max-age=3600` for everything under `images/` (may be updated). This two-tier caching approach means:
- Build assets are cached forever at the edge — zero origin reads after first request
- Images refresh at the CDN every hour — updates propagate quickly without a full cache purge
Conditional Reads with `If-None-Match`
R2 supports conditional fetches natively. Add this to your serving Worker to reduce bandwidth even further:
const object = await env.ASSETS_BUCKET.get(key, {
onlyIf: request.headers,
});
if (!object || !("body" in object)) {
// Object exists but hasn't changed — return 304
return new Response(null, {
status: 304,
headers: { ETag: (object as R2ObjectBody).etag },
});
}When a browser sends `If-None-Match: "etag-value"`, R2 checks the stored ETag and skips the body transfer if it matches. Combined with caching headers, this dramatically reduces both bandwidth and read operation counts.
Summary
Cloudflare R2 gives you:
- No egress fees — the biggest cost advantage over S3
- S3-compatible API — easy migration path if you're coming from AWS
- Native Workers integration — zero config, just a binding in `wrangler.toml`
- Global distribution — Cloudflare's network handles geography for you
The setup is three steps: create a bucket, bind it in `wrangler.toml`, and read/write with the built-in R2 API. Add proper caching headers and you have a production-grade asset pipeline that scales to any traffic level without surprise bills.
If you're already on Cloudflare Workers, R2 is the obvious storage layer. If you're not — this might be the reason to switch.
*CoreCodery — Built in Thailand*
Enjoyed this? Get notified of new posts.
Weekly tutorials on Flutter, Cloudflare & Supabase — free.
No spam. Unsubscribe anytime.