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-Control headers in the application has no effect, because the edge replaces them on asset URLs.

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:

  1. Deploy proxy mode and the two routes above to production. They work fine before the CDN is in place.
  2. Add your production domain and wait for provisioning to complete.
  3. 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