Skip to main content
Infrastructure · 11 min read

Serving Static Assets from Cloudflare R2 Instead of S3: Benefits, Rules, and Trade-offs

When to serve static assets and directories directly from Cloudflare R2 instead of S3 — the cost case, SSG trade-offs, custom domains, cache rules, and production setup.

ET
EdgeSteed Team
Edge & Infrastructure, EdgeSteed

Static assets look boring until they become one of the largest and least predictable lines on the bill. Product screenshots, PDFs, videos, firmware builds, exported reports, font files, documentation bundles, and marketing media often start in the same bucket as everything else. Then traffic grows, files get hot, and the cost model quietly changes from “cheap storage” to “paid every time bytes leave the cloud.”

Cloudflare R2 is attractive because it changes that equation: S3-compatible object storage, no egress fees to the Internet, and a clean path to put the bucket behind your own Cloudflare-managed domain. But R2 is not a magic replacement for every static hosting pattern. The right architecture depends on whether you are serving a web application or a set of objects.

This guide is for teams deciding whether assets should live in S3, a static site host, or R2 behind a custom domain. We’ll cover the cost and performance case, where SSG still belongs, how direct R2 object serving works, which Cloudflare rules matter, and the failure modes to avoid.

The real problem: asset delivery is not just storage

Most teams evaluate S3 and R2 by storage price first. That is the wrong starting point for public assets. Storage is usually the stable part. Delivery is the variable part.

A public asset system has four jobs:

  1. Store objects durably. The bucket holds bytes and metadata.
  2. Expose URLs safely. The public surface should be a controlled hostname, not an internal bucket endpoint.
  3. Cache aggressively. Hot files should be served from edge cache, not from object storage on every request.
  4. Invalidate predictably. Deploys, replacements, and takedowns need a clear cache story.

S3 can do this, usually with CloudFront in front. R2 can do it with a custom domain inside Cloudflare, plus Cache Rules, WAF rules, Access, and Workers when you need logic. The biggest architectural difference is not the object API. It is where the edge layer lives and how much you pay when files leave storage.

Why R2 is compelling for public assets

R2’s strongest fit is public, read-heavy file delivery where the same objects are downloaded many times. That includes:

  • hashed frontend assets such as app.4f3a1c.css and vendor.91bc2.js
  • images, videos, fonts, and Open Graph media
  • downloadable PDFs, whitepapers, invoices, and generated reports
  • docs exports and static directories where exact file paths are acceptable
  • release artifacts, model files, datasets, and customer-visible archives

The benefits are practical:

No Internet egress fee from R2. Cloudflare charges for storage and operations, but R2’s standard pricing does not charge egress bandwidth to the Internet. For large public downloads, this is the lever that usually matters most.

S3-compatible tooling. Most deployment pipelines can switch by changing the endpoint and credentials. The AWS CLI, SDKs, rclone, Terraform, and many asset uploaders can talk to R2’s S3-compatible API.

One edge/security surface. A bucket on a custom domain can use Cloudflare Cache, WAF custom rules, bot controls, Access, Transform Rules, and Workers. You keep delivery policy in the same control plane as the rest of your site.

Cleaner multi-cloud posture. If your application is already on Cloudflare Workers, Pages, Vercel, Fly, or a non-AWS stack, putting public assets in R2 avoids pulling every visitor through AWS just to fetch static bytes.

Key takeaway: R2 is not just “cheaper S3.” Its advantage is strongest when object storage, CDN, cache policy, and edge security all live behind the same Cloudflare hostname.

SSG hosting vs direct R2 object serving

The first decision is whether you are serving a site or a bucket.

Use SSG hosting for HTML routes

Static Site Generation (SSG) turns pages into HTML, CSS, JS, and assets at build time. A platform such as Cloudflare Pages, Workers Static Assets, Netlify, or Vercel understands web routing:

  • /pricing can map to /pricing/index.html
  • SPA fallback can send unknown paths to /index.html
  • redirects and rewrites can run before the response
  • headers can be managed per route
  • deploys can be atomic, previewable, and tied to Git
  • 404 pages and trailing-slash behavior can be explicit

That is what you want for marketing pages, docs sites, blogs, landing pages, and apps where HTML routes matter. The build output is not just “files in a directory”; it is a deployable website with route semantics.

