Skip to main content
Back to Blog
CloudflareR2WorkersStorageTutorial

Cloudflare R2: Serve Images and Files from the Edge (No Egress Fees)

CoreCodery Team

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-assets

Verify it exists:

wrangler r2 bucket list

You 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/webp

This 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.jpg

In 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 deploy

Wrangler 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.