Introduction
File uploads are one of those features that sounds straightforward until you're managing terabytes of user avatars, product images, and documents in production. Active Storage: Ruby on Rails best practices explained gives us a robust, batteries-included framework for handling attachments without the complexity of rolling our own solution. Whether you're storing files locally during development or pushing them to S3 in production, Active Storage abstracts away the infrastructure details so we can focus on building features.
Over the last decade, we've learned that hardcoding file paths and managing permissions manually leads to security holes, missing files, and costly refactoring. Active Storage was built to solve exactly those problems. In this article, we'll walk through attaching files to models, configuring storage services, resizing images on demand, and letting browsers upload directly to your cloud storage without hitting your Rails server.
Attach Files — has_one_attached and has_many_attached
The foundation of Active Storage starts with declaring an attachment on your model. Rails gives us two macros: has_one_attached for single files and has_many_attached for collections.
Let's say we're building a SaaS product where users upload a profile avatar. Here's the model setup:
class User < ApplicationRecord
has_one_attached :avatar
validates :avatar, presence: true, content_type: 'image/png',
size: { less_than: 5.megabytes }
endActive Storage automatically creates a relationship between your User model and the underlying ActiveStorage::Attachment and ActiveStorage::Blob records. When you save a file, Rails stores metadata in the database and the actual bytes in your configured service (disk, S3, etc.).
In your controller, you'd accept the file like any other parameter:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
if @user.update(user_params)
redirect_to @user, notice: 'Avatar updated'
else
render :edit
end
end
private
def user_params
params.require(:user).permit(:name, :email, :avatar)
end
endIn your form, use a standard file input:
<%= form_with model: @user, local: true do |form| %>
<%= form.file_field :avatar %>
<%= form.submit %>
<% end %>For multiple files, say a product gallery:
class Product < ApplicationRecord
has_many_attached :images
validates :images, presence: true,
content_type: ['image/png', 'image/jpeg'],
size: { less_than: 10.megabytes }
endIn the controller, permit the array:
def product_params
params.require(:product).permit(:name, :description, images: [])
endAccessing the attachment is simple. In your view:
<%= image_tag @user.avatar, alt: 'Avatar' if @user.avatar.attached? %>
<% @product.images.each do |image| %>
<%= image_tag image, alt: 'Product image' %>
<% end %>Services — Local Disk in Development, S3 in Production
Active Storage's genius is that it decouples your application logic from where files actually live. In config/storage.yml, you define multiple services and switch between them based on environment.
By default, Rails generates this:
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: us-east-1
bucket: my-app-bucketThen in config/environments/production.rb:
config.active_storage.service = :amazonAnd in development:
config.active_storage.service = :localThis means developers can test uploads locally without AWS credentials, and production seamlessly pushes to S3. When you need to migrate from disk to S3, or between buckets, Rails provides rake tasks:
bin/rails active_storage:migrations:run
bin/rails active_storage:migrate:to amazon
bin/rails active_storage:purge_unattachedWe typically store AWS credentials in Rails encrypted credentials (RAILS_MASTER_KEY) or environment variables, never in the repository. In a team environment, each developer can have their own .env.local with test AWS credentials pointing to a shared dev bucket.
Variants — Resize Images on Demand
One of the biggest file storage headaches is managing multiple image sizes. You upload a 4MB photo, but on mobile you need a 200px thumbnail and on desktop a 1200px card image. Without variants, you'd either resize during upload or store multiple copies yourself.
Active Storage's variants generate resized versions on-the-fly using ImageMagick (via the image_processing gem):
class User < ApplicationRecord
has_one_attached :avatar
def avatar_thumbnail
avatar.variant(resize_to_limit: [200, 200]).processed
end
def avatar_display
avatar.variant(resize_to_fill: [1200, 400]).processed
end
endIn your view:
<%= image_tag @user.avatar_thumbnail, alt: 'Thumbnail' %>
<%= image_tag @user.avatar_display, alt: 'Full size' %>The first access to a variant triggers processing, then Rails caches the result. Subsequent requests serve the cached variant. The original image remains untouched.
Common variant options include:
resize_to_limit: [width, height]— scale down if larger, maintain aspect ratioresize_to_fill: [width, height]— crop to exact dimensions, maintain aspect ratioresize_to_fit: [width, height]— scale to fit within boundsquality: 80— reduce JPEG quality for faster downloadsformat: :webp— convert to modern formats for better compression
For a real-world example, imagine an e-commerce platform with product images. You'd define variants once and use them everywhere:
class Product < ApplicationRecord
has_many_attached :images
scope :with_thumbnail, -> { includes(images_attachments: :blob) }
def thumbnail
images.first&.variant(resize_to_limit: [300, 300]).processed
end
def hero_image
images.first&.variant(resize_to_fill: [1920, 600]).processed
end
def product_card
images.first&.variant(resize_to_limit: [500, 500], quality: 80).processed
end
endIn production, Rails stores variants in the same service as the original (S3 in our example). If a variant is deleted or corrupted, regenerating it is just a database query away—no lost originals.
Direct Uploads — Browser to Storage, Bypassing Rails
For large files or high-traffic applications, sending uploads through your Rails server is wasteful. Direct uploads let the browser upload straight to S3 (or your configured service) while Rails handles the database bookkeeping.
First, ensure your storage service supports direct uploads. S3 does; local disk doesn't:
# config/storage.yml
amazon:
service: S3
access_key_id: ...
secret_access_key: ...
bucket: my-app-bucketIn your model, declare the attachment:
class Document < ApplicationRecord
has_one_attached :file
validates :file, presence: true, size: { less_than: 100.megabytes }
endInstall the JavaScript package:
bin/importmap pin @rails/activestorageIn your view, use the direct_upload option:
<%= form_with model: @document, local: true do |form| %>
<%= form.file_field :file, direct_upload: true %>
<%= form.submit %>
<% end %>Add JavaScript to handle the upload and show progress:
import * as ActiveStorage from '@rails/activestorage'
ActiveStorage.start()
document.addEventListener('direct-upload:initialize', event => {
const { target } = event.detail
target.parentElement.insertAdjacentHTML('beforeend',
`<div class="upload-progress" id="upload-${target.id}"></div>`)
})
document.addEventListener('direct-upload:progress', event => {
const { loaded, total, xhr } = event.detail
const progress = (loaded / total * 100)
document.getElementById(`upload-${event.target.id}`).style.width = progress + '%'
})Behind the scenes, Rails generates temporary credentials for the browser to upload to S3 securely. Once uploaded, the blob is linked to your model on form submission.
This pattern is invaluable for video uploads, large datasets, or file sharing apps. Your Rails server isn't bottlenecked by upload bandwidth, and users see immediate feedback. In trading or financial systems where we deal with large CSV imports, direct uploads have cut average upload times from 45 seconds to 8 seconds.
Common Mistakes
Forgetting to check attachment presence in views. Always use if attachment.attached? before rendering, or you'll get nil errors:
<!-- Bad -->
<%= image_tag @user.avatar %>
<!-- Good -->
<%= image_tag @user.avatar if @user.avatar.attached? %>Not including eager-loading in queries. Accessing attachments in loops without includes triggers N+1 queries:
<!-- Bad -->
<% @products.each do |product| %>
<%= image_tag product.images.first %>
<% end %>
<!-- Good -->
<% @products.includes(images_attachments: :blob).each do |product| %>
<%= image_tag product.images.first %>
<% end %>Skipping validation on file uploads. Content type and size validation should always happen on the server, even with direct uploads:
# Validate on model, not just form
validates :avatar, content_type: 'image/png', size: { less_than: 5.megabytes }Not purging old attachments. When users update their avatar, the old blob isn't automatically deleted. Use purge_later in a callback:
class User < ApplicationRecord
has_one_attached :avatar
before_update :purge_old_avatar, if: :avatar_changed?
private
def purge_old_avatar
avatar.purge_later
end
endIgnoring image processing dependencies. Variants require ImageMagick or libvips. Ensure these are installed in your production environment, or variants will fail silently.
Key Takeaways
- Use
has_one_attachedorhas_many_attachedto declare attachments on models, and validate content type and size at the model level. - Configure multiple storage services in
config/storage.ymland switch between them by environment (local for development, S3 for production). - Define image variants once to generate thumbnails and display sizes on-the-fly, with transparent caching so variants aren't reprocessed.
- Use direct uploads to let browsers push files straight to S3, reducing server load and improving user experience for large files.
- Always check
attachment.attached?in views, eager-load attachments in queries, and purge old attachments in callbacks to avoid bloat. - Validate file uploads on the server side, even with direct uploads, and ensure image processing libraries are available in production.






Comments
Loading comments…