So here's the thing, I had this Rails app where images were taking forever to load. Users were complaining, and my AWS bill was climbing. After some research, I set up Cloudflare CDN in front of my S3 bucket, and wowβthe difference was night and day.
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
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.
Choose your region based on where most of your users are, or just pick the one closest to you.
Step 2: The Bucket Policy
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:
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
imagesorcdn - 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:
- Select Cache everything Template
- 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 (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
Go to Rule β Cloud Connector and click Create Cloud Connector.
Select AWS S3 since that's what we're using.
For the hostname pattern, use a wildcard like images.yourdomain.com/* since you'll be serving different images from various paths.
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:
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.
References
Read the full article with images on dev.to
