Active Storage CDN with Cloudfront and Subdomain in Rails

What we will be doing

This post assumes you’re using Active Storage in a Rails app and want to switch to using a CDN. I’m writing it down because I spent all day figuring this out yesterday, and couldn’t find a definitive “how-to” article anywhere!

In this article we will:

  1. Switch Active Storage to “Proxy Mode”
  2. Get an SSL cert for your sub-domain if you need one.
  3. Add a Cloudfront distribution in front of your S3 bucket
  4. Set up the sub-domain with your registrar
  5. Update the views in your Rails app to serve images from the CDN

Active Storage

I’m building a new product, built on Rails 7 of course, and for content images I opted to use Active Storage being the Rails WayTM, and it really is excellent.

If you haven’t done so already and you’re looking to set up Active Storage in your app, I can recommend this excellent article and won’t repeat those details here.

So, assuming you have Active Storage all set up, but want to improve the performance as I did, and serve your images from CDN with a sub-domain like cdn.example.com, this article is for you!

Why?

Why indeed. Good question. By default, when you’ve integrated Active Storage, when you use a regular Rails img_tag helper (note I’m talking only about images here but Active Storage can handle any attachment) such as this:

<%= image_tag(user.photo) if user.photo.attached? %>

it will generate an HTML <img> tag in your output markup that looks like this:

<img
  src="https://example.com/rails/active_storage/representations/redirect/
  eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbWdCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX
  2lkIn19--0131122555fe9f531867c4e7501550385b0a8b09eyJfcmFpbHMiOnsibWVzc2Fn
  ZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RkhKbGMybDZaVjkwYjE5c2FXM
  XBkRnNIYVFJZ0Eya0NJQU09IiwiZXhwIjpudWxsLCJwdXIiOiJ2YXJpYXRpb24ifX0=--4606
  eb8f856d674b899a4b32a30f5618fda47c16/image.png" />

The part we’re really interested in here is representations/redirect/. This is the default “Redirect Mode” and is the way Active Storage creates an indirection between the URL that we present in our web views and the actual location of the file. The link above will redirect to the service URL of the image in our case in the S3 bucket. This indirection through redirection means we don’t have to expose our bucket links in the markup, and allows for mirroring different services like S3, Google Cloud etc behind a single URL, for high-availability.

So in the example above, when the browser hits the URL in <img src= to GET the image, the response will be an HTTP 302 with a location header similar to below, which results in the client retrieving the attachment (image in our case) directly from the services S3 bucket.

> GET /rails/active_storage/representations/redirect/eyJfcmFpbHMiOn...

< HTTP/1.1 302 Found
< location: https://my-bucket-name.s3.eu-west-2.amazonaws.com/1g2fh...

The downside of this is these redirect links expire in 5 minutes by default, and are therefore not cacheable by design. For images, and CDNs this poses a problem. Enter, “Proxy Mode”!

1. Switch Active Storage to “Proxy Mode”

Active Storage has another mode called “Proxy Mode”. When this is enabled, the URL generated inside and img_tag will be something like this:

<img
  src="https://example.com/rails/active_storage/representations/proxy/
  eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbWdCIiwiZXhwIjpudWxsLCJwdXIiOiJi
  bG9iX2lkIn19--0131122555fe9f531867c4e7501550385b0a8b09/eyJfcmFpbHMiO
  nsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RkhKbGMyb
  DZaVjkwYjE5c2FXMXBkRnNIYVFISWFRSEkiLCJleHAiOm51bGwsInB1ciI6InZhcmlhd
  GlvbiJ9fQ==--8d286db4a314da0d738674a948c890d69a49abce/image.png" />

You should notice that the path now has representations/proxy rather than redirect, and this is a permanent URL that returns a 200 and the actual image file rather than a 302 redirect to the service URL.

So in this “proxy mode”, your Rails app will connect to the service URL source and download the file, apply any processing as needed, and return it to the calling client. This can be an expensive operation, hence the default “redirect mode” that offloads this processing by redirecting to the source file.

But what this means is now we can cache the file in a CDN, so after taking the hit once to download the file in our Rails app, forever more (unless the CDN is flushed) the file can be returned to clients directly from the CDN, completely bypassing the backend., and likely from a CDN node closer to your end user. Much faster!

OK, that was a lot of info, and we’ve not implemented anything yet! I wanted to make sure you understand the background first, but here we go!

You can enable proxying to be the default by setting config.active_storage.resolve_model_to_route = :rails_storage_proxy in an initializer, and while this is great and your image_tag will automatically generate a proxy URL, in my case I found I was unable to set the base URL when using this method, which is essential as we want to point all our Active Storage URLs to our CDN at cdn.example.com so we’re not going to do this.

The way recommended in the Rails Guides is to create a custom route, so lets do this.

Open up the config/routes.rb file and add the following custom route:

# config/routes.rb
direct :cdn_image do |model, options|
  if model.respond_to?(:signed_id)
    route_for(
      :rails_service_blob_proxy,
      model.signed_id,
      model.filename,
      options.merge(host: ENV['CDN_HOST'])
    )
  else
    signed_blob_id = model.blob.signed_id
    variation_key = model.variation.key
    filename = model.blob.filename

    route_for(
      :rails_blob_representation_proxy,
      signed_blob_id,
      variation_key,
      filename,
      options.merge(host: ENV['CDN_HOST'])
    )
  end
