Cloudflare R2 + Rails: The S3-Compatible Storage That Won't Break the Bank
Cloudflare R2 + Rails: The S3-Compatible Storage That Won’t Break the Bank
Let’s be honest: AWS S3 is great, but those egress fees? They hurt. Especially when you’re building a side project or a startup and every dollar counts.
Enter Cloudflare R2 — an S3-compatible object storage with zero egress fees. Yes, you read that right. Zero. Nada. Nothing.
In this guide, I’ll show you how to wire up R2 with Rails’ ActiveStorage. And yes, there’s a gotcha with the AWS SDK version — I’ll save you the headache I went through.
Why R2?
- Zero egress fees — Download all you want, pay $0.
- S3-compatible API — Works with everything that supports S3.
- Built-in CDN — Global distribution out of the box.
- Simple pricing — No complicated tier structures.
Step 1: Create Your R2 Bucket
Head to your Cloudflare Dashboard and navigate to R2 → Create Bucket.
Name it something like my-app-dev for development and my-app-prod for production.
Configure CORS
In your bucket settings, add this CORS policy so your app can upload files:
1
2
3
4
5
6
7
[
{
"AllowedOrigins": ["http://localhost:3000"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"]
}
]
Step 2: Get Your API Credentials
- In R2, under Account Details, click Manage API Tokens → Create User API Token.
- Save your:
- Access Key ID
- Secret Access Key (shown only once — copy it now!)
- Grab your Account ID from the dashboard URL
Step 3: Add the Gem (⚠️ Important!)
Here’s where things get tricky. Add this to your Gemfile:
1
gem "aws-sdk-s3", "~> 1.100.0"
Why version 1.100.0? Because newer versions (1.120+) have a bug that causes them to send multiple checksums (MD5 + CRC32) to R2, and R2 rejects this with:
1
Aws::S3::Errors::InvalidRequest: You can only specify one checksum at a time.
This is a known Rails issue. For now, pin to ~> 1.100.0.
Run bundle install.
Step 4: Configure Storage
In config/storage.yml:
1
2
3
4
5
6
7
8
cloudflare:
service: S3
endpoint: https://<%= ENV["CLOUDFLARE_R2_ACCOUNT_ID"] %>.r2.cloudflarestorage.com
access_key_id: <%= ENV["CLOUDFLARE_R2_ACCESS_KEY_ID"] %>
secret_access_key: <%= ENV["CLOUDFLARE_R2_SECRET_ACCESS_KEY"] %>
bucket: <%= ENV["CLOUDFLARE_R2_BUCKET"] %>
region: auto
http_proxy: ~
Step 5: Set Environment Variables
Add to your .env:
1
2
3
4
CLOUDFLARE_R2_ACCOUNT_ID=your_account_id
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_key
CLOUDFLARE_R2_BUCKET=my-app-dev
Step 6: Update Rails Environments
In config/environments/development.rb:
1
config.active_storage.service = :cloudflare
Do the same for your production environment!
Bonus: Organize Files by Store (Multi-Tenant Apps)
If you’re building a multi-tenant app (like I did), you might want files organized by tenant. Here’s a trick using Rails’ built-in Current attributes:
Create app/models/current.rb:
1
2
3
class Current < ActiveSupport::CurrentAttributes
attribute :store_subdomain
end
Create a concern in app/controllers/concerns/active_storage_context.rb:
1
2
3
4
5
6
7
8
9
10
11
12
13
module ActiveStorageContext
extend ActiveSupport::Concern
included do
before_action :set_active_storage_context
end
private
def set_active_storage_context
Current.store_subdomain = current_store&.subdomain
end
end
Include it in your ApplicationController:
1
2
3
4
class ApplicationController < ActionController::Base
include ActiveStorageContext
# ...
end
Then create config/initializers/active_storage_service_patch.rb:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
require "active_storage/service/s3_service"
Rails.application.config.after_initialize do
module ActiveStorage
class Service::S3Service
alias_method :original_upload, :upload
# ... alias other methods
def upload(key, body, **)
original_upload(prefix_key(key), body, **)
end
private
def prefix_key(key)
store = ::Current.store_subdomain || "unknown"
date = Date.today.strftime("%Y/%m")
"#{store}/attachments/#{date}/#{key}"
end
end
end
end
Now your files will be stored as: oxxo/attachments/2026/02/abc123...
Testing It Out
Fire up your Rails server and upload a file. Check your R2 bucket — you should see the file there!
1
2
3
4
5
6
# Quick test in console
blob = ActiveStorage::Blob.create_and_upload!(
io: File.open("test.txt"),
filename: "test.txt"
)
puts blob.url
Verify Your Configuration
Before testing, make sure your environment variables are loaded:
1
2
# Check they're set
echo $CLOUDFLARE_R2_BUCKET
Test Upload from Rails Console
1
rails console
1
2
3
4
5
6
7
8
9
10
11
# Simple upload test
File.write('test_upload.txt', 'Hello from R2!')
blob = ActiveStorage::Blob.create_and_upload!(
io: File.open('test_upload.txt'),
filename: 'test_upload.txt',
content_type: 'text/plain'
)
puts "Upload successful!"
puts "Key: #{blob.key}"
puts "URL: #{blob.url}"
File.delete('test_upload.txt')
If everything worked, you should see output like:
1
2
3
Upload successful!
Key: abc123def456...
URL: https://your-bucket.your-account-id.r2.cloudflarestorage.com/abc123...
Check Your R2 Bucket
Log into your Cloudflare Dashboard → R2 → Your Bucket. You should see your uploaded file there!
Test Download
1
2
blob = ActiveStorage::Blob.last
puts blob.download.read
If you get the file content back, you’re all set!
Troubleshooting Common Issues
“Missing service adapter for S3”
- Make sure
aws-sdk-s3is in your Gemfile and runbundle install
“You can only specify one checksum at a time”
- You’re using a newer aws-sdk-s3 version. Pin to
~> 1.100.0
CORS errors in browser console
- Check your bucket’s CORS policy includes your origin
Files go to root instead of prefixed path
- Make sure the Current attribute is being set (check your controller includes the concern)
Wrapping Up
Cloudflare R2 + Rails is a solid combo. Zero egress fees alone make it a winner for any project where users download files (think: avatars, documents, images).
The only gotcha? That AWS SDK version. Remember: ~> 1.100.0.
Happy coding!
Questions? Find me on Twitter. I’d love to hear how R2 works out for you!