Rails Active Storage
Overview
Rails Active Storage works well behind our CDN, but its default configuration does not. This article covers the setup we recommend for new applications, how to migrate an existing application without breaking saved URLs, and how to serve files that need an access check.
Why the Default Configuration Breaks
In its default redirect mode, Active Storage answers each file URL with a 302 redirect to a temporary signed storage URL that expires after a few minutes.
Active Storage URLs end with the file’s name, so they end in an asset
file extension, and the edge network applies far-future Cache-Control
and Expires headers to any response at those URLs, including
redirects (see the Asset File Caching article). Browsers cache the
redirect long after the signed URL behind it has expired, and the file
breaks.
Two things that look like fixes do not work here:
- Adding the route as a Non-Cached Path (see the Non-Cached Paths article) stops edge caching of the redirect but does not change the browser headers.
- Setting your own
Cache-Controlheaders in the application has no effect, because the edge replaces them on asset URLs.
Recommended Setup: Proxy Mode
Serve files through your application instead of redirecting to them:
# config/environments/production.rb
config.active_storage.resolve_model_to_route = :rails_storage_proxy
Proxy URLs return the file directly, and the URL changes whenever the file’s content changes. That makes the far-future browser headers correct: each URL always returns the same bytes, so browsers and the edge cache can hold them as long as they like. The edge caches each asset for 3 days, so your dynos serve any given file infrequently.
This is the configuration to start with on a new application.
One consequence to know about: once a file has been fetched, copies can live in the edge cache (3 days) and in browser caches until they expire. Deleting a file does not remove those copies, so treat anything served in proxy mode as public once it has been fetched.
Migrating an Existing Application
If your application ran in redirect mode before moving behind the CDN, URLs in the old style may be saved in emails, API responses, or other sites. Those URLs must keep working, and they cannot be changed after the fact.
The redirect-style and proxy-style paths share the same signed IDs, so you can point the old redirect paths at the proxy controllers with two routes:
# config/routes.rb
get "/rails/active_storage/blobs/redirect/:signed_id/*filename",
to: "active_storage/blobs/proxy#show"
get "/rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename",
to: "active_storage/representations/proxy#show"
Routes defined in your application take precedence over the Rails built-ins, so every saved URL keeps working. It now returns the file directly instead of a 302 to an expiring signed URL, which removes the redirect that browsers were caching.
Deploy in this order for a rollout with no broken windows:
- Deploy proxy mode and the two routes above to production. They work fine before the CDN is in place.
- Add your production domain and wait for provisioning to complete.
- Point your DNS at the CDN.
Because no expiring redirect is ever served through the CDN, there is no point at which a browser can cache a response that later breaks.
After the cutover, remove any Non-Cached Path entries you added for the Active Storage routes and click Clear Cache. With files served directly, edge caching on those paths is what you want.
Files That Need an Access Check
For files that require a permission check on every request (private reports, paid downloads), two rules apply.
Never hand out Active Storage URLs for these files. Do not use
url_for or the rails_blob_path helpers on them, in either proxy or
redirect style. A blob’s signed ID is a bearer token: Rails’ built-in
Active Storage routes accept it with no permission check, so any URL
containing one can be rewritten into a working download URL. That
rewritten URL also ends in the filename, so the edge would cache the
private file like any other asset. (If your application serves no
public files through Active Storage, you can remove the built-in
routes entirely with config.active_storage.draw_routes = false.)
Serve them from your own route, keyed by your own record IDs, with the permission check in the controller:
# config/routes.rb
get "/reports/:id/download", to: "reports#download"
# app/controllers/reports_controller.rb
class ReportsController < ApplicationController
def download
report = current_user.reports.find(params[:id])
redirect_to report.file.url, allow_other_host: true
end
end
Scoping the lookup through current_user is the permission check: a
request for someone else’s report raises RecordNotFound and returns
a 404. Because the URL has no asset extension, the edge does not apply
browser caching headers, and your application’s headers pass through
unchanged. The redirect goes to a signed storage URL that expires
after a few minutes.
One more step: add the route as a Non-Cached Path (Begins with
/reports/). The edge caches redirects (10 minutes on the Assets Only
profile, 180 minutes on Full Site), and a cached redirect would both
skip your permission check for that window and keep handing out a
still-live signed URL. With the rule in place, every request reaches
your application.
Summary
| File type | Configuration |
|---|---|
| Public files, new application | Proxy mode |
| Public files, existing application with saved URLs | Proxy mode plus the two redirect-path routes |
| Access-controlled files | Your own extension-free route with the permission check, plus a Non-Cached Path rule |