ruby / fingerprint

I use file-based asset fingerprinting in Ruby web apps to enable aggressive caching with CDNs.

The approach

As part of the deployment build, after building assets with esbuild, a Rake task fingerprints the files:

require "digest"

namespace :assets do
  task :precompile do
    ["public/css/app.css", "public/js/app.js"].each do |old_path|
      hash = Digest::MD5.file(File.expand_path(old_path, __dir__))
      ext = File.extname(old_path)
      base = old_path.chomp(ext)
      new_path = "#{base}-#{hash}#{ext}"
      system "mv #{old_path} #{new_path}"
    end
  end
end

The renamed files are served from public/:

public/app.css  -> public/app-a1b2c3d4.css
public/app.js   -> public/app-a1b2c3d5.js

Rack configuration

Serve static files with Rack::Static middleware and set cache headers per path.

Rack::Static applies header_rules based on URL prefix, regardless of HTTP status code. A 404 on /js/app-missing.js gets the same Cache-Control: public, max-age=31536000, immutable as a 200. If a CDN caches that 404, it serves the error to every user in that geography until the cache expires or is purged.

Wrap Rack::Static to only cache 200 responses:

class SafeStaticCache
  def initialize(app, **opts)
    @static = Rack::Static.new(app, **opts)
  end

  def call(env)
    status, headers, body = @static.call(env)
    if status != 200
      headers["Cache-Control"] = "no-store"
    end
    [status, headers, body]
  end
end
use SafeStaticCache,
  urls: ["/css", "/js", "/favicon.ico"],
  root: "public",
  header_rules: [
    # fingerprinted by rake assets:precompile
    ["/css", {"Cache-Control" => "public, max-age=31536000, immutable"}],
    ["/js", {"Cache-Control" => "public, max-age=31536000, immutable"}],

    # not fingerprinted
    ["/favicon.ico", {"Cache-Control" => "public, max-age=86400"}]
  ]

Since filenames include content hashes, each URL is immutable. Browsers and CDNs can cache 200s aggressively (1 year) without risk of serving stale content.

The immutable directive eliminates revalidation requests even on page reload.

Deployment

Example build command for Render:

npm install && \
npm run build && \
bundle install && \
bundle exec rake db:migrate && \
bundle exec rake assets:precompile

This:

  1. Installs JavaScript dependencies
  2. Builds and bundles with esbuild
  3. Installs Ruby dependencies
  4. Migrates the database
  5. Fingerprints static assets

Template integration

At boot, resolve fingerprinted paths:

# see rake assets:precompile definition in Rakefile
# and ui/views/layouts/application.haml

app_css_path = "/css/app.css"
app_js_path = "/js/app.js"

if ["staging", "production"].include?(ENV.fetch("APP_ENV"))
  root = File.expand_path("../..", __dir__)
  css_path = Dir.glob("#{root}/public/css/app*.css")&.first
  if css_path
    app_css_path = css_path.split("public")[1]
  end
  js_path = Dir.glob("#{root}/public/js/app*.js")&.first
  if js_path
    app_js_path = js_path.split("public")[1]
  end
end

APP_CSS_PATH = app_css_path.freeze
APP_JS_PATH = app_js_path.freeze

In views such as ui/views/layouts/application.haml:

%link{ rel: "stylesheet", href: APP_CSS_PATH }
%script{ src: APP_JS_PATH }

← All articles