DNS to CDN to origin

Content Distribution Networks (CDNs) pull content from their origin server during HTTP requests to cache them:

DNS -> CDN -> Origin


Cloudflare DNS -> Cloudflare CDN -> Render

Without an asset host

If a CNAME record for a domain name points to a Rails app on Render:

www.example.com -> example.onrender.com

The first HTTP request for a static asset:

The logs will contain lines like this:

GET "/assets/app-ql4h2308y.js"
GET "/assets/app-ql4h2308y.css"

This isn't the best use of Ruby processes; they should be reserved for handling application logic. Response time is degraded by waiting for processes to finish their work.

With a CDN as an asset host

In production, esbuild (more info below) appends a hash of each asset's contents to the asset's name. When the file changes, the browser requests the latest version.

The first time a user requests an asset, it will look like this:

GET www.example.com/app-ql4h2308y.css

A Cloudflare cache miss "pulls from the origin", making a GET request to the origin, stores the result in their cache, and serves the result.

Future GET and HEAD requests to the Cloudflare URL within the cache duration will be cached, with no second HTTP request to the origin.

All HTTP requests using verbs other than GET and HEAD proxy through to the origin.

esbuild config

I recommend using esbuild instead of the Rails asset pipeline.

Example package.json configuring React and TypeScript with linting and typechecking:

  "name": "app",
  "private": "true",
  "dependencies": {
    "esbuild": "^0.17.15",
    "esbuild-sass-plugin": "^2.8.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-select": "^5.7.2"
  "scripts": {
    "build": "node build.mjs",
    "buildwatch": "node build.mjs --watch",
    "lint": "eslint js",
    "typecheck": "tsc --noEmit"
  "devDependencies": {
    "@tsconfig/recommended": "^1.0.2",
    "@types/react": "^18.0.28",
    "@types/react-dom": "^18.0.11",
    "@typescript-eslint/eslint-plugin": "^5.57.1",
    "@typescript-eslint/parser": "^5.57.1",
    "eslint": "^8.37.0",
    "typescript": "^5.0.3"

Note the build and buildwatch scripts:

"build": "node build.mjs",
"buildwatch": "node build.mjs --watch",

buildwatch is run continuously in development. build is run as part of the Render "Build command" during deployment.

Example build.mjs:

import * as esbuild from "esbuild";
import { sassPlugin } from "esbuild-sass-plugin";

const args = process.argv.slice(2);
const watch = args.includes("--watch");

let opts = {
  entryPoints: ["js/app.ts", "css/app.scss"],
  plugins: [sassPlugin()],
  bundle: true,
  external: ["fonts/*"],
  loader: {
    ".woff2": "dataurl",
  tsconfig: "tsconfig.json",
  outdir: "public",

if (watch) {
  // dev
  let ctx = await esbuild.context({
    sourcemap: true,
  await ctx.watch();
} else {
  // deploy
  await esbuild.build({
    minify: true,
    keepNames: true,

Rails config

In config/environments/production.rb:

config.action_controller.asset_host = ENV.fetch("APP_HOST")
config.public_file_server.enabled = true
  config.public_file_server.headers = {
    "Cache-Control" => "public, s-maxage=2592000, maxage=86400, immutable"

The immutable directive eliminates revalidation requests.

In Rakefile:

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__))
      old_base, old_ext = old_path.split(".")
      new_path = "#{old_base}-#{hash}.#{old_ext}"
      system "mv #{old_path} #{new_path}"

Render config

In our production web service on Render, our build command will look like this:

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

In order, this:

  1. Builds JavaScript dependencies via NPM
  2. Transpiles, bundles, minifies via esbuild
  3. Builds Ruby dependencies via Bundler
  4. Migrates the database
  5. Fingerprints the static assets using the above rake task