Mamoon Faisal

System Architecture

eCommerce Specialist

System Engineer

Blog Post

AWS S3 to Cloudflare R2: Proven Way to Cut Image Cost (~$246/mo → ~$0)

March 29, 2026
Aws S3 To Cloudflare R2: Proven Way To Cut Image Cost (~$246/Mo → ~$0)

Table of Contents

AWS S3 to Cloudflare R2: How I Cut Image Delivery Cost From ~$246/Month to ~$0

AWS S3 to Cloudflare R2 migration fixes the usual trap: storage feels cheap, but image delivery is often dominated by data transfer out (egress), not GB stored.
Featured graphic for this AWS S3 to Cloudflare R2 image cost savings case study (invoice-backed figures below).

AWS S3 to Cloudflare R2 was my path to image cost savings at scale: same object paths, new R2 origin, Cloudflare edge cache, and a stable custom domain for every image URL.

In this write-up, I moved image storage from AWS S3 to Cloudflare R2 and routed traffic through Cloudflare edge caching. I planned and executed this migration end to end myself—architecture, cutover, cache rules, app changes, and verification.

Outcome: monthly image delivery cost fell from about $246/month (invoice-backed) to ~$0/month, while R2 storage stayed very low.

Confidentiality note: project/client identifiers (domains, bucket names, internal URLs) are intentionally anonymized.

The problem (why my AWS bill was high)

Before the change, my app served images from an AWS-origin setup (S3/CloudFront). Storage was moderate (~50GB), but image requests were frequent enough that data transfer OUT dominated the bill.

Symptoms I wanted to fix:

  • High monthly AWS cost mainly caused by image traffic/egress
  • During the initial cutover, ~450K RPM was pulling directly from AWS S3 because caching rules were not yet taking effect
  • Unpredictable performance during cache misses
  • More complexity in keeping image URLs consistent across the website and dashboard

The goal

  1. Keep the same folder structure and object paths so the migration is safe
  2. Serve images through Cloudflare for faster delivery and lower origin traffic
  3. Make sure overwriting an existing image path always shows the latest content (cache correctness)

The solution (what I changed)

I implemented five parts:

  1. Storage migration: AWS S3 → Cloudflare R2
  2. Delivery migration: use a Cloudflare custom domain for all images — base URL example: https://images.yourdomain.com
  3. Cache strategy: Cloudflare Cache Rules with folder-based TTL (weekly vs monthly)
  4. Cache correctness: cache-busting via URL version query: ?v=<updated_at>
  5. App update: website + dashboard use the same base URL (so everything benefits from edge caching)

Architecture: before vs after

Before (AWS origin): User/App → CloudFront (CDN) → S3 → Internet.

Cost driver: S3 egress / data transfer OUT.

After (Cloudflare edge + R2): User/App → Cloudflare Edge (cache + DDoS) → R2 (only on cache miss / TTL expiry) → Internet.

Cost driver: very low R2 storage plus Cloudflare edge delivery (R2 egress effectively $0 for this pattern).

Migration steps (S3 → R2)

Step 1: Create the R2 bucket

  • Bucket name: R2_BUCKET_NAME

Step 2: Create an R2 API token

  • Permissions: Object Read & Write
  • Scope: apply to the specific bucket only
  • Save: Access Key ID, Secret Access Key, and the R2 endpoint

Step 3: Get AWS S3 credentials (source for migration)

  • Create/use an IAM user with read permissions for the S3 bucket
  • Save: S3 bucket name, region (e.g. ap-southeast-1), Access Key ID + Secret

Step 4: Run migration (S3 → R2)

I migrated the bulk (~58GB) using Cloudflare’s S3 → R2 migration tool. The transfer completed in ~30 minutes.

End-to-end setup (bucket/custom domain + app base URL + cache configuration/deployment) took ~1 day.

If you need resume support or incremental sync later, use a migration helper that can skip already-copied objects using your saved credentials.

Step 5: Enable public delivery (custom domain recommended)

  • Option A: Public R2 development URL (quick testing)
  • Option B (recommended): Custom domain, e.g. images.yourdomain.com

I enabled the custom domain: https://images.yourdomain.com

CORS (only if needed): If your frontend uses fetch() to download image bytes, you may need an R2 CORS policy. For plain <img src> rendering, CORS usually isn’t an issue.

Step 6: Update the app base URL

Replace the old image base URL everywhere with https://images.yourdomain.com. Object paths stay the same; only the base/origin URL changes.

Next.js integration:

