I’ve been building a number of apps using Rails recently and they run on servers that are, shall we say, not particularly optimal. I’m from Yorkshire and I live in Scotland so thriftiness is ingrained.
In a standard Rails app, all the bundled assets – your Javascript and CSS – are served directly from the app. The HTML will look something like this:
<link rel="stylesheet" href="https://app.teamlight.net/assets/application-629486516f003a1f5753039c95ff1e88c1b1fa8ca5f0cdfb5e611e09aee60f91.css" data-turbo-track="reload" />
<script src="https://app.teamlight.net/assets/application-86f9baccdd5ffcc51d3b67d2715e4cbe8350ca0213a6fe28fdcc1cc6402ea88d.js" data-turbolinks-track="reload"></script>
It’s nice and simple but you might want to offload this traffic from your app to a CDN. This will save you CPU cycles and it will make your app feel snappier for people. It can make a big difference.
I decided to try and do this for my apps using AWS Cloudfront as the CDN. It was a little fiddly to get my head around so I decided to write it up here in case you’ve been trying to do the same. Hopefully it will just work™ but, of course, YMMV.
There are two different configurations you’ll need to go through depending on your use case. The first is for serving Rails assets (which all Rails apps can use), the second for ActiveStorage. I’ll deal with each in turn.
Rails makes it easy to change the name of the host used for serving assets. All you need to do is set the asset_host
config like this in your production.rb
:
config.asset_host = ENV['ASSET_HOST']
In my use case, I wanted to keep the mailer asset host on my own domain (not on the CDN), so I have a separate line in my config for this:
config.asset_host = ENV['ASSET_HOST']
config.action_mailer.asset_host = "https://app.teamlight.net"
As we haven’t configured a CDN yet, you should set ENV['ASSET_HOST']
to your app domain for the time being.
Creating a “distribution” in Cloudfront is fairly straightforward. I braved the AWS UI to do this but if you’re cleverer than me you could write Terraform or CloudFormation to automate it. Here’s how I did it in the UI.
First create a new distribution. This will give you a domain which represents the CDN for your assets and it will look something like https://askj23h42jk.cloudfront.net.
Access-Control-Allow-Origin https://app.teamlight.net
Access-Control-Allow-Credentials true
Access-Control-Allow-Headers Origin, X-Requested-With, Content-Type, Accept
Access-Control-Allow-Methods GET, OPTIONS
CORS-with-preflight-and-SecurityHeadersPolicy
.
That’s it! Simple, right? 💀
Your new distribution will have a domain like https://d28o4jfr91no89.cloudfront.net. Now you can configure your app so that the ASSET_HOST
environment variable is set to this domain and, voila, your assets are now being served via a CDN! 🥳🤞
<link rel="stylesheet" href="https://d28o4jfr91no89.cloudfront.net/assets/application-629486516f003a1f5753039c95ff1e88c1b1fa8ca5f0cdfb5e611e09aee60f91.css" data-turbo-track="reload" />
<script src="https://d28o4jfr91no89.cloudfront.net/assets/application-86f9baccdd5ffcc51d3b67d2715e4cbe8350ca0213a6fe28fdcc1cc6402ea88d.js" data-turbo-track="reload" type="module"></script>
This is where the big prize is. In one app I built recently I was dealing with a lot of photo attachments (up to 500 per customer order), and they all lived on S3. I didn’t want my photo gallery requests hitting the app, or even my Caddy server. I wanted them to be served directly from my CDN, so they loaded quickly wherever the user was and so my app didn’t have to deal with all the image requests.
storage.yml
will look something like this:
s3:
service: S3
access_key_id: <%= ENV["AWS_ACCESS_KEY_ID"] %>
secret_access_key: <%= ENV["AWS_SECRET_ACCESS_KEY"] %>
region: eu-west-2
bucket: [YOUR S3 BUCKET NAME]
default_url_options
in your production.rb
file:
config.action_controller.default_url_options = { host: "yourdomain.com" }
production.rb
file:
config.active_storage.delivery_method = :proxy
Configure the behaviour by clicking on the “Behaviour” tab, select your S3 bucket as the origin and choose the HTTP and HTTPS protocol policy.
In the Caching section, choose the CachingOptimized
cache policy:
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::[YOUR S3 BUCKET NAME]/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "[YOUR CLOUDFRONT ARN]"
}
}
}
]
}
routes.rb
that points to the CDN if the ACTIVE_STORAGE_ASSET_HOST
is configured:
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 ENV.fetch("ACTIVE_STORAGE_ASSET_HOST", false) && blob&.key
File.join(ENV.fetch("ACTIVE_STORAGE_ASSET_HOST"), blob.key)
else
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)
end
end
rails_public_blob_url
route:
<%= image_tag rails_public_blob_url(photo.image.variant(:thumb)), id: dom_id(photo) %>
Note: If you’re using ActionText
(like I am in Teamlight) you can edit the _blob.html.erb
partial to use this route, e.g:
<% if blob.representable? %>
<img src="<%= rails_public_blob_url(blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ])) %>" />
<% end %>
ACTIVE_STORAGE_ASSET_HOST
to point to your new Cloudfront distribution domain and restart your server. Your ActiveStorage images should now be served from the CDN! 🎉
<img id="photo_5542" src="https://d48khrpvou8bp.cloudfront.net/y8u334heoeml2jjqwa6cjh9zu5j6">
There are probably nuances I’ve missed here, but I’ve done it a few times and this approach seems to work well for me. There are a lot of steps to go through and it’s easy to miss something, so I’d recommend creating development buckets to test with first just to make sure it all works.
If the worst happens and you deploy to production and it goes pear shaped, just change your ASSET_HOST
environment variable back to your app domain (for assets), or remove ACTIVE_STORAGE_ASSET_HOST
(for ActiveStorage), and restart your app to get things back to normal.
Good luck!