Migrate from Active Storage
One string column instead of three tables
Active Storage is fine until you want a file to outlive the app that uploaded it. FileHutch keeps the Rails ergonomics and drops the schema, the redirect hop and the variant records.
Before and after
The same attachment, both ways
Active Storage
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [256, 256]
end
end
# Three tables, and a redirect through Rails for every render.
image_tag user.avatar.variant(:thumb)FileHutch
class User < ApplicationRecord
has_hutch :avatar, policy: "avatars"
end
# users.avatar_file_id holds "file_8f2kq1x9". Nothing else.
image_tag user.avatar_transform_url("avatar")Why people move
What changes, and why it matters
- Three tables for one attachment
- active_storage_blobs, active_storage_attachments and active_storage_variant_records, plus a polymorphic join, to record that a user has an avatar. FileHutch is one string column on users.
- Delivery goes through your app
- rails_blob_url redirects through a Rails process for every image on the page. FileHutch delivery is a redirect from a short URL straight to the bucket, and transforms are served at the edge.
- URL expiry is a global setting
- config.active_storage.urls_expire_in applies to every attachment in the app. FileHutch sets TTL per upload policy, so invoices and avatars do not have to agree.
- Nothing records who did it
- There is no audit trail, and nothing distinguishes an upload your background job made from one a user made. Every FileHutch action carries the user or API key behind it.
Side by side
Where each one is stronger
| Aspect | Active Storage | FileHutch |
|---|---|---|
| Schema | 3 tables + polymorphic join | 1 string column |
| Delivery | Redirect through your app | Redirect to the bucket |
| Variants | Processed in your process, on request | Named transforms at the edge |
| Signed URL TTL | One global setting | Per upload policy |
| Audit | None | Every upload, delete and signed URL |
| Moving provider | Re-upload; keys are in your database | Settings change; IDs never move |
| Runs without a network | Yes, with the disk service | Yes, with the local adapter |
The move, in order
Every step is reversible until the last one.
-
1
Install the gem and create a policy
bin/rails generate file_hutch:install, then an avatars policy in the dashboard — or let your agent do both.
-
2
Add the column beside the attachment
Keep has_one_attached where it is. Add avatar_file_id next to it so both can be read during the move.
-
3
Backfill in the background
bin/rails file_hutch:backfill MODEL=User FROM=avatar streams each blob across and writes the ID; it fills only blank rows, so it is safe to re-run. If the Active Storage bucket is connected, ADOPT_FROM=conn_… registers the blobs in place instead.
-
4
Read from the new column, fall back to the old
One helper that prefers avatar_file_id and falls back to the attachment. Deploy it and watch the fallback rate go to zero.
-
5
Drop the attachment and the tables
Once the fallback is unused, remove has_one_attached and purge. The Active Storage tables go with it.
What does not change
- Your model API — an attachment is still one line on the class
- Direct browser uploads, still direct to the bucket
- Your bucket, if you were already using S3 or R2
When to stay where you are
Active Storage is free and in the box, and it processes variants with libvips on your own machine. If you have one small app, no compliance requirement and no plan to change storage, it is the right answer and you should keep it.
Try the move on one attachment first.
The free plan is enough to migrate a single model and see whether the rest is worth it.