Florin Lipan

Welcome to my personal website.

Salut! I'm a Ruby/Rust/Crystal developer with a devops background and leadership experience. You can also find me here: GitHub / Twitter / LinkedIn / Email.

↞ Back

Serving ActiveStorage uploads through a CDN with Rails direct routes

ActiveStorage makes it really easy to upload files from Rails to an S3 bucket or an S3-compatible service, like DigitalOcean Spaces. Refer to the official documentation if you’d like to know more about setting up ActiveStorage.

If your uploads are meant to be public and you were thinking of serving them directly through the CDN sitting in front of your S3 bucket, you’ll soon notice a problem: ActiveStorage URLs are built to always go through your Rails app, mainly through ActiveStorage::BlobsController. This controller is responsible for setting the cache headers and redirecting to the bucket URL. Your Rails app will be the first point of contact even if it’s just to retrieve the bucket URL. On top of that, there’s no place to specify a CDN host to replace the bucket host.

Fortunately, there is an easy way to go around this problem. In order to translate stored files into URLs, Rails provides the URL helper rails_blob_url, which basically resolves to this ActiveStorage::BlobsController. We’d like to introduce a new helper that points directly to our CDN host.

Though there are different ways of solving this problem, I found using Rails direct routes an elegant solution. Rails direct routes provide a way to create URL helpers directly from your config/routes.rb:

# config/routes.rb

direct :rails_public_blob do |blob|
  File.join("https://cdn.example.com", blob.key)
end

You can call this route the same way you’d call the original Rails URL helper:

class User
  has_one_attached :profile_picture
end

rails_public_blob_url(User.first.profile_picture)
# => https://cdn.example.com/j8rte71tp8xpq5afr3uqxlcqtkzn

# You can also use this outside views
Rails.application.routes.url_helpers.rails_public_blob_url(User.first.profile_picture)

Let’s refactor our route a bit:

# config/routes.rb

direct :rails_public_blob do |blob|
  # Preserve the behaviour of `rails_blob_url` inside these environments
  # where S3 or the CDN might not be configured
  if Rails.env.development? || Rails.env.test?
    route_for(:rails_blob, blob)
  else
    # Use an environment variable instead of hard-coding the CDN host
    # You could also use the Rails.configuration to achieve the same
    File.join(ENV.fetch("CDN_HOST"), blob.key)
  end
end

Proxy mode

Rails recently introduced a way to configure a CDN host for your ActiveStorage assets. This requires a proxy-enabled CDN (Cloudflare, CloudFront, nginx etc.) - so using only S3 or DigitalOcean Spaces as public file servers is excluded. On top of that, the CDN will fall back to the Rails backend once per uncached file (and again every time the CDN cache is invalidated). Yes, the backend request is fairly cheap (it’s just a redirect), but it can get delayed by other slower requests to your backend during peak times.

The solution proposed in my article can serve assets directly from S3 or DigitalOcean Spaces, using these services as public static file servers. At the end of the day, it all depends what kind of CDN you are using, how much you are willing to add to your infrastructure and at which level you’d like to optimize. For your average website I think serving assets directly from S3 or DigitalOcean Spaces is perfectly fine.

You can read more about proxy mode here.

Variants

If you’re using variants, things will look a bit different in your development environment. Running the following code:

image = User.first.profile_picture
rails_blob_url(image.variant(resize_to_limit: [100, 100]).processed)

…will produce an error: NoMethodError (undefined method 'signed_id' for #<ActiveStorage::Variant>).

According to this comment, the recommended way for accessing variants directly is by using the rails_representation_url helper. The following call should work:

image = User.first.profile_picture
rails_representation_url(image.variant(resize_to_limit: [100, 100]).processed)

Let’s update our direct route to accomodate the logic for variants:

# config/routes.rb

direct :rails_public_blob do |blob|
  # Preserve the behaviour of `rails_blob_url` inside these environments
  # where S3 or the CDN might not be configured
  if Rails.env.development? || Rails.env.test?
    route = 
      # ActiveStorage::VariantWithRecord was introduced in Rails 6.1
      # Remove the second check if you're using an older version
      if blob.is_a?(ActiveStorage::Variant) || blob.is_a?(ActiveStorage::VariantWithRecord)
        :rails_representation
      else
       :rails_blob
      end
    route_for(route, blob)
  else
    # Use an environment variable instead of hard-coding the CDN host
    File.join(ENV.fetch("CDN_HOST"), blob.key)
  end
end

Note that the production version using the CDN works the same for both the original attachment as well as the variants.

Conclusion

You can use this new URL helper whenever your ActiveStorage files should be served directly through a CDN without having to deploy this setup to your development environment.

Rails 6.1 will allow defining multiple storage services for the same environment, which means you’ll be able to use both public and private buckets from your code. This makes using public buckets and CDNs an even more viable option than before. See this PR for more details.

Thanks to Eduardo Álvarez for raising the variants issue in the comments.

If you enjoyed my blog post, please spread the news:

Share on Hacker News
Share on Reddit