Cloudflare R2 + Laravel: low-cost media storage without compromises

R2 is an S3-compatible storage with predictable pricing and a CDN one click away. This guide shows how to wire it into Laravel for both public and private files, set correct edge caching, and ship your Vite build directly to R2 with truly atomic releases.

Cloudflare R2 + Laravel: low-cost media storage without compromises

Why R2 fits Laravel projects

Almost every product asks the same questions: where to keep user files, how not to overpay for bandwidth, how to make images and scripts arrive instantly, and how to release the frontend without “broken” links. R2 speaks the familiar S3 dialect and pairs neatly with a global CDN, which means minimal changes in your app and a short path to ideal caching headers. If you serve assets from your server today and move them to the edge tomorrow, the transition is smooth and low-risk.

Of course, any infrastructural “magic” has trade-offs. A tight CDN integration is fantastic for speed and for cheap, cache-friendly releases, but you must play by the provider’s rules (for example, how public access is managed) and give up a few S3 niceties. Below is an honest look at what you get out of the box and where a little adaptation helps.

Where R2 shines — and where it falls short

  • Strengths. S3 compatibility without friction; assets are served from a nearby CDN POP under your custom domain; predictable egress when using the CDN; perfect fit for hashed assets, static sites, public previews, and media blobs.
  • Trade-offs. Not a 1:1 feature match with classic S3 (e.g., public access is configured at the bucket/domain level rather than via per-object ACL headers); the broader third-party ecosystem of the biggest S3 providers is still larger; the very best wins come when you embrace the provider’s CDN pairing.

What this means in practice: treat public files as cacheable edge content (immutable names, long TTL), treat private files as signed S3 requests (short-lived links or a backend proxy), and push your build artifacts to the bucket with the headers you want users to see.

R2 concepts in 2 minutes

  • Bucket: a container for your objects. For public delivery you attach a custom domain to the bucket and flip the public access switch at the bucket level.
  • Endpoint: S3-compatible endpoint such as https://<ACCOUNT_ID>.r2.cloudflarestorage.com. SDKs talk to this; browsers typically hit your custom domain (CDN).
  • Public vs private: public objects are simply fetched by URL via CDN; private ones are requested via signed requests (temporary S3 signature) or served by your own backend/Worker.
  • Caching: edge POPs honor your object headers (Cache-Control, ETag, Content-Type). Immutable names = zero invalidations.

Wiring R2 into Laravel

Prerequisite (S3 adapter for Flysystem):

composer require league/flysystem-aws-s3-v3:^3.0

Configure a regular S3 disk (the important bits are the endpoint and path-style access):

// config/filesystems.php
'disks' => [
    'r2' => [
        'driver' => 's3',
        'key' => env('R2_KEY'),
        'secret' => env('R2_SECRET'),
        'region' => 'auto',
        'bucket' => env('R2_BUCKET'),
        'endpoint' => env('R2_ENDPOINT'), // https://<ACCOUNT_ID>.r2.cloudflarestorage.com
        'use_path_style_endpoint' => true,
        'url' => env('R2_PUBLIC_BASE'),   // https://assets.example.com (your CDN domain)
        'visibility' => 'private',
    ],
],
# .env
R2_KEY=...
R2_SECRET=...
R2_BUCKET=your-bucket
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
R2_PUBLIC_BASE=https://assets.example.com

Use it as you would any S3 disk:

// Public file
Storage::disk('r2')->put('media/avatars/u123.jpg', $binary);
$url = Storage::disk('r2')->url('media/avatars/u123.jpg');

// Private file (serve via a temporary signed URL)
$link = Storage::disk('r2')->temporaryUrl(
    'private/invoice-2025-09.pdf',
    now()->addMinutes(5)
);

Two-disk pattern (optional but tidy)

To make intent explicit, create r2_public and r2_private disks with different base URLs and default headers when uploading:

// config/filesystems.php
'disks' => [
  'r2_public' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'url' => env('R2_PUBLIC_BASE'),
    'visibility' => 'public',
  ],
  'r2_private' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'visibility' => 'private',
  ],
],

Public vs private: split by prefixes

A simple, durable convention:

  • build/ — frontend assets (JS/CSS/fonts/icons), public, cached “forever”.
  • media/ — public images/previews, moderate TTL.
  • private/ — closed documents and originals, served only via temporaryUrl().

Why prefixes? They make lifecycle automation trivial: headers at upload time, CDN rules, and access policies can target simple paths. Also, your cleanup and rollbacks are safer when assets are grouped.

