Rails Active Storage Expert
Implement file uploads, attachments, image processing, and cloud storage in Rails applications using Active Storage.
Philosophy
Core Principles:
- Always run the install generator — Active Storage needs its migrations and config
- Use libvips over ImageMagick — 10x faster, 1/10 memory usage
- Prevent N+1 queries — Always use
with_attached_<name>scopes - Handle missing attachments — Never assume an attachment exists
- Proxy in production, redirect in development — Proxy mode works with CDNs
- Use named variants — Define variants on the model, not in views
When To Use This Skill
- Adding file uploads to a Rails model
- Configuring cloud storage (S3, GCS, Azure)
- Implementing image resizing/transformation (variants)
- Setting up direct uploads from the browser
- Debugging attachment issues (missing files, broken variants)
- Migrating between storage services
- Testing file uploads
Instructions
Step 1: Verify Setup
ALWAYS check if Active Storage is installed first:
# Check for Active Storage tables
bin/rails runner "puts ActiveStorage::Blob.table_exists?"
# Check for storage.yml
ls config/storage.yml
# Check for Active Storage migrations
ls db/migrate/*active_storage*
If not installed:
bin/rails active_storage:install
bin/rails db:migrate
This creates three tables:
active_storage_blobs— file metadata (filename, content_type, byte_size, checksum)active_storage_attachments— polymorphic join table connecting models to blobsactive_storage_variant_records— tracks generated variants
Verify image processing is available:
# Check for libvips (preferred)
vips --version
# Or ImageMagick (fallback)
convert --version
Add to Gemfile if needed:
gem "image_processing", "~> 1.2" # Required for variants
Step 2: Configure Storage Service
config/storage.yml — Define available services:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
Set the active service per environment:
# config/environments/development.rb
config.active_storage.service = :local
# config/environments/test.rb
config.active_storage.service = :test
# config/environments/production.rb
config.active_storage.service = :amazon # or :google, :azure
For cloud services, see reference.md for S3, GCS, and Azure configuration.
Step 3: Declare Attachments on Models
Single file attachment:
class User < ApplicationRecord
has_one_attached :avatar
end
Multiple file attachments:
class Message < ApplicationRecord
has_many_attached :images
end
With named variants (ALWAYS do this):
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [100, 100]
attachable.variant :medium, resize_to_limit: [300, 300]
attachable.variant :large, resize_to_limit: [800, 800]
end
end
With a specific storage service:
class User < ApplicationRecord
has_one_attached :avatar, service: :s3
end
Step 4: Handle Attachments in Controllers
Strong parameters — permit attachment params:
# has_one_attached
def user_params
params.expect(user: [:name, :email, :avatar])
end
# has_many_attached
def message_params
params.expect(message: [:title, :content, images: []])
end
Attaching files programmatically:
# From an upload
user.avatar.attach(params[:avatar])
# From an IO object
user.avatar.attach(
io: File.open("/path/to/file.jpg"),
filename: "avatar.jpg",
content_type: "image/jpeg"
)
# From a URL (download first)
user.avatar.attach(
io: URI.open("https://example.com/photo.jpg"),
filename: "photo.jpg"
)
Step 5: Display Attachments in Views
ALWAYS check if attached before rendering:
<%# WRONG — will raise if no avatar %>
<%= image_tag user.avatar %>
<%# CORRECT — guard against missing attachment %>
<%= image_tag user.avatar if user.avatar.attached? %>
<%# BEST — use variant with guard %>
<% if user.avatar.attached? %>
<%= image_tag user.avatar.variant(:thumb) %>
<% end %>
Use named variants (not inline transforms):
<%# WRONG — variant defined in view %>
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
<%# CORRECT — named variant from model %>
<%= image_tag user.avatar.variant(:thumb) %>
Step 6: Prevent N+1 Queries
This is the #1 performance mistake with Active Storage.
# WRONG — N+1 query for each user's avatar
@users = User.all
# In view: user.avatar triggers a query per user
# CORRECT — eager load attachments
@users = User.with_attached_avatar
# For has_many_attached
@messages = Message.with_attached_images
When rendering variants, also load variant records:
# In view, after eager loading attachments:
message.images.with_all_variant_records.each do |image|
image_tag image.variant(:thumb).processed.url
end
Step 7: Form Setup
Simple file upload:
<%= form_with model: @user do |form| %>
<%= form.file_field :avatar %>
<%= form.submit %>
<% end %>
Multiple files:
<%= form_with model: @message do |form| %>
<%= form.file_field :images, multiple: true %>
<%= form.submit %>
<% end %>
Preserve existing has_many_attached files on update:
<%# Without this, updating replaces ALL existing attachments %>
<% @message.images.each do |image| %>
<%= form.hidden_field :images, multiple: true, value: image.signed_id %>
<% end %>
<%= form.file_field :images, multiple: true %>
Direct uploads (upload to cloud before form submit):
<%= form.file_field :avatar, direct_upload: true %>
Requires Active Storage JavaScript. See reference.md for setup.
Step 8: Removing Attachments
# Synchronous — deletes blob + file from storage immediately
user.avatar.purge
# Async — deletes via background job (preferred in controllers)
user.avatar.purge_later
Allow users to remove attachments via a checkbox:
<% if @user.avatar.attached? %>
<%= image_tag @user.avatar.variant(:thumb) %>
<%= form.check_box :remove_avatar, label: "Remove avatar" %>
<% end %>
# In controller or model
after_save :purge_avatar, if: -> { saved_change_to_attribute?(:remove_avatar) }
# Or handle in controller:
def update
@user.update(user_params)
@user.avatar.purge_later if params[:user][:remove_avatar] == "1"
end
Step 9: Testing
Use file_fixture_upload in integration tests:
class UsersControllerTest < ActionDispatch::IntegrationTest
test "creates user with avatar" do
post users_path, params: {
user: {
name: "Test",
avatar: file_fixture_upload("avatar.png", "image/png")
}
}
user = User.order(:created_at).last
assert user.avatar.attached?
end
end
Place test files in test/fixtures/files/.
Clean up uploaded files after tests:
# test/test_helper.rb or application_system_test_case.rb
class ActionDispatch::IntegrationTest
def after_teardown
super
FileUtils.rm_rf(ActiveStorage::Blob.service.root)
end
end
For parallel tests, isolate storage per process:
class ActiveSupport::TestCase
parallelize_setup do |i|
ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{i}"
end
end
See reference.md for fixture-based attachment testing.
Common Agent Mistakes
1. Forgetting the install generator
Active Storage won't work without its migrations. Always run bin/rails active_storage:install and bin/rails db:migrate.
2. Missing image processing dependency
Variants require gem "image_processing" AND either libvips or ImageMagick installed on the system. Without these, variant() calls will raise.
3. Wrong variant processor syntax
# WRONG — MiniMagick syntax with Vips processor
variant(resize: "100x100")
# CORRECT — Vips syntax (Rails 7+ default)
variant(resize_to_limit: [100, 100])
4. Not handling missing attachments
# WILL RAISE if no avatar attached
user.avatar.variant(:thumb)
# SAFE
user.avatar.variant(:thumb) if user.avatar.attached?
5. N+1 queries with attachments
Always use with_attached_<name> scope when loading collections. This is the most common performance issue.
6. Defining variants in views instead of models
Variants defined inline in views are hard to maintain and can't be preprocessed. Define them on the model using the block syntax.
7. Replacing instead of appending with has_many_attached
By default, attaching new files to has_many_attached replaces all existing attachments. Use hidden fields with signed_id to preserve existing ones.
Quick Reference
Variant Transforms (Vips — Rails 7+ default)
| Transform | Syntax | Description |
|-----------|--------|-------------|
| Resize to fit | resize_to_limit: [w, h] | Shrinks to fit within bounds, preserves aspect ratio |
| Resize to fill | resize_to_fill: [w, h] | Fills bounds exactly, crops excess |
| Resize and pad | resize_to_fit: [w, h] | Fits within bounds, may be smaller |
| Resize exact | resize_and_pad: [w, h] | Fits within bounds, pads to exact size |
| Convert format | format: :webp | Convert to another format |
| Quality | saver: { quality: 80 } | Set output quality |
| Rotate | rotate: 90 | Rotate by degrees |
Key Methods
# Attachment status
record.avatar.attached? # => true/false
record.avatar.blank? # => true/false (opposite of attached?)
# File metadata
record.avatar.filename # => "avatar.png"
record.avatar.content_type # => "image/png"
record.avatar.byte_size # => 12345
record.avatar.blob.metadata # => {"identified"=>true, "width"=>100, "height"=>100, "analyzed"=>true}
# URLs
url_for(record.avatar) # Redirect URL
rails_blob_path(record.avatar) # Path helper
rails_storage_proxy_path(record.avatar) # Proxy URL
# Downloading
record.avatar.download # => binary string
record.avatar.open { |f| ... } # => yields Tempfile
# Purging
record.avatar.purge # Sync delete
record.avatar.purge_later # Async delete
Configuration Options
# config/application.rb or environment files
config.active_storage.variant_processor = :vips # :vips (default) or :mini_magick
config.active_storage.resolve_model_to_route = :rails_storage_proxy # Enable proxy mode
config.active_storage.draw_routes = false # Disable default routes (for custom auth)
config.active_storage.track_variants = true # Track variant records (default)
config.active_storage.service = :local # Set storage service