DEV Community

Daniel, Petrica Andrei-Daniel
Daniel, Petrica Andrei-Daniel

Posted on • Originally published at danielpetrica.com on

How to Replace Raw S3 URLs with a Laravel Image Proxy (and Keep Your CDN Cache)

How to Replace Raw S3 URLs with a Laravel Image Proxy (and Keep Your CDN Cache)

If you're serving images and media files directly from an S3-compatible object storage (like Hetzner Object Storage, AWS S3, or DigitalOcean Spaces), you're probably used to URLs that look like this:

https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.png
Enter fullscreen mode Exit fullscreen mode

That's ugly. It exposes your storage provider and bucket name. And if your bucket is private, you can't even serve those URLs directly. You'd need signed URLs or a proxy.

Here's how I replaced all raw S3 media URLs on my Laravel blog with clean proxied paths through the application, while keeping 24-hour CDN caching for performance.

The architecture

We run a typical Laravel + Octane stack behind Traefik with Cloudflare. All media is stored on an S3-compatible object storage in two buckets: a private one for uploaded media (project/files) and a public one for auto-generated Open Graph images (project/og-images).

Originally, the app generated URLs directly to object storage using Storage::url(). This meant:

  • Private bucket URLs were inaccessible (and switching to public was not an option)
  • Provider details were exposed in every page's HTML
  • No control over caching at the application level

Step 1: Build a URL helper

First, I created a MediaUrlBusiness class that centralizes URL generation for both disks. Instead of calling Storage::disk('og-images')->url($path) or Storage::url($path) in blade templates, I now call:

MediaUrlBusiness::forOgImage($path); // → /storage/og-images/posts/slug.png
MediaUrlBusiness::forMedia($path); // → /storage/media/posts/slug/cover.png
Enter fullscreen mode Exit fullscreen mode

This returns a clean relative path like /storage/og-images/.... All 6+ blade templates that previously generated raw S3 URLs now use this single helper.

Step 2: The proxy controller

The ObjectProxyController catches all /storage/{disk}/{path} requests. It:

  • Validates the disk is one of the allowed ones (media → private bucket, og-images → public bucket)
  • Resolves the full S3 key using the disk's configured prefix
  • Streams the file from object storage using response()->stream() with proper Content-Type and Content-Length headers
  • Sets a Cache-Control: public, max-age=86400 header so Cloudflare and browsers cache aggressively

Here's the core of it:

$disk = Storage::disk($diskMap[$diskSegment]);
$stream = $disk->readStream($fullPath);

return response()->stream(
    callback: function () use ($stream) {
        fpassthru($stream);
        fclose($stream);
    },
    headers: [
        'Content-Type' => $disk->mimeType($fullPath),
        'Content-Length' => $disk->fileSize($fullPath),
        'Cache-Control' => 'public, max-age=86400, immutable',
    ]
);
Enter fullscreen mode Exit fullscreen mode

A detail that tripped me up: Storage::download() and streamDownload() buffer the entire file into memory. For large images, that wastes memory. Switching to readStream() + response()->stream() sends the file directly from S3 to the client without buffering.

Note: Laravel's S3 driver supports fileSize(). For custom disks, use $disk->size() instead.

Important: anything in the private bucket is now accessible through the proxy if someone guesses the path. Make sure that bucket only holds public-facing assets.

Step 3: Route and middleware setup

The proxy route lives in routes/static.php alongside other cacheable frontend routes:

Route::get(uri: '/storage/{disk}/{path}', action: ObjectProxyController::class)
    ->where('path', '.*')
    ->name('storage.proxy');
Enter fullscreen mode Exit fullscreen mode

I also modified the SetCacheControlHeader middleware to skip responses that already have a Cache-Control header set (since the proxy controller sets its own). This way the middleware doesn't override our carefully tuned 24-hour cache policy.

Step 4: Wire it up everywhere

The MediaUrlBusiness helper is now used in:

  • Post, page, and tag show views, for both OG images and feature images
  • Homepage and archive listings, for post card images
  • Case study components, for service images
  • OgImageBusiness, for storing and retrieving generated OG images

Every call to Storage::disk('og-images')->url(...) or Storage::url(...) was replaced with the appropriate MediaUrlBusiness::for*() method.

What about the admin panel?

The Filament admin panel has its own media proxy. The existing MediaProxyController handles CORS issues when editors browse and insert images in the RichEditor. That stays as-is; it's authenticated and not meant for public caching.

The result

Before:

<img src="https://storage.example.com/my-bucket/project/files/posts/hello-world/cover.png">
Enter fullscreen mode Exit fullscreen mode

After:

<img src="/storage/media/posts/hello-world/cover.png">
Enter fullscreen mode Exit fullscreen mode

Clean, provider-agnostic, and cache-friendly. The URL no longer leaks my storage provider or bucket name. And if I ever switch providers, I only need to change the disk configuration. The public URLs stay the same.


Need this for your site? If your team doesn't have the bandwidth to implement this, I can set it up for you: clean proxied image URLs, CDN caching, and private bucket security. Get in touch →

Top comments (0)