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
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, aUser) to a blob under a name likecoverorphotos. - 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:
- The browser submits a form with the file.
- Rails creates a blob record and uploads the file to the configured service.
- Rails creates an attachment record that links the post to the blob.
- 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_blobsstores facts about the file. Thekeyis the unique identifier used in the storage service, andservice_namerecords where the file lives.active_storage_attachmentsconnects any model to a blob. The polymorphicrecordreference is why one table can servePost,User,Invoice, and every other model in your app.active_storage_variant_recordsremembers 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_attachedgives you one attachment.post.coverreturns a single attachment proxy.has_many_attachedgives you a collection.post.photosreturns 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_tagaccepts 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.coverandlink_towith the attachment for everyday views. - Use
rails_blob_pathwhen you need a download disposition or a plain path. - Use
.urlwhen 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:
- The browser asks your app for a signed upload URL.
- Rails creates a blob record and returns the URL.
- The browser uploads the file directly to the storage service.
- 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 |
| No cloud account needed, fast |
Test |
| Files go to |
Production |
| 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_magickis 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
deleteanddelete_all, do not trigger cleanup. They leave orphaned files in storage. Usedestroyordestroy_allwhen 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.sanitizedwhen 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: truefor 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: :webpfor 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
storagedirectory 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.