Edge cache: which headers to set

  • For hashed assets (e.g., app.78e2a3.js): Cache-Control: public, max-age=31536000, immutable
  • For manifests, configs, JSON, and other “live” files: Cache-Control: public, max-age=60

Set headers at upload time so they stick to the object. Browsers + CDN will also respect ETag and serve 304s when appropriate. Immutable naming is what eliminates cache invalidations.

Content-Type matters (fonts/images)

Browsers are picky with font/woff2 and image/svg+xml. When uploading, pass explicit ContentType:

Storage::disk('r2_public')->put(
  'build/assets/app.78e2a3.woff2',
  $bytes,
  ['visibility' => 'public', 'ContentType' => 'font/woff2', 'CacheControl' => 'public, max-age=31536000, immutable']
);

CORS for fonts and XHR

If your app runs on app.example.com and assets on assets.example.com, configure CORS on the bucket to allow fonts and JSON fetches to load without errors:

[
  {
    "AllowedMethods": ["GET", "HEAD", "OPTIONS"],
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Content-Length"],
    "MaxAgeSeconds": 86400
  }
]

Ship your Vite build straight to R2 (atomic releases)

The idea: run npm run build in CI and let an S3-compatible Vite uploader push public/build to R2. Your bucket path and the public URL must match Vite’s base.

Environment variables

R2_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxx
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=my-cdn
R2_PUBLIC_BASE=https://assets.example.com          # CDN domain
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
UPLOAD_ENABLED=1                                    # enable upload in CI

Vite config

Key points: set base to CDN-domain + /build/; use path-style access for the S3 client; don’t rely on ACLs for public access (configure bucket/domain instead); perform two uploads for atomicity — assets first (long TTL), then manifest.json (short TTL).

// vite.config.ts
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import { ViteS3 } from '@froxz/vite-plugin-s3'

const enabled = !!process.env.UPLOAD_ENABLED

const clientConfig = {
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT!,
  forcePathStyle: true, // safer for R2
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
}

export default defineConfig({
  base: `${process.env.R2_PUBLIC_BASE}/build/`,
  build: { manifest: true, outDir: 'public/build', emptyOutDir: true },
  plugins: [
    laravel({
      input: ['resources/js/app.ts', 'resources/css/app.css'],
      refresh: true,
      buildDirectory: 'build',
    }),

    // 1) Hashed assets — long TTL + immutable
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [
        /\/assets\/.+\.[a-f0-9]{8,}\.(js|css|woff2|png|jpe?g|webp|svg|gif|ico|map)$/i
      ],
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=31536000, immutable',
      },
    }),

    // 2) manifest.json — short TTL; uploaded after assets
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [/manifest\.json$/],
      sequentialUploads: true,
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=60',
      },
    }),
  ],
})

CI pipeline

npm ci
npm run build    # the uploader pushes the build to R2

Keep UPLOAD_ENABLED=0 locally and enable it only in CI. Store all R2_* secrets in your CI’s secret manager.

Rollbacks that actually work

Because assets are immutable, rolling back is just a manifest switch. Keep the previous manifest.json around; to roll back, re-upload the older manifest (short TTL) while assets remain cached with long TTL. No invalidations, no waiting.

Private delivery patterns

For sensitive files use one of these:

  1. Temporary S3-style links. Use Storage::disk('r2')->temporaryUrl(). Simple and fast. Revocation happens by expiring the link soon (minutes).
  2. Backend proxy route. Authorize in Laravel, then stream from R2 with your app’s credentials. Lets you set Content-Disposition (inline vs attachment) and log access centrally.
  3. Cloudflare Worker. A tiny edge function that validates a JWT/session cookie and fetches from R2, keeping binary traffic at the edge.

Example: Laravel proxy controller

// routes/web.php
Route::get('/files/{path}', App\Http\Controllers\PrivateFileController::class)
  ->where('path', '.*')
  ->middleware(['auth']);

// app/Http/Controllers/PrivateFileController.php
public function __invoke(string $path)
{
    abort_unless(Str::startsWith($path, 'private/'), 404);

    $stream = Storage::disk('r2_private')->readStream($path);
    abort_unless($stream, 404);

    return response()->stream(function () use ($stream) {
        fpassthru($stream);
    }, 200, [
        'Content-Type' => Storage::disk('r2_private')->mimeType($path) ?? 'application/octet-stream',
        'Content-Disposition' => 'inline', // or 'attachment'
        'Cache-Control' => 'private, max-age=0, no-store',
    ]);
}

