Rails Active Storage: Moving File Uploads Off Your Application Server
Active Storage lets you swap local disk for S3, GCS, or Azure by changing configuration—not model code. This post walks through installation, service setup, a user-avatar example with on-the-fly resizing, and the URL gotcha when switching backends.
16 Jan 2026, 22:28 UTC

The problem: files accumulate on the app server
Every Rails project eventually needs to accept user uploads—avatars, PDFs, CSVs, product photos. The naive approach writes files to public/uploads on the same box that runs Puma. That works until you add a second application server, need to serve files through a CDN, or hit disk quotas during a traffic spike. Migrating off local disk then touches models, controllers, and views all at once.
Thesis: Active Storage abstracts the storage backend
Active Storage (built into Rails 5.2+) gives you a single attachment API—has_one_attached / has_many_attached—while the actual bytes live in a configurable service: local disk, Amazon S3, Google Cloud Storage, or Azure Blob Storage. Swapping services means editing config/storage.yml and environment credentials, not rewriting model code.
Getting the tables in place
Run the installer once per application. It creates two tables—active_storage_blobs (metadata) and active_storage_attachments (polymorphic join)—and a migration you can inspect before committing.
# Terminal, project root, developer with write access to db/migrate
rails active_storage:install
rails db:migrate
Verify the migration ran: rails db:migrate:status should show both migrations as "up".
Configure the service you actually want
Edit config/storage.yml. Each top-level key is a service name referenced later. Example for S3 in production and local disk in development:
# config/storage.yml
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-uploads
Then tell each environment which service to use:
# config/environments/production.rb
config.active_storage.service = :amazon
# config/environments/development.rb
config.active_storage.service = :local
Credentials belong in rails credentials:edit --environment production (or the equivalent secrets manager). Never commit raw keys.
Worked example: user avatars with on-the-fly resizing
Add the attachment declaration to the model:
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :avatar
end
Permit the parameter in the controller:
# app/controllers/users_controller.rb
def user_params
params.require(:user).permit(:name, :email, :avatar)
end
The form field is a standard file_field:
<%= form.file_field :avatar, accept: "image/*" %>
Display the uploaded image with a variant that resizes on demand. The image_processing gem (add to Gemfile, run bundle install) powers this:
<%= image_tag current_user.avatar.variant(resize_to_limit: [300, 300]) if current_user.avatar.attached? %>
On first request Active Storage generates the 300×300 variant, stores it alongside the original, and serves subsequent requests from the same service (or CDN if you configured one). No background job required unless you pre-generate variants.
Trade-off: public URLs differ between services
Local disk serves files through a Rails controller (/rails/active_storage/blobs/.../filename), while S3 returns a presigned URL that expires. If you hard-code url_for(user.avatar) in emails or API responses, those links break when you switch services. Use rails_blob_url(user.avatar, disposition: :attachment) for permanent links, or generate signed URLs on demand with user.avatar.service_url(expires_in: 1.hour). Test both environments before deploying.
Quick verification checklist
- Start the server in development, upload an avatar via the form, confirm the file appears under
storage/. - Run
rails consoleand executeUser.first.avatar.service_name— should return "local". - In production (or a staging box pointed at S3), repeat the upload and verify the object lands in the correct bucket with
aws s3 ls s3://my-app-uploads/. - Request the variant URL in a browser; the image should render without 500 errors.
Next step: add direct upload for large files
For files over a few megabytes, skip the Rails controller entirely. Active Storage's direct upload JavaScript library sends bytes straight from the browser to S3 (or GCS/Azure), then notifies your app with a signed blob ID. Include //= require activestorage in your JavaScript bundle, add direct_upload: true to the file field, and the same has_one_attached declaration handles the rest. This keeps request throughput high and memory usage low on your application servers.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.