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.