FileHutch

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. 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. 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. 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. 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. 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.