Example: minimal Worker idea

Pseudocode: verify a signed token (HMAC/JWT), fetch private/<key> from R2 with service credentials, set Content-Disposition, stream back. Result lives near the user and never touches your PHP runtime.

Image transformations: three solid options

  1. Background conversions in Laravel. Upload the original, queue a job, generate sizes/formats, use fingerprinted names like photo@800w.a1b2c3.jpg.
  2. Edge transformations. Resize public previews at the edge (Workers/Images) and cache the result at the CDN.
  3. Hybrid. Frequent sizes at the edge; exotic ones in background jobs. Fingerprinted names let you keep immutable safely.

Tip: whichever approach you pick, persist the derived URL (or path) you actually render. That avoids surprise re-computations and keeps your templates simple.

Security and access

  • Split credentials. one for the web app (read/write specific prefixes), another for CI (write-only to build/).
  • Principle of least privilege. deny-list everything, then allow PutObject for build/* to CI; allow Put/Get/List/Delete for media/* to the app if you need edits.
  • Region/jurisdiction. use the correct endpoint variant if your bucket is locked to a specific jurisdiction to avoid signature/redirect issues.
  • Secrets hygiene. keep keys in env/CI vaults, never in vite.config.ts or repo.

Common pitfalls

  • MIME types for fonts/images. Ensure correct Content-Type on objects—browsers are picky about font/woff2 and image/svg+xml.
  • “Sticky” manifest. Keep a short TTL and upload the manifest last.
  • Mismatched paths. Vite’s base must match the actual CDN path (e.g., https://assets.example.com/build/…).
  • Clock skew & signatures. Temp URLs may fail if your server clock drifts. NTP fixes mysterious SignatureDoesNotMatch errors.
  • Wrong addressing mode. R2 prefers path-style (forcePathStyle). Virtual-hosted requests can 403/redirect.
  • CORS. If fonts fail on Safari/Firefox, add CORS for your app origin and expose ETag.

Implementation checklist

  1. Create a bucket and enable Public Bucket; attach a Custom Domain.
  2. Install the S3 adapter and configure the r2 (or split public/private) disks with endpoint, use_path_style_endpoint=true, and url set to your CDN domain.
  3. Split prefixes: build/, media/, private/.
  4. Enable Vite → R2: set base to the CDN; perform two uploads (assets → manifest); prefer path-style client.
  5. Set headers: hashed assets — immutable + 1y; manifest — short TTL; media — practical TTL (e.g., hours-days).
  6. Optional: background / edge image conversions.
  7. Isolate app and CI credentials. Add CORS for your app origin.
  8. Plan rollbacks: keep previous manifest; rollback = re-upload old manifest.

Cost & limits mindset (non-exhaustive)

Think in operations and egress. The biggest savings appear when most public traffic is served by the CDN with long-lived cache; CI pushes are tiny compared to users’ reads. If you move user uploads to media/ with a moderate TTL and prefer thumbnails/previews over originals in UI, you reduce bytes and time-to-first-paint. Track object counts, request rates, and cache hit ratio to avoid surprises.

Troubleshooting quick reference

  • 403 on temp URL: check clock skew, credentials, and that the request host matches the signed host (SDK often signs the endpoint, not your CDN domain).
  • Font blocked: add/relax CORS, expose ETag, confirm Content-Type.
  • 404 after deploy: likely a base mismatch or you uploaded manifest.json before assets.
  • Slow first hit: expected cold edge cache. Use pre-warm (optional) or accept the first-miss tax; subsequent hits are instant.

Conclusion

If you want a fast frontend, inexpensive storage, and fewer moving parts, the Laravel + R2 combo delivers: familiar file APIs, CDN delivery out of the box, and tidy Vite releases without broken paths. You’ll give up a few S3-specific niceties, but in return you get speed, simplicity, and cost predictability—the things teams value most day to day.


Get in touch

Have a project
in mind?

Tell us the context and the outcome you want. We’ll reply within 1 business day with the simplest next step (timeline, rough budget, or quick audit).

By submitting, you agree that we’ll process your data to respond to your enquiry and, if applicable, to take pre-contract steps at your request (GDPR Art. 6(1)(b)) or for our legitimate interests (Art. 6(1)(f)). Please avoid sharing special-category data. See our Privacy Policy.
We reply within 1 business day.