CORS font issues with Rails, Heroku, CloudFront and Passenger


Ever saw a log in your browsers console saying some resources like web fonts could not be loaded because Access-Control-Allow-Origin headers were missing? Did you think “oh, this should be easy” and then spent hours of searching through various misleading articles and even more hours applying those advices and still failing? Well, I sure did and here’s the story and how I finally won.

TL;DR;

You need to get Passenger’s nginx template, modify it to attach CORS headers and use it instead of the default one.

The Setup

My Rails app is hosted on Heroku and assets are served from the CloudFront distribution that has custom origin pointing back to the Rails app. Heroku precompiles my assets during slug compilation and stores them under the folder public/assets (check assets and cloudfront Heroku documents for details). All that is powered by standalone Passenger, just recently upgraded from Unicorn.

My config/environments/production.rb file contains something like this:

config.serve_static_assets = true
config.action_controller.asset_host = "//something.cloudfront.net"

First line means that my app’s assets will be served from the Rails app and not from nginx. Actually, Rails will inject here a special middleware (previously Rack::Static and more recently ActionDispatch::Static) and serve all files from the folder public. So whenever some resource is requested from the web app, it is first inspected by the middleware. If the file is found, it will be served directly from the file system. If not, the request will travel through the usual Rails routing and controllers stuff. This is useful if we would like to control custom headers for those resources.

I know what I’m doing…

The issue of missing CORS headers for web fonts was, I thought initially, a walk in the park. First I would need to inject manually those CORS headers by using some middleware injection magic, or even better, I would use font_assets gem. Then I would invalidate font assets in CloudFront to force a cache refresh and to get proper CORS headers. Unfortunately, it didn’t work. Whatever I’ve tried, CORS headers were nowhere to be seen.

Sobering up

Of course, the real breakthrough came only until I started paying much closer attention to what was being returned from those requests. If I requested a valid resource that existed in the public folder, I got Server: nginx/1.6.1 But if I requested some file that didn’t exist I got Server: nginx/1.6.1 + Phusion Passenger 4.0.50 along with all the CORS headers I could ever hoped for (and 404 error too). Which means that my serve_static_assets setting didn’t work; nginx was somehow instructed to serve my static assets, without my consent.

It turned out that Passenger standalone gem is installing its own nginx configuration file, compared to much simpler Unicorn gem I previously had. Here’s how it looked like in the original config.erb:

# Rails asset pipeline support.
location ~ "^/assets/.+-[0-9a-f]{32}\..+" {
    error_page 490 = @static_asset;
    error_page 491 = @dynamic_request;
    recursive_error_pages on;

    if (-f $request_filename) {
        return 490;
    }
    if (!-f $request_filename) {
        return 491;
    }
}
location @static_asset {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
    add_header ETag "";
}
location @dynamic_request {
    passenger_enabled on;
}

Let me explain those couple of lines:

  • If a resource contains assets in path, contains a digest in its name and actually exists on the file system, it will be treated as a static resource and server by the location @static_asset setting.
  • If such resource’s file doesn’t exist on the file system, it will use location @dynamic_request i.e. go to the Rails app via Passenger.
  • If a resource doesn’t contain assets in path and/or doesn’t contain 32 characters digest in its name, it will always be treated as a static content with the usual location @static_asset code. My web fonts were such resources.

The Solution

What I did then is pretty straightforward; I copied that whole template, stored it in my config folder and modified it a bit. Here’s what I’ve changed to serve CORS headers (but only with web fonts):

# Rails asset pipeline support.
location ~ "^/assets/.+-[0-9a-f]{32}\..+" {
    error_page 490 = @static_asset;
    error_page 491 = @dynamic_request;
    recursive_error_pages on;

    if (-f $request_filename) {
        return 490;
    }
    if (!-f $request_filename) {
        return 491;
    }
}
# Fonts in assets that don't contain digest in file name.
location ~ "^/assets/.+\.(eot|svg|ttf|otf|woff)" {
    error_page 490 = @static_asset_fonts;
    error_page 491 = @dynamic_request;
    recursive_error_pages on;

    if (-f $request_filename) {
        return 490;
    }
    if (!-f $request_filename) {
        return 491;
    }
}
location @static_asset {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
    add_header ETag "";
}
location @static_asset_fonts {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
    add_header ETag "";
    add_header 'Access-Control-Allow-Origin' '*';
    add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS';
    add_header 'Access-Control-Allow-Headers' '*';
    add_header 'Access-Control-Max-Age' 3628800;
}
location @dynamic_request {
    passenger_enabled on;
}

Besides modifying nginx template, I needed to add to Procfile –nginx-config-template parameter and a path to my copy of template (for that parameter to work you need Passenger >= 4.0.39).

web:
  bundle exec passenger start -p $PORT \
  --max-pool-size ${WEB_CONCURRENCY:-3} \
  --nginx-config-template ./config/passenger_config.erb

The only remaining thing is to remember to update i.e. merge Passenger’s nginx template with my changes whenever I decide to update that gem.


comments powered by Disqus