Migrate from raw S3
Keep the bucket. Stop rewriting the same 400 lines
Presigned POST, a CORS rule, a uuid column, a cleanup job for the uploads that never completed, and no idea who uploaded what. It works. You have also written it three times.
Before and after
The same attachment, both ways
Raw S3
post = S3_BUCKET.presigned_post(
key: "uploads/ebad6b9e-61b8-4c0e-95f2-6bca021abe14/${filename}",
content_length_range: 0..25.megabytes,
acl: "private", success_action_status: "201"
)
# Plus: a CORS rule, a completion callback, a reaper for the
# uploads that never finished, and a column holding the key.
user.update!(avatar_key: post.fields["key"], avatar_bucket: BUCKET)FileHutch
# The policy already knows the types, the cap and the visibility.
upload = FileHutch.upload_policy("avatars").create_upload(filename: name)
user.update!(avatar_file_id: upload.file_id)Why people move
What changes, and why it matters
- The bucket name is in your database
- Every row that stores a key or a URL is a row that has to change when the bucket does. FileHutch stores the key internally and hands your app an ID.
- Nothing enforces what may be uploaded
- A presigned POST can carry conditions, and they are easy to get subtly wrong. An upload policy is a named object with types, a size cap and a visibility, and it is the same in every app.
- Orphans accumulate
- Uploads that started and never finished sit in the bucket forever unless you wrote the reaper. FileHutch finalises or expires every upload it issued.
- No transforms, no audit, no dashboard
- You either build them or do without. Most teams do without, then need them during an incident.
Side by side
Where each one is stronger
| Aspect | Raw S3 | FileHutch |
|---|---|---|
| Bucket | Yours | Still yours |
| Egress cost | S3 charges it | Same bucket, same bill — or move to R2, which does not |
| Upload rules | Presigned conditions per call site | A named policy |
| Incomplete uploads | Your reaper | Finalised or expired |
| Transforms | Build it | Named, at the edge |
| Audit | CloudTrail, if enabled | In the dashboard, per file |
| What your app stores | Bucket, key, region | file_8f2kq1x9 |
The move, in order
Every step is reversible until the last one.
-
1
Connect the bucket you already have
No copying. FileHutch reads and writes the same bucket — this is the one migration with no backfill.
-
2
Write the policies you have been enforcing by hand
Take the conditions out of your presigned POST calls and name them.
-
3
Import the keys you already store
Storage → Import existing files adopts a whole prefix and gives you a key-to-ID map; POST /api/v1/files/adopt does one key. Either way the object stays where it is.
-
4
Replace presigning with the SDK
One call per upload. The CORS rule is printed in the dashboard, so it is a copy and paste.
-
5
Drop the key and bucket columns
This is the moment the schema stops knowing about a storage provider.
What does not change
- The bucket, the region and the objects already in it
- Your existing object keys — import does not move them
- Direct browser uploads, still straight to S3
When to stay where you are
If you have one upload path, one bucket and no need for transforms or audit, presigned POST is about forty lines and costs nothing. Adding a service to replace forty lines is a bad trade.
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.