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 delete or delete_prefixed call against a key not under <namespace>/dev/ is intercepted and returns false
  • 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:

  1. 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]
  2. Subsequent calls: Read the variant key directly from the source blob's metadatazero database queries

Properties:

  • Persistent and consistent across workers / deploys: the cache lives in the database metadata column, 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 varying N) 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_url semantics assume S3. Disk / GCS / Azure are not supported.
  • ActiveStorage.track_variants = true (Rails default): The variant cache mechanism depends on the VariantWithRecord flow. track_variants = false is not supported.
  • public_url assumes a publicly readable bucket: For private buckets, use ActiveStorage's native attachment.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.