Changing your storage provider sounds terrifying. Hundreds of user avatars, uploaded PDFs, product images -- all sitting on one provider, and you need to move them to another without breaking a single link or losing a single file.
It turns out Rails has a built-in escape hatch for exactly this scenario: ActiveStorage::Service::MirrorService. In this post, I'll walk through how we migrated from DigitalOcean Spaces to Cloudflare R2 with zero downtime and zero data loss.
Why We Moved
DigitalOcean Spaces served us well, but Cloudflare R2 offered compelling advantages for our use case:
- No egress fees -- R2 charges nothing for bandwidth out, which matters when you're serving images through a Rails proxy
- Tighter Cloudflare integration -- our app already sits behind Cloudflare, so R2 means fewer network hops
- S3-compatible API -- both services speak the same protocol, making the migration purely a configuration change
The key insight: since both services are S3-compatible, Rails treats them identically. The migration is about data, not code.
The Secret Weapon: MirrorService
Rails' ActiveStorage::Service::MirrorService is designed for exactly this kind of transition. Here's how it works:
- You designate a primary service (where reads come from)
- You designate one or more mirrors (where writes also go)
- Every upload and delete is applied to all services
- Downloads only hit the primary
This means you can deploy the mirror, and from that moment forward, every new upload lands on both providers. The only problem left is the backlog of existing files.
Step 1: Define the Mirror
Both services were already in our config/storage.yml. We just needed to add a mirror that ties them together:
digitalocean:
service: S3
endpoint: <%= ENV["SPACES_ENDPOINT"] %>
access_key_id: <%= ENV["SPACES_ACCESS_KEY_ID"] %>
secret_access_key: <%= ENV["SPACES_SECRET_ACCESS_KEY"] %>
region: <%= ENV["SPACES_REGION"] %>
bucket: <%= ENV["SPACES_BUCKET"] %>
r2:
service: S3
endpoint: <%= ENV["R2_ENDPOINT"] %>
access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
region: <%= ENV["R2_REGION"] %>
bucket: <%= ENV["R2_BUCKET"] %>
migration_mirror:
service: Mirror
primary: digitalocean
mirrors:
- r2
The critical detail: primary: digitalocean. This means reads still come from DigitalOcean (where all existing files live), while writes fan out to both services. Your app keeps working exactly as before.
Step 2: Deploy the Mirror
One line change in config/environments/production.rb:
config.active_storage.service = :migration_mirror
Deploy this. From this moment, every new upload, variant generation, and deletion happens on both providers simultaneously. Users notice nothing.
Step 3: Copy the Backlog
New uploads are covered, but existing files only live on DigitalOcean. We need to copy them over. Here's the approach:
# In a rake task or console session
source_service = ActiveStorage::Blob.services.fetch(:digitalocean)
target_service = ActiveStorage::Blob.services.fetch(:r2)
blobs = ActiveStorage::Blob.where(service_name: "digitalocean")
total = blobs.count
blobs.find_each.with_index do |blob, index|
# Skip if already copied (makes the task resumable)
if target_service.exist?(blob.key)
puts "[#{index + 1}/#{total}] SKIP #{blob.key}"
next
end
source_service.open(blob.key, checksum: blob.checksum) do |file|
target_service.upload(blob.key, file, checksum: blob.checksum)
end
puts "[#{index + 1}/#{total}] COPIED #{blob.key}"
rescue => e
puts "[#{index + 1}/#{total}] ERROR #{blob.key}: #{e.message}"
end
A few things to note:
exist?check makes this idempotent. Run it twice, ten times -- it only copies what's missing.find_eachprocesses in batches of 1000, so memory stays flat regardless of how many blobs you have.- Error rescue per blob means one failure doesn't stop the whole migration. Re-run to retry failures.
For large datasets, you could parallelize this with Parallel.each or fan out into background jobs. For our few hundred files, a single sequential pass took under a minute.
Step 4: Validate
Before flipping the switch, verify that every single blob exists on the target:
target_service = ActiveStorage::Blob.services.fetch(:r2)
missing = []
ActiveStorage::Blob.where(service_name: "digitalocean").find_each do |blob|
missing << blob unless target_service.exist?(blob.key)
end
if missing.any?
puts "MISSING #{missing.size} blobs!"
missing.each { |b| puts " #{b.id}: #{b.filename} (#{b.key})" }
else
puts "All blobs present on R2. Safe to switch."
end
This is the safety net. Don't skip it.
Step 5: Switch Over
With everything validated, update the blob records and switch the service:
# Point all blob records to the new service
ActiveStorage::Blob
.where(service_name: "digitalocean")
.update_all(service_name: "r2")
Then deploy with the final config:
# config/environments/production.rb
config.active_storage.service = :r2
And clean up storage.yml by removing the digitalocean and migration_mirror entries.
Step 6: Don't Delete Anything (Yet)
This is important: leave the DigitalOcean files alone. They cost almost nothing to store, and they're your safety net if something was missed. After a week or two of running cleanly on R2, you can confidently clean up the old bucket.
The Timeline
Here's what our migration looked like in practice:
Step | Time | Downtime |
|---|---|---|
Add mirror config + deploy | 5 min | None |
Copy existing blobs | ~1 min | None |
Validate | ~30 sec | None |
Switch service_name + deploy | 5 min | None |
Total | ~12 min | Zero |
Gotchas We Encountered
Blob service_name column: Each ActiveStorage::Blob record has a service_name column that determines which service handles that blob. If you forget to update this column, blobs will try to read from the old service even after you switch the config. The update_all in Step 5 is not optional.
Variant records: Processed variants (thumbnails, resized images, etc.) are stored as separate files on the service but tracked through ActiveStorage::VariantRecord. They share the same blob key prefix, so the copy step handles them automatically -- if you're iterating over all blobs. Make sure your query doesn't accidentally filter them out.
Mirror direction matters: Setting primary: r2 (the new, empty service) as primary would break all existing file downloads immediately. Always make the current service the primary, and the new service the mirror.
Appendix: The Complete Rake Tasks
We wrapped all of this into a set of generic rake tasks. They work for any source and target service defined in storage.yml -- if we ever need to move from R2 to somewhere else, the same toolkit works out of the box.
rake storage:status # Show blob counts per service
rake storage:migrate[digitalocean,r2] # Copy blobs (skip existing)
rake storage:migrate[digitalocean,r2,dry_run] # Preview what would be copied
rake storage:validate[digitalocean,r2] # Verify all blobs exist on target
rake storage:switch[digitalocean,r2] # Update service_name column
rake storage:switch[digitalocean,r2,dry_run] # Preview the switch
Here's the full implementation in lib/tasks/storage.rake:
namespace :storage do
desc <<~DESC
Migrate Active Storage blobs from one service to another.
This uses Rails' built-in MirrorService to copy files safely.
No files are deleted -- the source service is left untouched.
Prerequisites:
1. Both services must be defined in config/storage.yml
2. A mirror service must exist with the source as primary
and the target in mirrors (or define one inline)
Usage:
rake storage:migrate[digitalocean,r2] # Copy all blobs
rake storage:migrate[digitalocean,r2,dry_run] # Preview only
DESC
task :migrate, %i[source target dry_run] => :environment do |_t, args|
source_name = args[:source]
target_name = args[:target]
dry_run = args[:dry_run] == "dry_run"
abort "Usage: rake storage:migrate[source_service,target_service]" if source_name.blank? || target_name.blank?
source_service = ActiveStorage::Blob.services.fetch(source_name.to_sym) do
abort "Unknown service: #{source_name}. Check config/storage.yml"
end
target_service = ActiveStorage::Blob.services.fetch(target_name.to_sym) do
abort "Unknown service: #{target_name}. Check config/storage.yml"
end
blobs = ActiveStorage::Blob.where(service_name: source_name)
total = blobs.count
if total.zero?
puts "No blobs found with service_name='#{source_name}'. Nothing to migrate."
next
end
puts "#{'[DRY RUN] ' if dry_run}Migrating #{total} blobs: #{source_name} -> #{target_name}"
puts ""
migrated = 0
skipped = 0
errors = []
blobs.find_each.with_index do |blob, index|
prefix = "[#{index + 1}/#{total}]"
if target_service.exist?(blob.key)
skipped += 1
puts "#{prefix} SKIP #{blob.key} (already exists on #{target_name})"
next
end
if dry_run
puts "#{prefix} WOULD COPY #{blob.key} (#{blob.byte_size} bytes)"
migrated += 1
next
end
begin
source_service.open(blob.key, checksum: blob.checksum) do |file|
target_service.upload(blob.key, file, checksum: blob.checksum)
end
migrated += 1
puts "#{prefix} COPIED #{blob.key} (#{blob.byte_size} bytes)"
rescue => e
errors << { blob_id: blob.id, key: blob.key, error: e.message }
puts "#{prefix} ERROR #{blob.key}: #{e.message}"
end
end
puts ""
puts "=" * 60
puts "Migration #{'(dry run) ' if dry_run}complete:"
puts " Copied: #{migrated}"
puts " Skipped: #{skipped} (already on #{target_name})"
puts " Errors: #{errors.size}"
if errors.any?
puts ""
puts "Failed blobs:"
errors.each do |err|
puts " Blob ##{err[:blob_id]} (#{err[:key]}): #{err[:error]}"
end
puts ""
puts "Re-run the task to retry failed blobs."
end
end
desc <<~DESC
Validate that all blobs from a source service exist on the target service.
Checks every blob's key exists on the target without downloading content.
Use this after migration to confirm all files were copied before switching.
Usage:
rake storage:validate[digitalocean,r2]
DESC
task :validate, %i[source target] => :environment do |_t, args|
source_name = args[:source]
target_name = args[:target]
abort "Usage: rake storage:validate[source_service,target_service]" if source_name.blank? || target_name.blank?
target_service = ActiveStorage::Blob.services.fetch(target_name.to_sym) do
abort "Unknown service: #{target_name}. Check config/storage.yml"
end
blobs = ActiveStorage::Blob.where(service_name: source_name)
total = blobs.count
if total.zero?
puts "No blobs found with service_name='#{source_name}'. Nothing to validate."
next
end
puts "Validating #{total} blobs exist on #{target_name}..."
puts ""
missing = []
blobs.find_each.with_index do |blob, index|
prefix = "[#{index + 1}/#{total}]"
if target_service.exist?(blob.key)
puts "#{prefix} OK #{blob.key}"
else
missing << { blob_id: blob.id, key: blob.key, filename: blob.filename.to_s }
puts "#{prefix} MISSING #{blob.key} (#{blob.filename})"
end
end
puts ""
puts "=" * 60
puts "Validation complete: #{total - missing.size}/#{total} blobs present on #{target_name}"
puts ""
if missing.any?
puts "Missing blobs (#{missing.size}):"
missing.each do |m|
puts " Blob ##{m[:blob_id]} #{m[:filename]} (#{m[:key]})"
end
puts ""
puts "Run `rake storage:migrate[#{source_name},#{target_name}]` to copy missing blobs."
abort "Validation failed: #{missing.size} blob(s) missing on #{target_name}"
else
puts "All blobs present on #{target_name}. Safe to switch."
end
end
desc <<~DESC
Switch blob records from one service to another.
Updates the service_name column on all matching blobs.
Run this ONLY after `storage:validate` confirms all files are copied.
This does NOT delete files from the source -- they remain as a backup.
Usage:
rake storage:switch[digitalocean,r2] # Switch all blobs
rake storage:switch[digitalocean,r2,dry_run] # Preview only
DESC
task :switch, %i[source target dry_run] => :environment do |_t, args|
source_name = args[:source]
target_name = args[:target]
dry_run = args[:dry_run] == "dry_run"
abort "Usage: rake storage:switch[source_service,target_service]" if source_name.blank? || target_name.blank?
# Verify target service exists
ActiveStorage::Blob.services.fetch(target_name.to_sym) do
abort "Unknown service: #{target_name}. Check config/storage.yml"
end
blobs = ActiveStorage::Blob.where(service_name: source_name)
total = blobs.count
if total.zero?
puts "No blobs found with service_name='#{source_name}'. Nothing to switch."
next
end
if dry_run
puts "[DRY RUN] Would switch #{total} blobs: service_name '#{source_name}' -> '#{target_name}'"
next
end
puts "Switching #{total} blobs: service_name '#{source_name}' -> '#{target_name}'"
updated = blobs.update_all(service_name: target_name)
puts "Done. Updated #{updated} blob records."
puts ""
puts "Next steps:"
puts " 1. Update config.active_storage.service to :#{target_name} in production.rb"
puts " 2. Remove the '#{source_name}' entry from config/storage.yml"
puts " 3. Remove the 'migration_mirror' entry from config/storage.yml"
puts " 4. Source files on #{source_name} are NOT deleted -- clean up manually when ready"
end
desc <<~DESC
Show a summary of blob counts per service.
Usage:
rake storage:status
DESC
task status: :environment do
puts "Active Storage blob counts by service:"
puts ""
counts = ActiveStorage::Blob.group(:service_name).count
if counts.empty?
puts " No blobs found."
else
counts.sort_by { |_, v| -v }.each do |service_name, count|
puts " #{service_name}: #{count} blobs"
end
puts ""
puts " Total: #{counts.values.sum} blobs"
end
end
end
Key Takeaways
- Rails has your back:
MirrorServiceis built for exactly this. You don't need third-party gems or custom middleware. - Make it resumable: The
exist?check before each copy means you can safely re-run after failures. - Validate before switching: A two-minute validation pass saves you from a bad day.
- Never delete the source: Storage is cheap. Keep the old files as a safety net until you're confident.
- Keep it generic: Today it's DigitalOcean to R2. Tomorrow it might be R2 to S3. Build the tooling once.
The scariest part of changing storage providers isn't the technical work -- it's the fear of losing data. By using Rails' mirroring, validating thoroughly, and never deleting the source, you remove that fear entirely.