end

Notice we’re adding the CDN hostname in the routes here, so make sure to set ENV['CDN_HOST'] locally and on your deployment server. There are countless ways to do this so I’ll assume you have a preferred method. If not, something like dotenv-rails is a popular way locally.

We also need to set public: true in our storage configuration, in my case I’m using S3, so open up the config/storage.yml and add that to the end of your config. Here’s mine, just add the last line public: true to your service config:

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: eu-west-2
  bucket: my-bucket-name-<%= Rails.env %>
  public: true

2. Get an SSL cert for your sub-domain if you need one.

We’ll need to create a sub-domain for the CDN and you’ll be configuring this sub-domain with your registrar. If they can provide and SSL cert for your sub-domain, go ahead and set that up and move on to step 3. In my case I’m using Namecheap, and they don’t support SSL certs on subdomains, but fortunately we have options. Since we’re using S3 for the content store, and will be using Cloudfront for the CDN, it makes sense to use AWS Certificate Manager for this as its free, and we’re in AWS anyhow.

Go to the main services list at aws.amazon.com and navigate to ‘Certificate Manager’. There click “request certificate”, check the “public certificate” box and then “next”, where you’ll be asked for the Fully qualified domain name - in our case cdn.your-domain.com (where your-domain is.. er… your domain!) and finally click the “request” button, leaving all the default options as they are.

Now you should be able to see your certificate when you navigate to “List Certificates” but it will be in a “Pending” state. This is because we need to prove we own this domain. The way this is done best is through DNS. If you click on your certificate ID in the list, you should see the domain listed, with a value for “CNAME name” and one for “CNAME value”. We’re going to need to enter these on the registrar site in a minute so keep that window open.

Certificate Manager!

3. Add a Cloudfront distribution in front of our S3 bucket

Next up we need to create the Cloudfront distribution. In the AWS console site, from the services menu select “Cloudfront” and click the “Create Distribution” button. We need to add your origin bucket to to the “Origin domain” input, which should be shown in the list, and then further down the form in “Cache policy” select “CachingOptimzed”.

Next select “Disable WAF” and then click the “Add Item” button under “Alternate domain name (CNAME)” and add your sub-domain name eg cdn.your-domain.com, Lastly select our new certificate from step 2 above in the “Custom SSL certificate” section, then click “Create Distribution”. Voila, you should see a new distribution in the list. Make a note of its domain name. It will look something like abcd12efghij.cloudfront.net

Cloudfront Distribution!

4. Set up a sub-domain with your registrar

This will be slightly different depending on your registrar, but for Namecheap at least, I needed to go to “Advanced DNS” for my domain, but whatever the registrar we need to add 2 new CNAME records. First one is the subdomain, so set type = “CNAME”, host = “cdn” and value = “abcd12efghij.cloudfront.net” (this will be your cloudfront distribution domain).

The second CNAME record we need to add is for verification of the certificate. Back in the Certificate Manager in AWS which you hopefully have in another tab, grab the “CNAME name” listed for your certificate domain and paste it in to the DNS section at your registrar site. For Namecheap this goes under “Host” column. Note, the “CNAME name” will look something like _2bd37b27854d40c76213cdc93ca4b7ea.cdn.example.com.. For whatever reason, at Namecheap I needed to strip the .cdn.example.com. part from the end to get this to work. I did mention I spent all day on this didn’t I? This was part of the reason!!

Do the same for the “CNAME value” in the Certificate Manager and paste this in to the “Value” section of your DNS record.

DNS Configuration!

Once the subdomain is configured, if you go back to Certificate Manager in AWS, you should see the “Pending” label under “Status” has changed to “Success”! This means AWS was able to validate you own the domain via its DNS.

NOTE: I had some issues with this, and as mentioned had to truncate the “CNAME name” to make it work, but all being well you got there!

5. Update the views in your Rails app to serve images from the CDN

Lastly we need to update our views to use the new cdn_image route we defined earlier. So, in our app, for any Active Storage images we want to update the image_tag to pass in the cdn_image_url.

One way to do this would be to change tags as follows:

# old image tag
<%= image_tag(user.photo) if user.photo.attached? %>

# changed to use cdn_image_url
<%= image_tag(cdn_image_url(user.photo)) if user.photo.attached? %>

You could also tidy this up a bit with a helper. Here’s what I did since all my attached images are named photo and have variants for :thumb and :main, but you could adapt this to suit:

def cdn_image_tag(record, variant, *options)
  if record.photo.attached?
    image_tag(cdn_image_url(record.photo.variant(variant)), *options)
  else
    image_tag("placeholder_100x100.png", *options)
  end
end

Then I can clean up my markup like this:

# old image tag
<%= image_tag(user.photo) if user.photo.attached? %>

# changed to use cdn_image_tag helper
<%= cdn_image_tag(user) %>

Wrapping up

Now all our images that use the cdn_image_tag will have the https://cdn.example.com host prefix, have permanent proxy URL paths, and should be lightning fast in the browser for our end users which was the goal!

I hope this was useful. It’s complicated and lots of moving parts so don’t hesitate to drop me a line if you have questions of need help!