Use R2 for object-key delivery

R2 public buckets expose objects. A request path maps to an object key. That is perfect when the URL is supposed to identify a file:

https://assets.example.com/brand/logo.svg
https://assets.example.com/downloads/report-2026.pdf
https://assets.example.com/releases/app-1.8.4/darwin-arm64.zip

It is less ideal when you expect a web host to infer routes:

/docs/          -> /docs/index.html
/app/settings  -> /app/index.html
/old-path      -> /new-path

Those behaviors are application routing, not object storage. You can add them with a Worker in front of R2, but at that point you are building a small static host. That can be the right move for a controlled docs archive or internal portal, but it should be a deliberate choice.

Three production patterns that work

Pattern 1: SSG site plus R2 asset domain

This is the default we recommend.

Your site stays on Cloudflare Pages, Workers, Astro, Next, or another SSG-capable host. Large or long-lived assets move to R2 behind a subdomain:

www.example.com        -> site/app host
assets.example.com     -> R2 public bucket custom domain
downloads.example.com  -> R2 public bucket custom domain

The site links to R2 URLs for heavy assets:

<img src="https://assets.example.com/blog/r2-architecture.png" width="1200" height="630" alt="">
<a href="https://downloads.example.com/whitepapers/cloud-cost-audit.pdf">Download PDF</a>

This keeps route handling, SEO, structured data, and deploy previews in the site platform while moving high-volume bytes to cheaper, cacheable object delivery.

Pattern 2: Direct R2 directory for file archives

This fits release artifacts, public datasets, documentation exports, design kits, or anything where the URL is a file path and not an app route.

https://releases.example.com/myapp/1.8.4/checksums.txt
https://releases.example.com/myapp/1.8.4/myapp-linux-amd64.tar.gz
https://releases.example.com/myapp/1.8.4/myapp-darwin-arm64.zip

You upload objects with stable keys, set cache headers, and treat the bucket as a public file tree. Do not depend on bucket directory listing as navigation. Publish an index page from your main site or generate an explicit index.html object if users need a browseable landing page.

Pattern 3: Worker in front of R2 for route behavior

Use a Worker when direct object serving is almost enough, but you need a little logic:

  • /docs/ should load /docs/index.html
  • missing extension paths should fall back to HTML
  • private downloads need token checks
  • tenant-specific prefixes should map to different object keys
  • certain objects need custom headers or signed URL validation

A Worker can read from an R2 binding, normalize the path, set headers, and return the object. This is more flexible than a pure public bucket, but it adds compute, code ownership, and observability requirements. For full websites, Pages or Workers Static Assets will usually be a better starting point.

Custom domain setup: the minimum viable production path

Cloudflare’s R2 public bucket docs are clear on the production shape: expose the bucket on a custom domain you control. The r2.dev URL is for development and is rate-limited; it does not give you the same cache, WAF, Access, or bot-management surface.

A production setup looks like this:

  1. Create an R2 bucket, for example edge-assets.
  2. Add a custom domain such as assets.example.com in the bucket settings.
  3. Let Cloudflare create/manage the DNS record for that hostname.
  4. Keep the hostname proxied through Cloudflare.
  5. Disable the public r2.dev endpoint if you are relying on Access, WAF, token rules, or any security policy on the custom domain.
  6. Configure Cache Rules for the hostname.
  7. Upload objects with correct Content-Type and Cache-Control metadata.

The custom domain is the boundary where Cloudflare products can help. Without it, you have a public development endpoint, not a production delivery architecture.

Cache rules that matter

R2 is storage. Cloudflare Cache is what turns repeated public reads into fast edge responses. The rules should be boring and explicit.

1. Cache immutable hashed assets for a long time

For files with content hashes in the name, make both browser and edge cache long-lived:

Hostname: assets.example.com
Path matches: /_assets/* or file extension in {css js svg woff2 avif webp png jpg}
Cache eligibility: Eligible for cache
Edge TTL: 1 month or longer
Browser TTL: 1 year
Respect origin Cache-Control: yes, if your upload metadata is reliable

Pair that with object metadata:

Cache-Control: public, max-age=31536000, immutable

When the content changes, the filename changes. You do not purge; you publish a new object key.

2. Cache mutable files shorter

Files like latest.zip, catalog.json, feed.xml, and index.html need shorter TTLs because the URL can change without the key changing:

Path matches: /latest/* or *.json or *.xml or */index.html
Cache eligibility: Eligible for cache
Edge TTL: 5 minutes to 1 hour
Browser TTL: 1 minute to 10 minutes

