A Guide to Caching Your Rails Application With Fastly

Jessie Young

In this blog post, I will explain how Fastly works and how to set up Fastly in a Rails application. I will also give some troubleshooting tips based on my experiences implementing Fastly in a Rails application.

Understanding CDNs and cache invalidation

Fastly is a content delivery network (CDN). A CDN is a system of distributed servers that deliver web content to a user based on geographic location.

Giant Robots (this blog) uses Fastly. If you are reading this post from Australia, the content you are reading was likely retrieved from a different web server than the content that my coworkers here in San Francisco are reading.

You and my coworkers are reading the same content, just delivered from different locations to accelerate load time. But what if the blog post has a typo and I push up a new version? How will these distributed servers know that the content they hold has changed?

The answer to these questions is: cache invalidation. But what does that really mean?

Caching, most generally, refers to any way a computer stores something so that it can be quickly accessed later on. Browsers, for example, cache web pages by storing a copy of the pages you visit and then use that copy when you re-visit rather than retrieving all of the data from the web server again.

Cache invalidation is the process by which you tell the services that cache your content that they are no longer up to date and need to retrieve updated content rather than deliver the outdated copy that they hold. Cache invalidation is important because without it we could not cache dynamic web content.

Understanding Fastly caching

The simplest way to do cache invalidation is to “purge” the entire cache each time a change is made in your application. This means that you tell service that is caching your site - in this case, Fastly - to retrieve all new content because the cache is no longer a valid representation of your site. This can work nicely for a relatively static site, such as a blog, that is only updated once a day. But what if your blog has comments and gets a new comment every minute?

This is where Fastly really shines. As opposed to requiring you to purge the entire cache for every change to your web application, Fastly uses what they call “surrogate keys” to dynamically determine which pages of your application need cache invalidation and which remain the same.

The Surrogate-Key HTTP header values tell Fastly which pieces of dynamic content are on a particular page. Continuing with the blog example, you might have a Surrogate-Key value of posts on the home page and then a value of posts/1 on the page that just shows your first post. When you update the first post, you will want Fastly to purge the home page (because it shows the first post) and the page that shows just the first post, but not the rest of the blog post pages, because those were unchanged.

Think of the Surrogate-Key header as a map that tells Fastly where each item in your system appears. When that item changes, Fastly goes to each location on the map and refreshes the content so that it is up to date.

While you can purge the entire cache for a site that uses Fastly, page-specific cache invalidation means that you are not purging the cache for pages that remain the same. Preserving the cache for most pages means that Fastly only needs to hit your web servers when necessary.

A note about edge caching

Confusingly, Fastly’s documentation uses the terms “CDN” and “edge caching” interchangeably. Edge caching is a specialized form of edge computing, an umbrella term that refers to pushing “applications, data and computing power (services) away from centralized points to the logical extremes of a network”.

Edge caching is a form of caching that uses de-centralized servers “to move content ‘closer’ to the end-users that view it to avoid the latency that occurs as packets traverse longer distances across the network.” Fastly provides an edge caching service through its CDN.

Now that you have a basic understanding of how Fastly works, let’s dive into implementing Fastly in a Rails application.

Getting started with Fastly

DNS Setup

After you’ve signed up for Fastly, you’ll need to do a little configuration to get it going:

  1. Set the host.

In my case, I am using Heroku, so my address is a Heroku hostname. The “Address” field is cut off, but it is the same value as the “Name” field.

Fastly host configuration

  1. Set the domain.

Set the domain to whatever you want your site’s domain to be. In my case, it was www.jessieayoung.com

Fastly domain configuration

  1. Set the CNAME for your site to the Fastly endpoint. In my case it was www.jessieayoung.com.global.prod.fastly.net. I did this via DNSimple, which is what I use to manage my domains.

  2. Confirm that your DNS setup is working.

Once the DNS changes propagate, you should see some new headers when you curl the site you are caching.

Compare the headers of the apex domain, which I have not set up to use Fastly and my www subdomain, which is pointing to the Fastly URL above:

% curl --silent --verbose --output dev/null jessieayoung.com

< HTTP/1.1 200 OK

...

< X-Rack-Cache: miss

vs:

% curl --silent --verbose --output dev/null www.jessieayoung.com

< HTTP/1.1 200 OK

...

< X-Served-By: cache-lax1426-LAX
< X-Cache: MISS
< X-Cache-Hits: 0
< X-Timer: S1415387356.979543,VS0,VE1212

These new headers are being set by Fastly. I am getting a MISS, which means that curl is hitting my web server rather than the cached version of the site. This is because I haven’t added Fastly to my Rails app. Next step: setting up the fastly-rails gem.

Rails setup

Install the fastly-rails gem:

# Gemfile
gem "fastly-rails"
% bundle

Add the Fastly environment variables to your .env file:

# .env
FASTLY_API_KEY=replace_me
FASTLY_SERVICE_ID=replace_me

And add the Fastly configuration file:

