DNS to CDN to origin
Content Distribution Networks (CDNs) pull content from their origin server during HTTP requests to cache them:
DNS -> CDN -> Origin
Example:
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:
- is received by Render.com's load balancers, which terminates TLS
- is forwarded to the Render.com web service,
which needs to bind to host
0.0.0.0
on a port, usually specified by aPORT
variable. - passed to one of the running Puma workers (web server process)
- routed by Rails to the asset (CSS, JS, img, font file)
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({
...opts,
sourcemap: true,
});
await ctx.watch();
console.log("watching...");
} else {
// deploy
await esbuild.build({
...opts,
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}"
end
end
end
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:
- Builds JavaScript dependencies via NPM
- Transpiles, bundles, minifies via esbuild
- Builds Ruby dependencies via Bundler
- Migrates the database
- Fingerprints the static assets using the above rake task