/ Tags: RAILS / Categories: RAILS

Active Storage — File Uploads and Attachments in Rails Without the Complexity

File uploads have a reputation for being the feature that always requires a gem, a CDN configuration, and half a day of setup. Active Storage, built into Rails 5.2+, changes that. It handles single and multiple attachments, image variants, direct uploads to cloud storage, and streaming — all with a model-friendly API that fits naturally into the Rails way of doing things.

Setup


Active Storage needs its own migrations to create three tables (active_storage_blobs, active_storage_attachments, active_storage_variant_records).

Setup:

bin/rails active_storage:install
bin/rails db:migrate

Setup:

# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

amazon:
  service: S3
  access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %>
  secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %>
  region: us-east-1
  bucket: <%= ENV["AWS_BUCKET"] %>
# config/environments/production.rb
config.active_storage.service = :amazon

# config/environments/development.rb
config.active_storage.service = :local

For S3, add gem "aws-sdk-s3" to your Gemfile. Active Storage supports S3, GCS, Azure Blob, and local disk with the same model API.

Attaching Files to Models


Example:

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar
  has_many_attached :documents
end

# app/models/article.rb
class Article < ApplicationRecord
  has_one_attached :cover_image
  has_many_attached :attachments
end

Example:

# Attaching in a controller
def update
  @user = User.find(params[:id])
  @user.avatar.attach(params[:avatar])
  # or via update with permitted params
  @user.update(user_params)
end

def user_params
  params.require(:user).permit(:name, :email, :avatar, documents: [])
end
<!-- app/views/users/_form.html.erb -->
<%= form_with model: @user do |f| %>
  <%= f.file_field :avatar %>
  <%= f.file_field :documents, multiple: true %>
<% end %>

Displaying and Linking Attachments


Example:

# Check if attached
@user.avatar.attached?   # => true / false

# Generate a URL
url_for(@user.avatar)              # => signed URL (expires by default)
rails_blob_path(@user.avatar)      # => path helper
rails_blob_url(@user.avatar)       # => full URL helper
<!-- Display an image -->
<% if @user.avatar.attached? %>
  <%= image_tag @user.avatar %>
<% end %>

<!-- Link to download a document -->
<% @user.documents.each do |doc| %>
  <%= link_to doc.filename, rails_blob_path(doc, disposition: "attachment") %>
<% end %>

Image Variants — On-Demand Resizing


Active Storage integrates with ImageMagick (via image_processing gem) to generate transformed variants on demand. The variant is generated once and cached on subsequent requests.

Setup:

# Gemfile
gem "image_processing", "~> 1.2"

Example:

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar

  def avatar_thumbnail
    avatar.variant(resize_to_fill: [100, 100])
  end

  def avatar_preview
    avatar.variant(resize_to_limit: [400, 400], format: :webp)
  end
end
<!-- Trigger variant generation on display -->
<%= image_tag @user.avatar.variant(resize_to_fill: [80, 80]) %>

<!-- Or use the model method -->
<%= image_tag @user.avatar_thumbnail %>

Example:

# Named variants (Rails 7.1+) — define once, reference anywhere
class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb,  resize_to_fill: [100, 100]
    attachable.variant :medium, resize_to_limit: [400, 400], format: :webp
    attachable.variant :large,  resize_to_limit: [800, 800]
  end
end

# In views
image_tag @user.avatar.variant(:thumb)
image_tag @user.avatar.variant(:medium)

Named variants are generated lazily on first access and stored, making them efficient for repeated display.

Direct Uploads — Uploading Straight to Cloud Storage


For production apps, direct uploads bypass your Rails server entirely — the browser uploads directly to S3/GCS, then Rails just records the reference.

Setup:

// app/javascript/application.js
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()
<!-- Enable direct upload in the form field -->
<%= f.file_field :avatar, direct_upload: true %>

With direct_upload: true, the JavaScript library gets a presigned URL from your Rails server, uploads directly to cloud storage, and passes a signed blob ID back to your form submission. Your server never touches the file bytes, which dramatically reduces upload time and server memory usage.

Validating Attachments


Active Storage doesn’t include built-in content-type or size validation (as of Rails 7), but the patterns are straightforward.

Example:

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :avatar

  validate :avatar_content_type, if: :avatar_attached?
  validate :avatar_file_size, if: :avatar_attached?

  private

  def avatar_attached?
    avatar.attached?
  end

  def avatar_content_type
    allowed = %w[image/jpeg image/png image/webp image/gif]
    unless avatar.content_type.in?(allowed)
      errors.add(:avatar, "must be a JPEG, PNG, WebP, or GIF")
    end
  end

  def avatar_file_size
    if avatar.byte_size > 5.megabytes
      errors.add(:avatar, "must be less than 5MB")
    end
  end
end

For a more declarative approach, the active_storage_validations gem adds validates :avatar, content_type: and size: matchers.

Purging Attachments


Example:

# Purge immediately (synchronous)
@user.avatar.purge

# Purge in background job (recommended in production)
@user.avatar.purge_later

# Purge specific files from has_many_attached
@user.documents.where(id: params[:document_ids]).each(&:purge_later)

# Purge all of a user's documents
@user.documents.purge_later

purge_later enqueues an ActiveStorage::PurgeJob via Active Job. For cloud storage, this deletes the blob from S3/GCS in a background job rather than blocking the request.

Pro-Tip: Don’t serve Active Storage URLs directly in emails or store them in the database. Active Storage URLs are signed and expire (default: 5 minutes for rails_blob_url). For emails, generate the URL at send time. For permanent public URLs (public S3 objects), configure config.active_storage.service_urls_expire_in = nil or use the Direct URLs option in your storage config — but only for assets that genuinely need to be public and non-expiring.

Conclusion


Active Storage covers the full file attachment lifecycle — attaching, displaying, transforming, validating, and purging — with an API that feels native to Rails. The jump to direct uploads eliminates server bottlenecks for cloud storage, named variants centralize image transformation logic, and background purging keeps file cleanup off the request path. For most Rails applications, Active Storage is all the file handling infrastructure you need.

FAQs


Q1: Does Active Storage work with multiple cloud providers simultaneously?
Yes. You can configure multiple services in storage.yml and switch between them per environment. Some apps use local disk in development, S3 in production, and a mirror service to replicate to two cloud providers simultaneously using the Mirror service type.

Q2: How do I handle virus scanning for uploads?
Active Storage doesn’t include virus scanning. A common pattern is to use a background job that passes the blob to a scanning service (ClamAV, or cloud provider scanning APIs) and marks the attachment as safe or quarantines it. The attachment is accessible during scanning unless you implement a pending/approved state.

Q3: What’s the difference between purge and purge_later?
purge deletes the record and the underlying file synchronously in the current request. purge_later enqueues a background job. Use purge_later in production — it keeps the request fast and handles cloud storage deletion without blocking.

Q4: Can I attach files from a URL (not an uploaded file)?
Yes. user.avatar.attach(io: URI.open(url), filename: "avatar.jpg", content_type: "image/jpeg") creates an attachment from any IO object. This is useful for migrating existing files or attaching from external URLs.

Q5: Are Active Storage URLs publicly accessible?
By default, Active Storage generates signed, expiring URLs — they’re not publicly accessible without the signed token. For truly public assets (avatars, public images), configure public: true in your storage service config, which generates permanent public CDN URLs instead.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More