If users must see updates immediately, either version the URL or purge by URL/prefix during deploy.

3. Do not cache private or tokenized URLs blindly

If downloads are gated by cookies, Authorization headers, or signed query strings, default to bypass unless you have designed the cache key correctly.

Bad pattern:

https://downloads.example.com/private/report.pdf?token=abc

If Cloudflare caches only by path and ignores the query string, the first authorized response can become visible to the wrong user. For private downloads, use a Worker or WAF token authentication, include the relevant query/header in the cache key, or bypass cache entirely.

4. Use Cache Everything only where you mean it

Cloudflare only caches certain file types by default. For a bucket host, that is often too conservative, especially if you serve extensionless objects, JSON, or generated artifacts. A Cache Rule that makes a specific hostname eligible for cache is appropriate:

if http.host eq "assets.example.com"
then Cache eligibility = Eligible for cache

Do not apply that globally to your main application hostname. Keep bucket caching policy on the bucket hostname.

5. Sort or ignore query strings intentionally

Marketing tools and build pipelines love appending query strings:

/image.png?v=2
/app.css?utm_source=newsletter

For immutable assets, query strings usually should not create separate cache entries. For signed URLs, they absolutely might matter. Decide per hostname and path.

Upload pipeline: preserve metadata

The easiest way to break an asset system is to upload every object with the wrong content type or no cache metadata. Browsers care about Content-Type; caches care about Cache-Control.

Using S3-compatible tooling, a deployment often looks like this:

aws s3 sync ./dist/assets s3://edge-assets/assets \
  --endpoint-url "https://<account-id>.r2.cloudflarestorage.com" \
  --cache-control "public, max-age=31536000, immutable" \
  --delete

For mutable files, upload with a different policy:

aws s3 cp ./dist/manifest.json s3://edge-assets/manifest.json \
  --endpoint-url "https://<account-id>.r2.cloudflarestorage.com" \
  --cache-control "public, max-age=300"

In real pipelines, split uploads by class:

  • hashed assets: one year, immutable
  • HTML and manifests: short TTL
  • downloads with versioned filenames: long TTL
  • latest.* aliases: short TTL plus purge on release
  • private exports: no public bucket, or Worker-mediated access

This metadata split matters more than the storage provider. A badly cached R2 bucket will behave worse than a well-tuned S3 + CloudFront setup.

R2 vs S3: the practical comparison

AreaS3 + CloudFrontR2 + Cloudflare custom domain
Storage APINative S3S3-compatible, with some API differences
Public deliveryUsually CloudFront or S3 website endpointCloudflare custom domain or r2.dev for development
Internet egressCharged according to AWS data transfer / CDN pricingNo R2 egress bandwidth fee
Edge cacheCloudFront policies and invalidationsCloudflare Cache Rules, purge, tiered cache
Security layerAWS IAM, bucket policies, CloudFront signed URLs, WAFCloudflare WAF, Access, token rules, Workers
Website routingS3 website hosting and/or CloudFront functions/Lambda@EdgeDirect object keys unless you add a Worker or use Pages/Workers Static Assets
Best fitAWS-native stacks, complex AWS governance, existing CloudFront setupsCloudflare-native delivery, public assets, high egress workloads, multi-cloud apps

This is not a universal replacement argument. If your company is deeply AWS-native, already has CloudFront policies, signed URLs, logging, and governance wired in, S3 may be the least risky choice. R2 becomes compelling when egress is material, assets are public or semi-public, and Cloudflare is already your edge.

The SEO and Core Web Vitals angle

Moving assets to R2 does not automatically improve SEO. It improves the inputs that affect user experience when configured correctly:

  • lower asset latency through Cloudflare edge cache
  • fewer origin bottlenecks for images, fonts, and scripts
  • stable URLs for immutable files
  • predictable cache headers
  • less application server work during traffic spikes

