Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 033 / 100

Active Storage: Ruby on Rails best practices explained

File uploads and attachments.

Expona Admin8 min read

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:

ruby
class User < ApplicationRecord
  has_one_attached :avatar
  
  validates :avatar, presence: true, content_type: 'image/png',
                     size: { less_than: 5.megabytes }
end

Active 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:

ruby
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
end

In your form, use a standard file input:

ruby
<%= form_with model: @user, local: true do |form| %>
  <%= form.file_field :avatar %>
  <%= form.submit %>
<% end %>

For multiple files, say a product gallery:

ruby
class Product < ApplicationRecord
  has_many_attached :images
  
  validates :images, presence: true,
                     content_type: ['image/png', 'image/jpeg'],
                     size: { less_than: 10.megabytes }
end

In the controller, permit the array:

ruby
def product_params
  params.require(:product).permit(:name, :description, images: [])
end

Accessing the attachment is simple. In your view:

ruby
<%= 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:

ruby
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-bucket

Then in config/environments/production.rb:

ruby
config.active_storage.service = :amazon

And in development:

ruby
config.active_storage.service = :local

This 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:

ruby
bin/rails active_storage:migrations:run
bin/rails active_storage:migrate:to amazon
bin/rails active_storage:purge_unattached

We 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):

ruby
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
end

In your view:

ruby
<%= 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 ratio
  • resize_to_fill: [width, height] — crop to exact dimensions, maintain aspect ratio
  • resize_to_fit: [width, height] — scale to fit within bounds
  • quality: 80 — reduce JPEG quality for faster downloads
  • format: :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:

ruby
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
end

In 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:

ruby
# config/storage.yml
amazon:
  service: S3
  access_key_id: ...
  secret_access_key: ...
  bucket: my-app-bucket

In your model, declare the attachment:

ruby
class Document < ApplicationRecord
  has_one_attached :file
  
  validates :file, presence: true, size: { less_than: 100.megabytes }
end

Install the JavaScript package:

ruby
bin/importmap pin @rails/activestorage

In your view, use the direct_upload option:

ruby
<%= 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:

ruby
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:

ruby
<!-- 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:

ruby
<!-- 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:

ruby
# 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:

ruby
class User < ApplicationRecord
  has_one_attached :avatar
  
  before_update :purge_old_avatar, if: :avatar_changed?

  private

  def purge_old_avatar
    avatar.purge_later
  end
end

Ignoring 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_attached or has_many_attached to declare attachments on models, and validate content type and size at the model level.
  • Configure multiple storage services in config/storage.yml and 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles