Rails Active Storage: A Practical Guide

Learn Rails Active Storage: file uploads, has_one_attached, variants, direct uploads, S3, validations, and production tips.


By Jean Emmanuel Cadet • 23 min read

Rails Active Storage: A Practical Guide
Share with friends

Almost every real Rails application ends up handling files sooner or later. Users upload avatars, teams attach PDFs to records, and editors add cover images to posts. Rails Active Storage is the built-in framework for handling all of this without extra gems or hand-rolled upload code.

This guide walks through Rails Active Storage from the first install to production concerns. You will learn how to attach files with has_one_attached and has_many_attached, build upload forms, generate URLs, use direct uploads, connect Amazon S3, create image variants, validate uploads, and clean up safely. The examples target Rails 8, and the notes flag the few places where older versions differ.


What Is Active Storage in Rails?

Active Storage is the Rails framework for uploading files to a storage service and attaching them to Active Record objects. It ships with Rails, so there is nothing to add for basic use.

Before Active Storage, most teams reached for third-party gems or wrote custom upload code. Rails added a standard solution in version 5.2 for several reasons:

  • File handling is a common need, and every app was solving it differently.
  • Storing files inside the database or on the application server does not scale well.
  • Cloud storage such as Amazon S3 needs a consistent abstraction so you can switch providers without rewriting your models.
  • Image resizing, previews, and cleanup should follow one predictable pattern.

The result is a small API on your models, a set of database tables that track files, and a pluggable storage layer. You write has_one_attached :avatar, and Rails handles storage, lookup, URLs, and deletion.


How Active Storage Works

Active Storage has four main pieces that cooperate:

  • Blob: a database record describing an uploaded file. It stores the filename, content type, byte size, checksum, and a unique key.
  • Attachment: a join record connecting one of your models (a Post, a User) to a blob under a name like cover or photos.
  • Variant record: a record that tracks a processed version of an image, such as a resized thumbnail.
  • Service: the backend that actually stores the bytes, such as local disk, Amazon S3, or another object storage provider.

Here is the flow when a user uploads a cover image for a post:

  1. The browser submits a form with the file.
  2. Rails creates a blob record and uploads the file to the configured service.
  3. Rails creates an attachment record that links the post to the blob.
  4. When you display the image, Rails generates a URL that points to the file, either through your app or directly at the storage service.

The file bytes live in the storage service. The database only holds metadata and relationships. That separation keeps your database small and lets you move files between services without touching your models.


Installing and Configuring Active Storage

In a new Rails app, Active Storage is already available. In an existing app, run the installer:

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

The installer copies a migration that creates the Active Storage tables. If your app uses UUID primary keys, set that in your generators configuration before running the installer so the new tables use matching key types:

# config/application.rb
config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end

Storage services

Services are defined in config/storage.yml. A fresh Rails app includes a local disk service for development and a test service:

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

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

Each environment then picks which service to use:

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

# config/environments/test.rb
config.active_storage.service = :test

With this setup, files uploaded in development land in the storage directory of your project. Add that directory to .gitignore so uploads never end up in version control. Rails generates the ignore rule for new apps, but check it in older ones.

Image processing

To resize images, you need the image_processing gem and an image library on your machine. Rails uses libvips by default in modern versions:

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

Install libvips with your system package manager (brew install vips on macOS or sudo apt install libvips on Debian and Ubuntu). The default Rails 8 Dockerfile already includes it. If you prefer ImageMagick, set config.active_storage.variant_processor = :mini_magick and add the mini_magick gem.


The Active Storage Database Tables

The installer creates three tables. Here is a simplified view of what each one holds:

create_table :active_storage_blobs do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
t.datetime :created_at, null: false
end

create_table :active_storage_attachments do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false
t.references :blob, null: false
t.datetime :created_at, null: false
end

create_table :active_storage_variant_records do |t|
t.belongs_to :blob, null: false, index: false
t.string :variation_digest, null: false
end

Why these tables exist:

  • active_storage_blobs stores facts about the file. The key is the unique identifier used in the storage service, and service_name records where the file lives.
  • active_storage_attachments connects any model to a blob. The polymorphic record reference is why one table can serve Post, User, Invoice, and every other model in your app.
  • active_storage_variant_records remembers which variants have already been generated, so Rails does not process the same thumbnail twice.

Because attachments are polymorphic, you never add file columns to your own tables. There is no avatar_url string to maintain.


Attaching Files to Models

One file with has_one_attached

Use has_one_attached when a record has a single file, such as a profile photo or a cover image:

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

You can now attach and check files:

user.avatar.attach(io: File.open("/path/to/photo.jpg"), filename: "photo.jpg", content_type: "image/jpeg")
user.avatar.attached? # => true
user.avatar.filename.to_s # => "photo.jpg"

Attaching a new file to a has_one_attached field replaces the previous one.

Many files with has_many_attached

Use has_many_attached when a record can have any number of files, such as a photo gallery or a set of documents:

# app/models/post.rb
class Post < ApplicationRecord
has_one_attached :cover
has_many_attached :photos
end

post.photos behaves like a collection. You can loop over it, count it, and attach more files:

post.photos.attach(params[:photos])
post.photos.count
post.photos.each { |photo| puts photo.filename }

The difference between has_one_attached and has_many_attached

The difference is cardinality and the shape of the API:

  • has_one_attached gives you one attachment. post.cover returns a single attachment proxy.
  • has_many_attached gives you a collection. post.photos returns a proxy you can iterate.
  • In forms and strong parameters, the many version takes an array (photos: []).

If you are unsure, choose based on the domain. An avatar is one file. A listing with photos is many.

Attach versus assign

attach on a saved record persists immediately. On a new, unsaved record, the file is saved when the record is saved. Assigning through update or new also works and is what forms use.

For has_many_attached, assigning an array replaces the existing files on modern Rails. That surprises many developers, and the mistakes section below covers a safe pattern for adding files to an existing collection.


Uploading Files Through Rails Forms

Add a file field to the form and permit the attribute in your controller. No special form setup is needed, because form_with sets the multipart encoding when it sees a file field.

<%# app/views/posts/_form.html.erb %>
<%= form_with model: post do |form| %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>

<div>
<%= form.label :cover %>
<%= form.file_field :cover, accept: "image/png,image/jpeg,image/webp" %>
</div>

<div>
<%= form.label :photos %>
<%= form.file_field :photos, multiple: true %>
</div>

<%= form.submit %>
<% end %>

The accept attribute helps users pick the right files, but it is only a convenience. Browsers do not enforce it strictly, and attackers ignore it, so you still need server-side validation.

In the controller, permit the attachment names. For a single file, permit the name as a scalar. For multiple files, permit an array:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def create
@post = Post.new(post_params)

if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end

private

def post_params
params.expect(post: [:title, :cover, photos: []])
end
end

params.expect is the Rails 8 way to require and permit parameters. On Rails 7 and earlier, use params.require(:post).permit(:title, :cover, photos: []).

When the form submits, Rails uploads the file to the configured service, creates the blob, and links it to the post. If validation fails, the record is not saved, and the upload is discarded.


Displaying and Linking to Uploaded Files

Once a file is attached, you can display images and link to downloads directly from your views.

<%# app/views/posts/show.html.erb %>
<% if @post.cover.attached? %>
<%= image_tag @post.cover, alt: @post.title %>
<% else %>
<%= image_tag "placeholder.png", alt: "No cover image" %>
<% end %>

<% if @post.photos.attached? %>
<ul>
<% @post.photos.each do |photo| %>
<li>
<%= link_to photo.filename.to_s, rails_blob_path(photo, disposition: "attachment") %>
</li>
<% end %>
</ul>
<% end %>

Notes on this example:

  • attached? guards against records with no file. Calling methods on a missing attachment can raise errors, so always check first for optional files.
  • image_tag accepts an attachment directly and generates the right URL.
  • disposition: "attachment" tells the browser to download the file. The default, inline, lets the browser display it when it can.

For many files, remember to call attached? on the collection. It returns true only when at least one file is present.


Generating URLs for Active Storage Files

Rails Active Storage offers several ways to reference a file. They look similar, but they behave differently.

url_for and polymorphic URLs

url_for(post.cover)

url_for returns a permanent route on your application. When someone requests it, Rails redirects to a short-lived signed URL on the storage service. This is what image_tag @post.cover and link_to "Download", @post.cover use behind the scenes.

rails_blob_path and rails_blob_url

rails_blob_path(post.cover, disposition: "attachment")
rails_blob_url(post.cover)

These helpers generate the same kind of permanent application route, and they let you set options such as disposition. Use the _path version in views and the _url version when you need a full URL, such as in emails or API responses.

Redirect and proxy routes

Active Storage supports two delivery modes:

  • Redirect mode (rails_storage_redirect_path) sends the browser to the storage service. This is the default. It is efficient because file bytes never pass through your Rails servers.
  • Proxy mode (rails_storage_proxy_path) streams the file through your app. This is useful for CDN setups or when you cannot expose the storage service URL, but it uses more application resources.

You can switch the default with config.active_storage.resolve_model_to_route = :rails_storage_proxy.

The service URL

post.cover.url

This returns the URL from the storage service itself, such as a signed S3 URL. It expires after a short time (five minutes by default, configurable through config.active_storage.service_urls_expire_in). Do not store or cache these URLs in places that outlive their expiry, such as a cached fragment.

For public assets, you can mark a service as public in storage.yml with public: true. Then url returns a stable URL that does not expire. Check your bucket's public access and ACL settings first, because new S3 buckets block public access by default.

Which one should you use?

  • Use image_tag @post.cover and link_to with the attachment for everyday views.
  • Use rails_blob_path when you need a download disposition or a plain path.
  • Use .url when you need a direct service URL, such as when generating a short-lived link after authorization.

The permanent application routes are the safest default. They never go stale, because the redirect creates a fresh signed URL on each request. To understand how those requests move through routing and controllers before the redirect happens, see how the Rails request lifecycle works.


Direct Uploads

By default, files pass through your Rails server on the way to storage. That works fine for small files, but large uploads tie up a web worker for the whole transfer. Direct uploads solve this by letting the browser send the file straight to the storage service.

Here is the flow:

  1. The browser asks your app for a signed upload URL.
  2. Rails creates a blob record and returns the URL.
  3. The browser uploads the file directly to the storage service.
  4. The form submits only a signed blob ID, and Rails attaches the existing blob.

Direct uploads are useful when:

  • Users upload large files such as videos or high-resolution images.
  • You want to keep web workers free for regular requests.
  • You want to show upload progress in the browser.

Enabling direct uploads

Add the JavaScript library. With importmap, which is the Rails default:

# config/importmap.rb
pin "@rails/activestorage", to: "activestorage.esm.js"
// app/javascript/application.js
import * as ActiveStorage from "@rails/activestorage"
ActiveStorage.start()

If you use a JavaScript bundler, install the package instead with npm install @rails/activestorage. Then add direct_upload: true to the file field:

<%= form.file_field :cover, direct_upload: true %>
<%= form.file_field :photos, multiple: true, direct_upload: true %>

CORS for cloud storage

Because the browser talks to your bucket directly, the bucket must allow cross-origin PUT requests from your site. For S3, a CORS configuration like this works:

[
{
"AllowedHeaders": ["Content-Type", "Content-MD5", "Content-Disposition"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://www.example.com"],
"MaxAgeSeconds": 3600
}
]

Replace the origin with your real domain. Missing CORS rules are the most common reason direct uploads fail in production while working locally.

Direct uploads that never get attached to a record leave unattached blobs behind. The cleanup section covers how to handle them.


Using Active Storage with Amazon S3 and Compatible Providers

Local disk storage is fine for development, but production apps usually need cloud storage. Active Storage supports Amazon S3, Google Cloud Storage, and Azure Storage. Many S3-compatible services also work, including Cloudflare R2, DigitalOcean Spaces, Backblaze B2, and MinIO.

Setting up S3

Add the AWS SDK gem:

# Gemfile
gem "aws-sdk-s3", require: false

Then define the service in config/storage.yml:

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: myapp-production

Point production at it:

# config/environments/production.rb
config.active_storage.service = :amazon

The IAM user or role needs permission to list the bucket and to get, put, and delete objects in it. Grant only that and nothing broader.

If your app runs on AWS infrastructure with an IAM role, you can leave out the access keys entirely. The AWS SDK then finds credentials from the environment, which is safer than storing long-lived keys.

S3-compatible providers

For services that speak the S3 API, use the same S3 service with a custom endpoint:

cloudflare_r2:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:r2, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:r2, :secret_access_key) %>
endpoint: https://<account-id>.r2.cloudflarestorage.com
region: auto
bucket: myapp-production

Some providers also need force_path_style: true, which MinIO commonly requires. Check your provider's documentation for region and endpoint values.

Using more than one service

You can define several services and choose one per attachment:

class Document < ApplicationRecord
has_one_attached :file, service: :amazon
has_one_attached :thumbnail, service: :cloudflare_r2
end

This is handy when you keep private documents in one bucket and public assets in another.


Configuring Storage per Environment and Securing Credentials

A clean setup uses a different service per environment:

Environment

Service

Why

Development

:local

No cloud account needed, fast

Test

:test

Files go to tmp and stay out of the way

Production

:amazon or similar

Durable, scalable storage

Staging should mirror production, using its own bucket so test uploads never mix with real data.

Keeping secrets out of your code

Never commit access keys to Git. Use encrypted credentials instead. Edit the production credentials file:

bin/rails credentials:edit --environment production
# config/credentials/production.yml.enc (decrypted view)
aws:
access_key_id: AKIA...
secret_access_key: your-secret

The storage.yml file then reads these values through Rails.application.credentials, as shown earlier. The master key for each environment stays outside the repository and is supplied through RAILS_MASTER_KEY or a key file on the server. If you want a deeper look at how encrypted credentials work, read Rails credentials and how to use them securely.

Testing with attachments

Tests use the :test service, so uploaded files never touch real storage. Here is a quick way to attach a fixture file in a test:

post = Post.new(title: "Hello")
post.cover.attach(
io: File.open(Rails.root.join("test/fixtures/files/cover.png")),
filename: "cover.png",
content_type: "image/png"
)
assert post.save

In controller and integration tests, fixture_file_upload("cover.png", "image/png") builds an uploaded file you can pass as a parameter.


Variants and Image Processing

Users upload huge images. Serving a 6 MB photo as a 100-pixel thumbnail wastes bandwidth and slows pages down. Variants let you transform images on demand.

Creating a variant

<%= image_tag @post.cover.variant(resize_to_limit: [400, 400]) %>

The first time this variant is requested, Rails processes the image, stores the result, and records it in active_storage_variant_records. Later requests reuse the stored file.

Named variants

Named variants keep transformations in one place:

class Post < ApplicationRecord
has_one_attached :cover do |attachable|
attachable.variant :thumb, resize_to_fill: [300, 200], format: :webp
attachable.variant :large, resize_to_limit: [1600, 1600], preprocessed: true
end

has_many_attached :photos do |attachable|
attachable.variant :gallery, resize_to_limit: [800, 800]
end
end
<%= image_tag @post.cover.variant(:thumb) %>

Common transformations include:

  • resize_to_limit: shrinks the image to fit inside the given size while keeping the aspect ratio. It never enlarges.
  • resize_to_fill: resizes and crops to fill the exact dimensions.
  • resize_to_fit: fits inside the dimensions, and can enlarge small images.
  • format: :webp: converts to a different format.

Lazy versus preprocessed variants

By default, variants are created lazily, on the first request. That is simple, but the first visitor pays the processing cost. Adding preprocessed: true generates the variant in a background job right after upload. Use it for variants you know you will always need.

ImageMagick and libvips

Variants rely on an image processing library:

  • libvips is the modern default. It is fast and uses little memory, which matters when processing many uploads.
  • ImageMagick through mini_magick is older and slower, but widely available and flexible.

Both are called through the image_processing gem. The syntax in your Ruby code stays mostly the same.

Not every file is an image. For PDFs and videos, Active Storage can generate preview images through preview, but that needs extra tools installed on the server, such as ffmpeg for video and poppler or mupdf for PDFs. Use variable? and previewable? to check what a given attachment supports before you try to transform it.


Validating Uploads

Active Storage does not validate file types or sizes for you. Without validation, users can upload anything of any size, so add checks to your models.

Validating type and size

class Post < ApplicationRecord
ACCEPTED_TYPES = %w[image/jpeg image/png image/webp].freeze
MAX_COVER_SIZE = 5.megabytes

has_one_attached :cover

validate :cover_is_acceptable

private

def cover_is_acceptable
return unless cover.attached?

unless ACCEPTED_TYPES.include?(cover.blob.content_type)
errors.add(:cover, "must be a JPEG, PNG, or WebP image")
end

if cover.blob.byte_size > MAX_COVER_SIZE
errors.add(:cover, "must be smaller than 5 MB")
end
end
end

Expected behavior: if someone uploads a 12 MB file or a .exe renamed to .jpg, the record fails validation, @post.save returns false, and the form re-renders with error messages on the cover field.

For multiple files, loop over the collection:

validate :photos_are_acceptable

def photos_are_acceptable
photos.each do |photo|
unless ACCEPTED_TYPES.include?(photo.blob.content_type)
errors.add(:photos, "must be JPEG, PNG, or WebP images")
break
end
end
end

Handling missing and invalid uploads

Decide whether the file is required. To require a cover:

validates :cover, presence: true

When an upload fails validation, Rails does not save the record and the file is not attached. In the view, show the errors and keep the guard around display code:

<% if @post.errors[:cover].any? %>
<p class="error"><%= @post.errors[:cover].to_sentence %></p>
<% end %>

Remember that the browser-side accept attribute is not a security measure. Real validation happens on the server.


Removing and Replacing Attachments

Replacing a file

For has_one_attached, attach a new file and the old one is replaced:

user.avatar.attach(params[:avatar])

The previous blob is scheduled for deletion, and its file is removed from storage in a background job.

Removing a file

post.cover.purge        # deletes the attachment, blob, and stored file now
post.cover.purge_later # does the same in a background job
post.cover.detach # removes the link but keeps the blob and file

In most cases, prefer purge_later, because deleting from cloud storage during a web request adds latency.

Removing one file from a collection

# app/controllers/photos_controller.rb
class PhotosController < ApplicationController
def destroy
post = Post.find(params[:post_id])
post.photos.find(params[:id]).purge_later
redirect_to post, notice: "Photo removed."
end
end

Scope the lookup through the parent record, as shown. Finding attachments globally with ActiveStorage::Attachment.find invites authorization bugs where one user deletes another user's file.


Attachments Versus Blobs, and How Cleanup Works

The two records are easy to confuse:

  • A blob is the file itself, in metadata form: filename, size, type, and storage key.
  • An attachment is the relationship between your record and a blob, under a given name.

You can see both in the console:

post.cover.attachment       # => ActiveStorage::Attachment
post.cover.blob # => ActiveStorage::Blob
post.cover.blob.byte_size # => 245891
post.cover.blob.key # => "x8k3..." (storage key)

A single blob can be attached to more than one record. Purging an attachment removes the attachment and, when nothing else uses the blob, the blob and stored file too.

What happens when you delete a record

Active Storage cleans up after you when a record is destroyed. The default is the equivalent of dependent: :purge_later, so the files are deleted in a background job:

post.destroy # attachments purged in the background

You can change this behavior:

has_one_attached :cover, dependent: false # keep the files

Important considerations:

  • Background purging needs a working Active Job backend. Rails 8 uses Solid Queue by default, but make sure a worker is running in production.
  • Methods that skip callbacks, such as delete and delete_all, do not trigger cleanup. They leave orphaned files in storage. Use destroy or destroy_all when records have attachments.
  • Direct uploads that were never attached, and uploads from abandoned forms, leave unattached blobs behind.

Cleaning unattached blobs

Schedule a recurring job that removes old unattached blobs:

# app/jobs/cleanup_unattached_blobs_job.rb
class CleanupUnattachedBlobsJob < ApplicationJob
queue_as :default

def perform
ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).find_each(&:purge_later)
end
end

The two-day window avoids deleting blobs that belong to uploads still in progress.


Security Considerations for File Uploads

File uploads are one of the riskiest features in a web application. Treat every uploaded file as untrusted.

Do not trust extensions or client metadata

The filename and content type sent by the browser are just claims. Anyone can rename malware.exe to photo.jpg or send a fake Content-Type header. Active Storage inspects the file's contents to determine the type for files uploaded through your server, but for direct uploads the browser-reported type may be what gets stored first.

Practical rules:

  • Validate against an allowlist of types, never a blocklist.
  • Check the blob's stored content type, not just the filename extension.
  • For sensitive workflows, verify the real type on the server after upload. Marcel, the library Rails uses for type detection, can inspect the bytes:
blob.open do |file|
detected_type = Marcel::MimeType.for(file, name: blob.filename.to_s)
# compare detected_type with your allowlist
end

Be careful with dangerous content types

Files such as HTML, SVG, and JavaScript can execute scripts when displayed inline from your domain, which opens the door to cross-site scripting. Rails already serves several of these types as forced downloads through config.active_storage.content_types_to_serve_as_binary. Do not remove types from that list, and be cautious about allowing SVG uploads from untrusted users.

Understand who can access files

Active Storage URLs contain signed, hard-to-guess identifiers, but they are not authenticated by default. Anyone who has the URL can fetch the file. For private documents, do not expose rails_blob_path directly. Authorize the request in your own controller and hand out a short-lived service URL:

class DocumentsController < ApplicationController
def show
document = Current.user.documents.find(params[:id])
redirect_to document.file.url(expires_in: 1.minute, disposition: "attachment"),
allow_other_host: true
end
end

Here Current.user.documents.find ensures the user owns the record, and the link expires after one minute.

Other protections

  • Limit upload size at your reverse proxy or load balancer as well, so oversized requests are rejected before they reach Rails.
  • Consider virus scanning for user-supplied documents, for example with ClamAV in a background job.
  • Cap image dimensions as well as file size, since a small file can decompress into a huge image and consume memory during processing.
  • Use blob.filename.sanitized when writing filenames to disk or headers.

Common Active Storage Mistakes and How to Avoid Them

Forgetting to check attached?. Calling variant or image_tag on a missing attachment raises an error. Guard optional files with attached? and show a placeholder.

Causing N+1 queries in lists. Loading a list of posts and then calling post.cover on each one triggers extra queries per record. Use the generated scopes:

@posts = Post.with_attached_cover.with_attached_photos

This is the same eager loading idea used elsewhere in Active Record. If you want to go deeper, read how to optimize Active Record queries in Rails.

Accidentally replacing or clearing a collection. Assigning photos through a form replaces the existing files, and submitting the form with no files chosen can clear them. To add files without touching the existing ones, attach them explicitly:

def update
@post = Post.find(params[:id])
new_photos = params.dig(:post, :photos)&.compact_blank

@post.photos.attach(new_photos) if new_photos.present?

if @post.update(post_params.except(:photos))
redirect_to @post
else
render :edit, status: :unprocessable_entity
end
end

Skipping validation. Active Storage will accept any file unless you say otherwise. Always validate type and size.

Using delete_all on records with files. This skips callbacks and leaves orphaned files in storage. Use destroy_all or handle cleanup yourself.

Caching expiring URLs. Storing attachment.url in a cache or a database column produces broken links once it expires. Cache the permanent route helpers instead, or the attachment itself.

Running production on disk without persistence. Local disk storage inside a container disappears when the container is replaced. Use cloud storage, or mount a persistent volume.

Missing CORS rules for direct uploads. Everything works in development and fails in production. Configure the bucket first.

Not running a job backend. Purging, analysis, and preprocessed variants all rely on Active Job. Without a worker, files pile up, and previews never appear.


Performance Considerations

Uploads and downloads are usually the heaviest operations in an app, so a few decisions matter.

Uploads

  • Use direct uploads for large files so web workers are not held open during transfers.
  • Set sensible size limits at the proxy and in validations.
  • Do heavy work such as scanning or analysis in background jobs, not during the request.

Downloads

  • Keep redirect mode as the default so file bytes flow from storage to the browser, not through Rails.
  • Put a CDN in front of public assets. Proxy mode plus a CDN, or a public service with a CDN domain, works well for cached images.
  • Avoid caching short-lived signed URLs.

Image variants

  • Create variants at the sizes you actually display. A gallery that serves the original file wastes bandwidth.
  • Use preprocessed: true for variants every visitor will see, so the first request is not slow.
  • Prefer libvips. It processes images faster and with lower memory usage than ImageMagick.
  • Consider format: :webp for smaller files.

Database and queries

  • Use with_attached_* scopes on any page that lists records with images.
  • Remember that each attachment involves joins to two tables. Eager loading keeps page queries predictable.

Storage costs

  • Clean unattached blobs regularly.
  • Use lifecycle rules on your bucket for temporary or old data, but do not apply them to files that records still reference.
  • Avoid generating a large number of unused variants for every attachment.

Active Storage in a Production Rails Deployment

Is Active Storage suitable for production? Yes. It is part of Rails, it is actively maintained, and many large applications rely on it. What matters is configuring it for production conditions.

A production checklist:

  • Use durable storage. Choose S3 or another object storage service. If you deploy with Kamal on a single server and use disk storage, mount a persistent volume for the storage directory so files survive deploys. That setup does not work across multiple servers.
  • Set the service explicitly in config/environments/production.rb, and keep credentials in encrypted files or IAM roles.
  • Run a job worker so purge, analyze, and preprocessing jobs execute.
  • Install libvips in your image if you use variants.
  • Configure CORS if you use direct uploads.
  • Plan backups. Database backups alone are not enough. The blob records point at files in the bucket, so enable versioning or replication for the bucket as well.
  • Monitor storage growth and the unattached blob cleanup job.
  • Separate buckets by environment so staging and production data never mix.

Also decide where public and private files live. Public images can go behind a CDN. Private documents should be served through authorized controllers with short-lived URLs.

With these pieces in place, Rails Active Storage gives you a dependable file handling layer without adding another dependency to maintain.


Conclusion

Rails Active Storage covers the full life of an uploaded file: attaching it to a model, storing it locally or in the cloud, transforming images, serving URLs, and cleaning up when records go away. The core ideas are small. Blobs describe files, attachments link them to your models, and services store the bytes.

To put this into practice, start with has_one_attached on one model and a local disk service. Add validation for file type and size, then move production to S3 with credentials kept out of your code. From there, add named variants for images, direct uploads for large files, and a cleanup job. Each step builds on the last, and none of them require leaving Rails.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.