CloudFront CDN with Rails on Heroku

(or, Everything You Never Wanted To Know About Serving Webfonts From A CDN)

by Leon Miller-Out

TL;DR

If you're using a CDN to serve your static assets, your webfonts may break if you don't provide the correct CORS headers.

Update: be sure to turn "Forwarded Query Strings" on when configuring your CloudFront. Rails uses query strings to make sure your users see the latest versions of your assets.

Update 2: The font_assets gem seems to do most of what I was doing manually.

Intro

I learned a lot the other day about caching, CDN, and webfonts. I hope I can save someone else some time by sharing this knowledge!

I started out with an idea: see if I could implement Amazon's CloudFront CDN on this site. Our site wasn't exhibiting performance problems, but I had heard CloudFront was great and wanted to experiment with it so I could learn whether it would work well for our clients.

Initial Setup

At first, it seemed like it would be pretty simple. I set up a CloudFront Distribution with an origin of http://singlebrook.com. This meant that the CDN would fetch any requested content from our site at Heroku, then cache it and distribute it to their network of edge caches so that it could be served very quickly for any subsequent requests. Once this was done, I could visit the new CloudFront.net subdomain and see our site there.

I was primarily interested in CDN'ing our static assets (images, JS, CSS, and webfonts). The next step was to tell Rails to use the new CloudFront hostname as its asset host by setting config.asset_host in config/environments/production.rb. Since we use Dragonfly for image management, and it is responsible for creating URLs for the images it manages, I also had to tell it about the new asset host. This was a pretty simple change: I just set config.url_host in config/initializers/dragonfly.rb.

Once this was done, I was able to confirm that the app was constructing CloudFront URLs for all of our static assets! I rolled this out to production, and all seemed well.

Webfonts fail!

A short while later, Alexandra noticed that the custom font-based social networking icons in our footer were not working in Firefox! Here's my lesson 1: Test new caching mechanisms in multiple browsers! I had only tested in Chrome.

A quick Googling revealed that Firefox (and IE) will refuse to load webfonts from domains other than the one listed in the address bar. Since my webfont file references in my CSS file were using root-relative URLs, and the CSS file was being served by CloudFront, the browser was loading the webfont files from CloudFront as well.

@font-face {
    font-family: 'singlebrookRegular';
    src: url('/fonts/singlebrook-webfont.eot');
    src: url('/fonts/singlebrook-webfont.eot?#iefix') format('embedded-opentype'),
         url('/fonts/singlebrook-webfont.woff') format('woff'),
         url('/fonts/singlebrook-webfont.ttf') format('truetype'),
         url('/fonts/singlebrook-webfont.svg#singlebrookRegular') format('svg');
    font-weight: normal;
    font-style: normal;
}

The trick to allowing the browser to use fonts from a different domain is to set up CORS (Cross-Origin Resource Sharing) headers for the fonts. "Great!", I thought. "I just need to add some custom headers. That should be no problem." Ha! Turns out it's pretty tricky when you're talking about static assets an older Rack-based web server.

Shoehorning in CORS on Heroku

CloudFront doesn't let you set a CORS policy; it just passes through whatever it gets from the origin server (in this case my Rails app). So the question at hand was: how do static assets get served on Heroku? Since there's no web server other than the one running your Rails app, Heroku forces your Rails app to serve static assets. This means that they end up being served by the Rack::Static middleware. So, somehow I needed to tell Rack to provide the Access-Control-Allow-Origin and other HTTP headers for URLs starting with "/fonts".

After looking around a little bit, I found the rack-cors gem, which seemed to be exactly what I needed. It's a Rake middleware for adding the various CORS headers. I popped it into config.ru (the "rackup" file used by Rack to start your Rails app) and it worked like a charm in my local environment. I tested it with "curl -I -H 'Origin: *' http://localhost:3000" and saw that the Access-Control-Allow-Origin header was included in the response! I tested the webfont loading in Firefox by setting my asset_host to http://intranet.lvh.me:3000 so Firefox would load the font files from a different domain than what I was looking at. That worked too! I happily pushed the new code to Heroku, only to find a big Application Error when I hit the site. This brings me to lesson 2: Always test your changes in a staging environment that is similar to the production environment. Rack had thrown an undefined constant error when it saw Rack::Cors in my config.ru. I suspect that this is because the web server isn't being started with Bundler on the Bamboo stack, so the "require: 'rack/cors'" in my Gemfile wasn't happening, but I'm not sure about that.

My next attempt was to move the Rack::Cors configuration into application.rb ("config.middleware.use Rack::Cors..."). This didn't add the headers because the assets are not getting served by Rails.

I tried to see if I could just add the custom headers to Rack directly, in the config.ru, but Rack::Static's only gotten support for that in the last month or so. I can't upgrade to Rack's master branch because Rails 3.0 depends on Rack 1.2, and master is based on 1.4.

Finally, I added "require 'rack/cors'" to my config.ru file before using Rack::Cors, and that made the fonts work correctly in Firefox and MSIE with the site on Heroku.

# config.ru
# Allow font files to be loaded from anywhere (for loading webfonts in Firefox)
require 'rack/cors'
use Rack::Cors do
  allow do
    origins '*'
    resource '/fonts/*', :headers => :any, :methods => :get
  end
end

Conclusion

Since we're still running on Heroku's Bamboo stacks, an automatic Varnish reverse cache sits in front of singlebrook.com. As long as we're setting the right cache headers in our app, Varnish will cache requests and respond to them before they get to my Rails app.

Under the hood, CloudFront is actually a Varnish-based, globally-distributed cache system. So, putting it in front of an app on Bamboo is unnecessarily caching the cache. It still provides the benefits of the multiple-edge-location CDN, but it doesn't keep any additional traffic away from the Rails app. However, having CloudFront in place will greatly ease our upcoming migration to Cedar, which doesn't include a Varnish caching layer.