When someone says that you need a “100 grams” to figure something out, it means that it’s completely unobvious and complicated and therefore you need a few vodka shots. Vodka in Russia is measured in grams – 100g being something you would casually drink for breakfast. Alright, today we’re going to figure out how to make Amazon Cloudfront actually work with static assets, Rails, Jammit and Heroku. Get your vodka glass, you’re going to need one.
First some background.
Syntactically Awesome Stylesheets (SASS) to CSS
SASS is a way to author CSS. We write all stylesheets using SASS and place them into app/stylesheets. These are compiled with compass and placed into public/stylesheets. Note that stylesheets often reference images in various tags, such as background. Those images in our system are added to public/assets/images. There’re a few good articles that dwell into SASS itself, including this one.
CoffeeScript to JavaScript
CoffeeScript is a language that compiles into JavaScript. We use Backbone.js heavily and write all javascript in coffee. Our files live in app/coffeescripts. Coffeescript is compiled with barista and the output is placed into public/javascripts.
Asset Packaging
CSS files generated by SASS and JS files generated by coffee are packaged together by Jammit. The latter uses a configuration file to specify what is to be packaged and how. Here’s a simplified version of our config/assets.yml .
Generating Assets
We use a simple Rake task to generate assets heavily inspired by this gist (bonus clean task included). I can run rake assets and get output in public/assets that is a mix of checked in (eg. public/assets/images) and generated (eg. public/assets/common.js and common.js.gz) files.
Amazon Simple Storage Service (S3) + CloudFront
Amazon S3 offers virtually infinite storage and a way to distribute content worldwide with CloudFront. You upload a file to an S3 bucket and it gets distributed worldwide to region-based endpoints. This way if someone from Japan hits your server, you can serve static files from a local data center in Japan. In addition, unlike S3, CloudFront can negotiate content - we can package assets into .gz files and serve those when the browser is capable of receiving compressed content. The latter makes mobile experience magnitude times better.
We’ve distributed our bucket on the CloudFront dashboard on Amazon and pointed our own DNS server for static.example.com to somemagicnumber.cloudfront.net. This way I can go to https://static.example.com/assets/common.css and see the assets/common.css file that I have uploaded to S3.
Distributing to CloudFront has one major caveat: data is cached for at least 24 hours, so you cannot use regular cache-busting techniques, such as appending a timestamp to every url. This is where things will get complicated. In the meantime, lets tell our application to use an environment variable CLOUDFRONT_URL where available or S3_BUCKET otherwise. This can be done in config/environment.rb _before _Application.initialize!.
We can now configure Heroku applications with either a CLOUDFRONT_URL
or an S3_BUCKET
depending on the environment.
heroku config:add CLOUDFRONT_URL=https://static.example.com
Examining the page source we’ll see that https://static.example.com has been inserted for all references to .css files.
Versioning and Cache Busting
If I update the assets/common.css file in S3, changes won’t appear on static.example.com for at least 24 hours. That’s not going to work for continuous deployment.
The solution is to version our asset files. We chose to use the git hash.
$ git rev-parse HEAD
50fd3fcfa592eaac16cce6b3c508cf2487749bb0
Instead of uploading assets/common.css we’ll upload assets/50fd3fcfa592eaac16cce6b3c508cf2487749bb0/common.css. The rake task that performs the upload will also delete the existing files that are being replaced by a new hash. We typically run something like rake assets:push:to_staging. Note that this task needs some pre-requisite functions, read this post for getting started with S3 and Rake.
We now need to tell our Rails application about this hash. We’ll adjust our config/environment.rb as follows.
Let’s set ASSET_HASH
on Heroku with the hash value before every deployment.
Assets are now served from a new directory with every deployment, effectively working around the CloudFront cache limitations.
Unpleasant Surprise with Static Images
After implementing the hashing solution I had an unpleasant surprise: CSS files were referencing static images with absolute paths, such as /assets/images/logo.png. This doesn’t work because it renders https://static.example.com/assets/images/logo.png. There’s no way to insert the hash into this at compile time (chicken-and-egg problem). No big deal, we can just make this path relative, right? Unfortunately Jammit rewrites relative paths (#167) which transforms assets/images/logo.png into ../stylesheets/images/logo.png. Fortunately it’s open-source, so I added a new rewrite_relative_paths option on my fork.
Adding rewrite_relative_paths = off
to config/assets.yml causes Jammit to leave the relative URLs alone.
Heroku Predeploy
Let’s summarize what happens for us with every deployment.
- Copy our production database to the target environment unless we’re deploying to production. [blog post]
- Synchronize image data (we have lots of images) between the production and the target S3 bucket unless we’re deploying to production. [blog post]
- Push assets to S3 under the current git hash (see above).
- Set
ASSET_HASH
on the target Heroku app (see above).
We wrap this up in a heroku:predeploy
task and use Heroku-Bartender to deploy.
Suggestions for improvements always welcome!