BetterStorage
A Rails ActiveStorage extension for the S3 service: predictable object paths, protection against accidental deletion of production files during local development, and zero-DB-query variant URL resolution.
English | 繁體中文
Why
| Pain point | Solution |
|---|---|
| 1. ActiveStorage uploads land at the bucket root with no structured path; multiple apps cannot share a bucket cleanly | namespace prefix with automatic date partitioning establishes a predictable path hierarchy and enables bucket sharing across applications |
| 2. Local development against production data risks deleting real production files when destroying records | Development uploads automatically receive a dev/ prefix; deletes against keys outside the dev path are intercepted |
| 3. Resolving a variant URL via ActiveStorage costs multiple database queries, becoming a bottleneck when rendering many derived images | Variant blob keys are persisted in the source blob's metadata; URL resolution reads from in-memory metadata with zero database queries |
Supported Versions
| Rails | Ruby |
|---|---|
| 7.1.x | 2.7+ |
| 7.2.x | 3.1+ |
| 8.0.x | 3.2+ |
| 8.1.x | 3.2+ |
Installation
# Gemfile
gem "better_storage"
bundle install
Configuration
Configure in config/initializers/better_storage.rb:
BetterStorage.configure do |config|
config.namespace = "my_app"
# config.s3_endpoint = "https://my-bucket.s3.region.amazonaws.com"
# config.protect_production_files = true
# config.prefix_date_format = "%Y/%m/%d"
end
Configuration fields
| Field | Default | Description |
|---|---|---|
namespace |
nil |
The outermost prefix applied to all upload paths. Trailing / is stripped automatically. |
s3_endpoint |
Auto-derived | The base URL used when assembling public URLs. Lazily derived from ActiveStorage::Blob.service.bucket.url on first read; explicit values take precedence. May be set to a CDN URL (e.g. CloudFront) so that public_url returns CDN-served paths directly. |
protect_production_files |
Rails.env.development? |
Enables the production file protection mechanism (see below). |
prefix_date_format |
"%Y%m" |
The strftime format used for date partitioning. Set to nil or false to disable date partitioning. |
Usage
Public URL
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [120, 120]
end
end
user = User.first
user.avatar.public_url # original
user.avatar.public_url(:thumb) # named variant
# For any ActiveStorage::Blob
blob.public_url
public_url concatenates s3_endpoint and the key directly without going through ActiveStorage's service_url signing flow. It is intended for publicly readable buckets. For private buckets, use ActiveStorage's native attachment.url to get a signed URL.
Upload paths
The blob key structure produced under namespace = "my_app" and prefix_date_format = "%Y%m":
| Environment | Key example |
|---|---|
| development | my_app/dev/202605/<token> |
| production | my_app/202605/<token> |
Variant blobs are independent ActiveStorage::Blob instances and follow the same prefix scheme.
Production file protection
When protect_production_files is true:
- Any
deleteordelete_prefixedcall against a key not under<namespace>/dev/is intercepted and returnsfalse - Enabled automatically when
Rails.env.development?returns true
Typical use case: developing locally with a snapshot of production data, where operations such as User#destroy_all would otherwise wipe production S3 objects.
To temporarily disable:
BetterStorage.config.protect_production_files = false
Variant URL caching
When an attachment's attachment.public_url(:style) is invoked:
- First call: Triggers ActiveStorage's normal variant processing flow and writes the resulting variant blob key into the source blob's
metadata["bs_variants"][variation_digest] - Subsequent calls: Read the variant key directly from the source blob's
metadata— zero database queries
Properties:
- Persistent and consistent across workers / deploys: the cache lives in the database
metadatacolumn, not in process-local memory - No manual invalidation needed: the variant blob key never changes, so the metadata entry remains valid
- Idempotent: writes are skipped when the digest entry already exists
Note: Dynamic transformations (e.g.
image.variant(resize_to_limit: [N, N])with varyingN) accumulate metadata entries on the source blob without bound. This is acceptable in practice — each entry is small (~150 bytes); 10000 entries ≈ 1.5MB. Named variants (the typical use case) are constant in size.
Assumptions / Limitations
- S3 service only: All protection mechanisms and
public_urlsemantics assume S3. Disk / GCS / Azure are not supported. ActiveStorage.track_variants = true(Rails default): The variant cache mechanism depends on theVariantWithRecordflow.track_variants = falseis not supported.public_urlassumes a publicly readable bucket: For private buckets, use ActiveStorage's nativeattachment.url(which produces signed URLs).- Protection only intercepts deletes: Upload, update, and other write operations are unaffected.
Development
bundle install
bundle exec rake test # default Rails version
bundle exec appraisal rails-7.1 rake test # specific Rails version
bundle exec rake coverage # merged coverage report across all 4 Rails versions
License
Released under the MIT License.