# config/initializers/fastly.rb
FastlyRails.configure do |config|
  config.api_key = ENV.fetch("FASTLY_API_KEY")
  thirty_days_in_seconds = 2592000
  config.max_age = thirty_days_in_seconds
  config.service_id = ENV.fetch("FASTLY_SERVICE_ID")
end

Make sure you set these environment variables in your application’s staging and/or production environments. Fastly’s documentation contains detailed information on where to find your Fastly API key and Service ID.

Setting the correct headers

Now that you have your environment variables for Fastly, you are ready to set the Surrogate-Key header on the pages of your Rails app that you want to cache and purge. As the section on understanding Fastly caching above explains, Fastly relies on the values of the Surrogate-Key header to determine which pages to purge when data in your application changes.

The fastly-rails gem provides a convenience method of set_cache_control_headers that sets the Surrogate-Key header. Set a before_action of set_cache_control_headers on every GET controller action you would like to cache.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_cache_control_headers, only: [:index, :show]

Now, in the controller methods, you can use the set_surrogate_key_header method to tell Fastly what the values of Surrogate-Key should be for that page:

# app/controllers/posts_controller.rb

...

def index
  @posts = Post.all
  set_surrogate_key_header Post.table_key, @posts.map(&:record_key)
end

def show
  @post = Post.find(params[:id])
  set_surrogate_key_header @post.record_key
end

The fastly-rails gem adds the record_key method to any instance of an ActiveRecord::Base class. For a post with an id of 1, post.record_key would return posts/1. It also adds the table_key method to every ActiveRecord::Base class, which returns the name of the model name. In the case of Post, it returns posts.

Passing an array of surrogate keys to set_surrogate_key_header tells Fastly which objects are included on that page so that when it comes time to purge, Fastly will know which pages display your object and thus need to be purged.

Selectively purging records

Setting the surrogate keys, like you did in the previous section, tells Fastly which objects are included on each page. Now, you need to tell Fastly when to purge the pages for each object. This can be done in the controller or via callbacks.

Including these purges in the controller will look like this:

# app/controllers/posts_controller.rb
  def create
    @post = Post.new(post_params)

    if @post.save
      @post.purge_all
      render @post
    end
  end

  def update
    @post = Post.find(params[:id])

    if @post.update(params)
      @post.purge
      render @post
    end
  end

  def delete
    @post = Post.find(params[:id])

    if @post.destroy
      @post.purge
      @post.purge_all
    end
  end
end

Including these purges in the controller is ideal. If your application data is not hitting a controller during creation / deletion / update (eg: you are updating via Rails console or using an engine like RailsAdmin), you can instead include these methods as callbacks:

# lib/active_record_extension.rb
if Rails.env.staging? || Rails.env.production?
  module ActiveRecordExtension
    extend ActiveSupport::Concern

    included do
      after_create :purge_all
      after_save :purge
      after_destroy :purge, :purge_all
    end

    ActiveRecord::Base.send(:include, ActiveRecordExtension)
  end
end

Creating a Rake task to purge all

While the setup above takes care of purging individual records, you will also want to purge your entire Fastly cache after each deploy of your application. This is easy to do via the Fastly web UI, but it’s even easier to do with this small Rake task:

# lib/tasks/purge_fastly_cache.rake
namespace :fastly do
  desc "Purge Fastly cache (takes about 5s)"
  task :purge do
    require Rails.root.join("config/initializers/fastly")
    FastlyRails.client.get_service(ENV.fetch("FASTLY_SERVICE_ID")).purge_all
    puts "Cache purged"
  end
end

Debugging tip: Fastly does not cache HTTP responses with cookies

As the fastly-rails README explains,

“By default, Fastly will not cache any response containing a Set-Cookie header. In general, this is beneficial because caching responses that contain sensitive data is typically not done on shared caches.”

The set_surrogate_key_header method removes the Set-Cookie header. In some cases, you may curl and find that the header is still there. After a thorough investigation, I found that my authentication library was adding Set-Cookie back in after it was removed by fastly-rails. Because Fastly does not cache responses with a Set-Cookie header, this meant that none of my pages were being cached.

To debug this behavior, I ran the handy rake middleware task, which outputs the middleware stack for the application. In the output, I saw the name of my authentication library’s middleware. To test whether this middleware was the culprit, I temporarily added the following:

# config/application.rb
config.middleware.delete "ExampleMiddleware"

and ran curl on my site again. When I confirmed that the Set-Cookie header was no longer present, I knew which piece of middleware to override. To re-delete the Set-Cookie header, I placed the fastly-rails middleware directly before my authentication middleware:

config.middleware.insert_before(
  ExampleMiddleware,
  "FastlyRails::Rack::RemoveSetCookieHeader"
)

With that, Set-Cookie was no longer being set and I had Fastly caching up and running.

Confirming that Fastly is working

To confirm that Fastly is working, run curl on your site a few times and confirm that the X-Cache header is returning a HIT. Once it is, you’re good to go!

Credits

Big thank you to the Fastly team for all of their help in getting my project set up and working properly. And a second big thank you to Harlow for helping me solve the Set-Cookie issue and being an-all around awesome dude.

Further reading