But be careful with the HTML. Search engines need stable canonical pages, semantic markup, structured data, redirects, and correct status codes. That is why SSG hosting remains the right home for pages. R2 should serve the bytes that pages reference, not replace the page platform unless your route needs are intentionally simple.

This pairs naturally with an Astro or Jamstack architecture: static pages from the site host, heavy assets from R2, and Cloudflare cache in front of both. The result is fast HTML, cheap asset delivery, and a clean separation between route logic and object storage.

Migration path from S3

Do not start by moving everything. Start with a traffic slice where the economics are obvious.

  1. Measure the current bill. Break out storage, GET requests, CloudFront/S3 data transfer, and invalidation costs.
  2. Pick public, versioned assets first. Images, PDFs, releases, and hashed build files are safer than private customer exports.
  3. Create the R2 bucket and custom domain. Use a subdomain such as assets.example.com so you can switch gradually.
  4. Sync objects with metadata. Preserve content types and apply cache policy by file class.
  5. Add Cache Rules. Long cache for immutable paths; short cache or bypass for mutable/private paths.
  6. Update references gradually. Move one page template, asset class, or download surface at a time.
  7. Watch cache analytics. You want high edge hit ratio and low origin reads from R2 after warmup.
  8. Keep rollback simple. Because URLs are separate, reverting the app to S3 asset URLs should be a config change, not a migration project.

If you need true zero-downtime migration from S3, use a dual-write or staged sync period. Publish new assets to both stores, switch reads to R2 for a subset of paths, then stop writing to S3 once access logs show the old URLs are cold.

Common mistakes

Using r2.dev in production. It is convenient, but it is not the production surface. Use a custom domain.

Moving HTML routes to R2 without route planning. Object storage is not automatically a web framework. If you need fallback routing, redirects, auth, or pretty directory indexes, use Pages/Workers or put a Worker in front.

Caching mutable files like immutable files. latest.zip with a one-year browser TTL is an incident waiting to happen. Version the filename or keep the TTL short.

Forgetting security bypasses. If r2.dev remains public while the custom domain is protected by Access or WAF, users may be able to bypass your protection through the development URL.

Ignoring content type. A JavaScript file served as application/octet-stream or an SVG with the wrong type can break browsers, previews, and security headers.

Treating R2 as a private download system without a gate. Public buckets are public. For private files, use signed URLs through a Worker, Cloudflare Access, WAF token authentication, or keep the bucket private and broker reads through application logic.

A decision rule you can use

Use this rule of thumb:

  • HTML pages and app routes: SSG/Pages/Workers Static Assets.
  • Large public files and immutable assets: R2 behind a custom domain.
  • Simple public file trees: R2 direct object serving.
  • File trees with route behavior or auth: Worker in front of R2.
  • AWS-native private object workflows: S3 may still be the right system.

The winning architecture is usually not “R2 instead of S3 everywhere.” It is more precise: keep page rendering and routing in the tool built for pages, move high-volume public assets to R2, and put cache/security rules where the traffic actually enters.

If your asset bill is growing or your static delivery setup is split across S3, CloudFront, app servers, and ad hoc buckets, contact us. EdgeSteed helps teams design edge compute and web performance architectures where HTML, assets, caching, and security each live in the right place.

جاهز لنقل هذا إلى الإنتاج؟

أخبرنا بما تبنيه. سنرسم أقصر طريق لإطلاقه.

واصل القراءة

المزيد من المدونة

Infrastructure

Servir assets estáticos desde Cloudflare R2 en vez de S3: ventajas, reglas y límites

Cuándo mover assets y directorios estáticos a Cloudflare R2 en lugar de S3: costes, SSG, dominios personalizados, reglas de caché y patrones de producción.

Infrastructure

Servir des assets statiques depuis Cloudflare R2 plutôt que S3 : bénéfices, règles et limites

Quand déplacer des assets et répertoires statiques vers Cloudflare R2 au lieu de S3 : coûts, SSG, domaines personnalisés, règles de cache et architecture de production.

Infrastructure

Servire asset statici da Cloudflare R2 invece che da S3: vantaggi, regole e limiti

Quando spostare asset e directory statiche su Cloudflare R2 invece che su S3: costi, SSG, domini personalizzati, regole di cache e architettura di produzione.