DEV Community

loading...

Serving ActiveStorage uploads through a CDN with Rails direct routes

lipanski profile image Florin Lipan Originally published at lipanski.com Updated on ・3 min read

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

...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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.


Originally published at https://lipanski.com/posts/activestorage-cdn-rails-direct-route.

Discussion

pic
Editor guide
Collapse
cryptoraptor profile image
Eduardo

Great tutorial! The direct route helper works great for my production environment, where I use Active Storage with a S3 service and Cloudflare CDN. However, in my development environment I have this

config.active_storage.service = :local

in storage.yml:

local:
service: Disk
root: <%= Rails.root.join("storage") %>

Now, when I run the app in development, I get this error as soon as I try to display an image:

undefined method `signed_id' for #ActiveStorage::Variant:0x00007f357...

the line error being shown is precisely the direct route in config/routes.rb:

 route_for(:rails_blob, blob) # exception here

The only difference I can see is that I am using variants, i.e., outside my views I have this:

Rails.application.routes.url_helpers.rails_public_blob_url(current_user.avatar.variant(variant_options))

Any ideas on what might be happening?

Thanks!

Collapse
lipanski profile image
Florin Lipan Author

Hi Eduardo

good point. If you're using variants, try calling .blob on the variant before you pass your variant as an argument to the URL helper:

blob = current_user.avatar.variant(variant_options).blob
Rails.application.routes.url_helpers.rails_public_blob_url(blob)

You might also need to add .processed to the mix (depending on how/when you process your variants):

blob = current_user.avatar.variant(variant_options).processed.blob
Rails.application.routes.url_helpers.rails_public_blob_url(blob)

Let me know if that worked.

Collapse
cryptoraptor profile image
Eduardo

Thank you for your kind response.

To make this work, it was required to add the "only_path: true" parameter to route_for, i.e.

direct :rails_public_blob do |blob|
if Rails.env.production?
File.join("cdn.example.com", blob.key)
else
route_for(:rails_blob, blob, only_path: true)
end
end

Without "only_path: true", I was getting this message:

Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true

After this was fixed, howerver, I noticed that the rails_public_blob helper started returning a path to the "master" image and not to the variant, i.e.

force the creation of the variant:
current_user.avatar.variant(variant_options).processed

blob = current_user.avatar.variant(variant_options).blob

image_path = Rails.application.routes.url_helpers.rails_public_blob_url(blob)

image_path points to the master image, not to the variant. This wasn't happening before. Any ideas? :)

Thanks for any insight!

-Eduardo

Thread Thread
lipanski profile image
Florin Lipan Author

Hi Eduardo

I just updated the post with the solution for variants. Please have a look. It appears variants have their own URL helper rails_representation_url.

Thread Thread
cryptoraptor profile image
Eduardo

Thank you, Florin! It works wonderful now. I still needed to add "only_path: true" to "route_for", i.e.:

route_for(route, blob, only_path: true)

Thanks for the great post and the generous assistance. 😊

Collapse
alispat profile image
Alisson Patrick

Thanks for sharing this! Saved my day!