NEXT_PUBLIC_IMAGE_BASE_URL=https://images.yourdomain.com
const imageUrl = `${process.env.NEXT_PUBLIC_IMAGE_BASE_URL}/${imagePath}`;

If using next/image, configure the remote image hostname in your Next.js image settings.

Step 7 (optional): Redirect old S3/CloudFront URLs

If URLs are still cached by users or external systems, redirect old URLs to the new base URL (e.g. 301).

Step 8: Verify and then decommission S3 (after confidence)

  • Confirm image loading on the website pages and dashboard
  • Test a sample of image paths
  • (Optional) Verify edge caching in DevTools: cf-cache-status: HIT on repeat requests
  • Monitor for errors for 1–2 weeks
  • Then stop relying on S3 reads (and optionally archive/delete the bucket when stable)

Ongoing upload strategy (so savings don’t regress)

  • Option A: Keep uploading to S3 and periodically sync to R2 (cron/worker).
  • Option B: Upload directly to R2 (S3-compatible endpoint), so new images appear immediately.

Cloudflare Cache Rules (what made it cheap)

Caching for R2 images is controlled by Cloudflare zone settings, not inside the R2 bucket.

Image hostname example: images.yourdomain.com. Ensure the DNS record is proxied (orange cloud) so Cache Rules apply.

I used two rules (weekly rule first, then monthly catch-all):

Rule 1: Weekly folders (7-day TTL)

Rule name: r2-images-weekly-folders

(http.host eq "images.yourdomain.com" and (starts_with(http.request.uri.path, "/Banners/") or starts_with(http.request.uri.path, "/Home_Page_Appearance/")))
  • Cache eligibility: Eligible for cache
  • Edge TTL: 604800 (7 days)
  • Ignore origin Cache-Control; optional browser TTL (e.g. 1 day)

Rule 2: Everything else (30-day TTL)

Rule name: r2-images-monthly

(http.host eq "images.yourdomain.com")
  • Cache eligibility: Eligible for cache
  • Edge TTL: 2592000 (30 days)
  • Ignore origin Cache-Control; optional browser TTL (e.g. 7 days)

Cache correctness: same-path overwrites

If you overwrite an object at the same path, the edge may serve the cached copy until TTL expires. Use a version in the URL, for example:

const imageUrl = `https://images.yourdomain.com/${imagePath}?v=${updatedAt}`;

When updatedAt changes, the URL changes and the edge fetches the latest object.

Results (what I achieved)

  • Before cache rules: AWS invoice (Dec 1–Dec 31, 2025): S3 = $25.97, Data Transfer = $220.49 (combined ~$246) with ~450K RPM hitting S3 directly.
  • S3 breakdown: Tier1 PUT/COPY/POST/LIST 3,681 requests → $0.02; Tier2 GET/other 61,722,684 requests → $24.69; storage 50.512 GB-Mo → $1.26.
  • After deployment: Edge-cached delivery; R2 “Data Retrieved” at 0B; ongoing image delivery ~$0/month.
  • Cloudflare R2 invoice (referenced billing period): $0.51 USD total.
  • R2 metrics (verification): avg storage ~58.83 GB, Data Retrieved 0 B.

Cost summary

PeriodS3 (storage + requests)Data transferTotal (image-related)
Dec 2025 (pre-cache)$25.97$220.49$246.46
Post-migration (Cloudflare)~$0.51 (R2 invoice period)$0 (Data Retrieved 0B)~$0

Why it worked: I standardized on one image hostname on Cloudflare for all traffic; TTL matches update frequency; R2 is hit on cache miss/refresh only; cache-busting avoids stale overwrites.

Post-deployment verification (15 days)

I monitored bucket metrics and edge behavior for 15 days after enabling cache rules.

  • Average storage: 58.83 GB
  • Data Retrieved from R2: 0 B
  • AWS (Jan 2026, comparison): S3 $0.00, Data Transfer $2.19

Lessons learned

  • Treat caching as part of the migration, not an afterthought
  • Use a single image base URL everywhere
  • Folder-based TTL balances cost vs freshness
  • Version query params when you overwrite the same object path

Frequently Asked Questions

Does Cloudflare R2 fully replace S3?

Yes for many object-storage workflows; delivery is typically via Cloudflare edge (custom domain or public R2 dev URL).

Create an R2 bucket + token, copy objects preserving keys/paths, then update your app base URL and cache rules.

Yes — use ?v=<updated_at> or change the path when content updates.

At the Cloudflare zone for the domain that hosts your image hostname, not inside the R2 bucket.

Basic Information

Services you're interested in...

Services you're interested in...