So here's the thing, I had this Rails app where images were taking forever to load. Users were bouncing, my AWS bill was getting expensive, and I was serving the same dog photos from S3 over and over again to users all around the world.
After some research, I discovered that putting Cloudflare in front of my S3 bucket was basically free performance magic. Here's exactly how I did it, including the gotchas I ran into.
Why This Actually Works
Before I get into the how-to, let me quickly explain why this setup is so effective:
When you serve images directly from S3, every single request hits your bucket. A user in Tokyo requesting an image from your US-East bucket? That's a long round trip. A user refreshing the page? Another S3 request and another charge on your AWS bill.
With Cloudflare in front, images get cached at edge locations worldwide. That Tokyo user gets the image from Cloudflare's Tokyo datacenter instead of Virginia. Plus, once an image is cached, you're not paying S3 bandwidth costs for repeated requests.
What You'll Need
- An AWS account with an S3 bucket (or willingness to create one)
- A domain on Cloudflare (free tier works fine)
- About 30 minutes
Let me walk you through each step.
Step 1: Setting Up Your S3 Bucket
First, create a new S3 bucket or use an existing one. I called mine my-app-images-2024 but you can name it whatever makes sense.
The tricky part: you need to turn off Block All Public Access.
Here's what the setting looks like:
Choose your region based on where most of your users are, or just pick the one closest to you.
Step 2: The Bucket Policy (This Part Caught Me Off Guard)
You need to add a bucket policy to actually make the images readable. This is the JSON you need to paste in (replace my-images-bucket
with your actual bucket name):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::my-images-bucket/*"]
}
]
}
Go to your bucket β Permissions β Bucket Policy, paste this in, and save.
After this, you should be able to access any image in your bucket directly:
After this, you should be able to access any image in your bucket directly:
https://my-images-bucket.s3.amazonaws.com/path/to/image.jpg
Note: Test this before moving on - upload a test image and try accessing it via the URL.
Step 3: Cloudflare CNAME Setup
Now for the good stuff. In your Cloudflare dashboard:
- Go to DNS
- Add a CNAME record
- Set the name to something like
images
orcdn
- Set the name to something like
- Point it to your S3 bucket:
my-images-bucket.s3.amazonaws.com
- Make sure the cloud is orange (proxied through Cloudflare) π
Now your images are accessible at https://images.yourdomain.com/path/to/image.jpg
instead of the ugly raw S3 URL.
Step 4: Caching Rules (Where the Magic Happens)
This is where you actually get the performance benefits. Without proper caching rules, Cloudflare might not cache your images aggressively.
Go to Rules β Cache Rules and create a new Cache rule:
Edge Cache TTL: 1 month (images don't change often)
Browser Cache TTL: 7 days
- URL pattern:
images.yourdomain.com/*
I initially set this to cache for just a few hours, but realized that was stupid - images rarely change, so why not cache them for a month?
Step 5: Cloud Connector: The Secret Sauce (Don't Skip This!)
Okay, I need to be honest - I initially thought Cloud Connector was just extra fluff, but it's actually really important. It handles the technical details that make S3 work properly with Cloudflare.
Here's what Cloud Connector does automatically:
- Fixes the Host header - S3 is picky about headers, and this makes sure they match what your bucket expects
- Routes traffic correctly - Makes sure requests actually reach your bucket
What it doesn't do (you still need cache rules):
- Cache behavior - you still need to set up those cache rules we talked about earlier
The good news? You get 10 free connectors even on Cloudflare's free plan, which is way more than you'll need.
Go to Rule β Cloud Connector and click Create Cloud Connector
:
Select AWS S3 since that's what we're using:
Now enter your bucket information. This is where you need to be precise:
Important: For the hostname pattern, use a wildcard like images.yourdomain.com/*
since you'll be serving different images from various paths:
This wildcard setup ensures that whether someone requests images.yourdomain.com/user-avatars/123.jpg or images.yourdomain.com/blog-posts/hero-image.png, the connector knows exactly which bucket to route to.
Pro tip: Double-check that your bucket name matches exactly what you put in the connector. I spent 20 minutes debugging why my images weren't loading, only to realize I had a typo in the bucket name. π€¦ββοΈ
Step 6: Rails Helper (If You're Using Rails)
Here's the Rails helper I wrote to make this work seamlessly with ActiveStorage:
Instead, we build URLs using the blob key and point them to our Cloudflare domain.
Hereβs a helper module:
module ApplicationHelper
include Pagy::Frontend
def cdn_image_url(attachment_or_blob, **opts)
blob = attachment_or_blob.is_a?(ActiveStorage::Blob) ? attachment_or_blob : attachment_or_blob&.blob
return unless blob
base = ENV['CDN_CLOUDFLARE_URL'].presence ||
Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: false)
url = "#{base.chomp('/')}/#{blob.key.delete_prefix('/')}"
return url if opts.blank?
params = opts.slice(:width, :height, :quality, :format).map { |k, v| "#{k.to_s[0]}=#{v}" }
"#{url}?#{params.join('&')}"
end
end
Then in your views:
<%= image_tag cdn_image_url(user.avatar, width: 300, quality: 80, format: :webp) %>
I set the CDN_CLOUDFLARE_URL
in my environment variables, so I can easily switch between development (direct S3) and production (Cloudflare CDN).
What I Learned the Hard Way
Cache headers matter. Initially, my images weren't being cached properly because I didn't set up the cache rules correctly.
The first request is still slow. That's expected - Cloudflare has to fetch from S3 the first time. After that, it's lightning fast.
ActiveStorage signed URLs don't work. If you're using Rails, you can't just use the built-in signed URLs because they have query parameters that mess with caching. That's why I built the custom helper.
Test with curl. This command helped me debug caching issues:
curl -I https://images.yourdomain.com/some-image.jpg
Look for cf-cache-status: HIT
in the response headers.
Worth the Effort !?
Absolutely. This took me about 2-3 hours to set up (including debugging time), and the performance improvement was immediate and dramatic. Plus, it's basically free - Cloudflare's free tier handles this just fine for most applications.
If you're serving images directly from S3, you should definitely do this. Your users will thank you, and your AWS bill will too.
Got questions about the setup? Hit me up in the comments. I probably ran into the same issue when I was figuring this out!
Top comments (0)