Isometric illustration of a central server distributing asset packages to CDN edge nodes around it

The Rails asset pipeline in production is three decisions: how assets are built (Propshaft or Sprockets, with importmap or a JavaScript bundler), how they’re fingerprinted and cached (digested filenames with a one-year immutable cache header), and where they’re served from (Thruster inside the container, a CDN in front of the app, or a bucket behind a CDN). Rails 8’s defaults, Propshaft, importmap and Thruster, are enough for most apps. A CDN starts paying off with global users or heavy traffic. This guide compares every option, shows the trade-offs and costs with sources, and recommends a setup by type of app. For build errors during assets:precompile, see the companion How-To linked below.


TL;DR

  • Rails 8 defaults to Propshaft, a lean pipeline that digests and copies files. Transpiling and bundling are delegated to importmap, jsbundling (esbuild, Bun, Rollup, Webpack), cssbundling or tailwindcss-rails.
  • Importmap ships ES modules without a build step and works well over HTTP/2. Choose a bundler when you need npm packages that require compilation, JSX or TypeScript.
  • Every digested asset should be served with Cache-Control: public, max-age=31536000, immutable. Filenames change when content changes, so long caching is safe.
  • Thruster serves and compresses assets directly from the Rails 8 container. Add a CDN (config.asset_host) for global audiences and to take asset traffic off your app. Cloudflare R2 charges no egress fees, while S3 charges $0.09/GB after the first 100 GB.
  • Keep old digests available across deploys with Kamal’s asset_path, a CDN cache or a bucket that only grows, or users mid-session will hit 404s.

Table of Contents

What is the Rails asset pipeline in production?

In production, the asset pipeline takes your stylesheets, JavaScript, images and fonts, builds them once during deployment, gives each file a content-based fingerprint, and serves the result with long-lived cache headers. Nothing is compiled at request time.

How Rails assets reach the browser in production: sources, build, digested files, then Thruster, a CDN or a bucket

Three layers make up the pipeline:

Layer Rails 8 default Alternatives Job
Asset library Propshaft Sprockets Digest, copy, resolve asset_path
JavaScript importmap-rails jsbundling-rails (esbuild, Bun, Rollup, Webpack), Vite Ruby Deliver JS modules or bundles
CSS Plain CSS or tailwindcss-rails cssbundling-rails (Sass, PostCSS, Bootstrap), dartsass-rails Compile CSS if needed
Serving Thruster in the container Nginx, CDN with asset_host, bucket + CDN Deliver files fast, with caching and compression

All of this comes together in one build command, bin/rails assets:precompile. Its errors and fixes are covered in the Rails assets precompile guide.

Propshaft vs Sprockets

Use Propshaft for new apps and for apps whose CSS and JavaScript are already handled by importmap or a bundler. Stay on Sprockets if you rely on its processors, such as Sass through sassc-rails, //= require concatenation or ERB inside assets, and don’t have time to migrate.

  Propshaft Sprockets
Default since Rails 8.0 Rails 3.1 – 7.x
Compiles Sass, CoffeeScript, ERB No (delegates to other tools) Yes
Concatenation (//= require) No Yes
Manifest of what to precompile Not needed: everything in load paths app/assets/config/manifest.js
Precompile speed Fast (digest and copy) Slower on large apps
Live compilation in production Never Possible with config.assets.compile = true (don’t)
Complexity Small codebase, few options Large, many processors and gems

Pros of Propshaft: fast builds, very little configuration, no hidden compile step in production, and predictable output.

Cons of Propshaft: no built-in Sass or concatenation, so apps that depended on Sprockets processors need another tool (dartsass-rails, cssbundling-rails) after migrating. Some older gems still ship assets that assume Sprockets.

Importmap vs jsbundling vs Vite

Importmap delivers your JavaScript as individual ES modules that the browser loads directly, without a bundler or Node. Bundlers combine modules into fewer, optimised files and support JSX, TypeScript and the npm ecosystem.

  importmap-rails jsbundling-rails (esbuild/Bun) Vite Ruby
Build step None (pins only) Yes, at precompile Yes, plus dev server
Node.js required No Yes (or Bun) Yes
JSX / TypeScript No Yes Yes
npm packages needing compilation No Yes Yes
Number of HTTP requests Many small modules (fine over HTTP/2+) Few bundles Few bundles, code splitting
Tree shaking No Yes Yes
Best for Hotwire apps (Turbo, Stimulus) React/Vue islands, larger JS SPA-like front ends, HMR in dev

Importmap’s many-requests trade-off is mostly a non-issue today: HTTP/2 and HTTP/3 multiplex requests over one connection, and every module is fingerprinted and cached for a year. 37signals has written about running HEY with importmap and no JavaScript build step at all. The case for a bundler is the code you depend on, not the number of files.

How should Rails serve assets in production?

Serve assets from Thruster in the container by default, put a CDN in front once you have users far from your servers or a lot of asset traffic, and use a bucket behind a CDN when you run many servers or lazy-load JavaScript chunks that must survive deploys.

  A · Thruster in the container B · CDN with origin pull C · Bucket + CDN
Setup None (Rails 8 Dockerfile) config.asset_host + CDN config CI uploads public/assets to a bucket
Latency for distant users Server’s region only Edge, close to users Edge, close to users
Load on app servers Serves every asset request (cheaply) Only cache misses None
Old assets after a deploy Need asset bridging (Kamal asset_path) Cached at edge for a while, not guaranteed Always available (bucket only grows)
Extra cost None CDN plan, often free tier Storage + CDN
Moving parts Fewest DNS, CDN rules CI step, bucket permissions, CDN

Pros of Thruster: zero configuration, gzip compression, efficient file serving, and caching headers. Cons: no geographic distribution, and all asset traffic shares your app’s bandwidth.

Pros of a CDN: lower latency worldwide, less traffic on your servers, often DDoS protection. Cons: another service and DNS to manage, and cache invalidation of non-digested files must be handled.

Configuring a CDN in front of Rails takes one setting:

# config/environments/production.rb
config.asset_host = ENV.fetch("ASSET_HOST", nil)   # e.g. "https://cdn.example.com"

Point the CDN’s origin at your app’s hostname. It requests /assets/... from Rails or Thruster on a cache miss and keeps the file at the edge for as long as the cache header allows. Because fingerprints change with content, you never need to purge /assets/*.

Pro Tip: Don’t proxy HTML through the CDN with long cache times unless you mean to. Cache rules for /assets/* should be “respect origin headers, cache for a year”. For HTML, follow your app’s Cache-Control, which is usually private or no-cache.

Caching, compression and fingerprinting

Every digested asset should be cached for a year, compressed, and fingerprinted. Rails 8 sets the first, Thruster handles the second, and the pipeline does the third.

# config/environments/production.rb (Rails 8 default)
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }

Add immutable so browsers don’t revalidate fingerprinted files even on reload:

config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}, immutable" }
Concern How it’s handled Check with
Fingerprinting Propshaft/Sprockets rename app.css → app-4f2a9c1e.css Page source shows digested names
Long cache public_file_server.headers curl -sI .../assets/app-….css \| grep cache-control
Compression Thruster gzips responses; CDNs add Brotli curl -sI -H "Accept-Encoding: br, gzip" …
Old versions after deploy Asset bridging or a bucket that keeps old digests Request a previous digest after deploying
HTTP/2 multiplexing TLS proxy or CDN curl -sI --http2 …

The deploy-overlap problem is subtle and worth repeating: right after a deploy, a browser holding the previous HTML asks for the previous digests. If the new container only contains new files, those requests return 404. With Kamal, asset_path: /rails/public/assets in config/deploy.yml combines old and new assets during the switch. With a bucket, upload without deleting.

How much does serving assets cost?

Serving assets is cheap until bandwidth adds up. The main cost is egress, the bytes leaving your provider, and it varies from free to $0.09/GB depending on where assets are stored and served.

Option Storage Egress / bandwidth Notes
From the app (Thruster) Included in the container Your platform’s bandwidth pricing Render charges bandwidth beyond the plan allowance
AWS S3 direct ~$0.023/GB-month (Standard) First 100 GB/month free, then $0.09/GB Usually fronted by CloudFront
Cloudflare R2 $0.015/GB-month $0 egress Pay per operation; free tier includes 10 GB storage
CDN in front of the app — Often free or flat-rate plans for typical sites Cuts origin bandwidth to cache misses

Prices from AWS S3 and Cloudflare R2 pricing pages, October 2026.

A worked example: 500 GB of asset traffic per month costs about $36 served straight from S3 (400 GB × $0.09 after the free 100 GB), and close to $0 from R2 or from a CDN with a free tier and a good hit rate. For most Rails apps, assets are a few megabytes per release, so storage is negligible and egress decides the bill.

That makes the “bucket + CDN” setup cheap as well as robust. Our startup hosting costs breakdown covers where bandwidth fits into the rest of the bill.

How Heroku, Render, Fly.io, Upsun and Enkihost serve Rails assets

Every platform can serve Rails assets from the app. They differ in how assets are built, whether a CDN is included, and what happens to old assets on deploy.

Platform How assets are built Served by Built-in CDN Old assets kept
Heroku Ruby buildpack runs assets:precompile The dyno (Rails/Thruster) No (use a CDN add-on or external CDN) Yes, buildpack caches public/assets and runs assets:clean
Render Your build command The service Static sites only; use an external CDN for apps Per build
Fly.io Your Dockerfile The Machine (Thruster) No (external CDN) Per image; bridge or use a bucket
Upsun Build hook The app container, or web.locations for static files Fastly on some plans Per build
Kamal 2 Your Dockerfile Thruster in the container No With asset_path bridging
Enkihost Your Dockerfile, or the generated one The container (Rails/Thruster) No (external CDN) Per image

Upsun lets you serve public/assets directly from its web server with web.locations, which takes static files away from Puma entirely:

# .upsun/config.yaml (app excerpt)
web:
  locations:
    "/assets":
      root: "public/assets"
      expires: 1y
      passthru: false
      allow: true

Enkihost runs Rails apps with zero-downtime deploys and per-app resource isolation, so serving assets with Thruster works as in the Rails 8 defaults, and a CDN with asset_host can be added in front the same way as on any platform above.

Which asset setup should you choose?

App Pipeline JavaScript Serving
New Hotwire app Propshaft importmap Thruster; add a CDN when traffic grows
Hotwire app with Tailwind Propshaft + tailwindcss-rails importmap Thruster or CDN
App with React/Vue components Propshaft jsbundling (esbuild or Bun) CDN, with asset bridging or a bucket
SPA-like front end in Rails Propshaft or Vite Ruby Vite Bucket + CDN
Legacy app on Sprockets with Sass Sprockets (migrate later) Existing setup CDN; keep config.assets.compile = false
Global audience Any Any CDN or bucket + CDN, required

The Rails performance optimization guide and Rails caching strategies cover the server side of page speed. Assets are the client side.

Author Perspective: the fastest build is no build

Over the past few years I’ve moved most of my Rails apps from Webpacker and jsbundling back to importmap and Propshaft, and I don’t miss the bundler. Deploys got faster, the Docker image lost Node, and “the build broke” stopped being a category of problem. When an app genuinely needs React or TypeScript, esbuild through jsbundling is fast and simple. What I’d push every team to do, whatever pipeline they choose, is check two things after the next deploy: that assets come back with immutable cache headers, and that a digest from the previous release still returns 200. Those two checks catch most asset problems I see in production.

Serving Rails assets on Enkihost

The Rails 8 asset defaults are designed to run without extra infrastructure, and that’s how they run on Enkihost:

Enkihost

  • Zero-downtime deploys, so precompiled, fingerprinted assets roll out with the release, and a CDN or bucket can keep previous digests available for users mid-session.
  • Per-app resource isolation with allocated memory and CPU, so Thruster serving assets doesn’t compete with other apps for resources.
  • PostgreSQL and Redis add-ons that inject DATABASE_URL and REDIS_URL, covering the rest of a Rails 8 stack without extra services.
  • Plans for Rails and Sinatra: Ignite at 9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included, or Blaze at 29 EUR with high availability and autoscaling. The free Spark plan is for Jekyll sites.

See enkihost.com.

FAQ

What is Propshaft in Rails?

Propshaft is the default asset pipeline in Rails 8. It fingerprints and copies files from the asset load paths into public/assets and resolves asset paths in views, but it does not compile Sass or bundle JavaScript itself. Those jobs are handled by importmap, jsbundling, cssbundling or tailwindcss-rails.

Should I use importmap or esbuild with Rails?

Use importmap for Hotwire apps that mostly use Turbo, Stimulus and a few small libraries, since it needs no build step and no Node.js. Use esbuild through jsbundling-rails when you need JSX, TypeScript or npm packages that must be compiled, or when you want tree shaking for a large JavaScript codebase.

Do I need a CDN for Rails assets?

Not at first. Thruster serves fingerprinted assets with long cache headers efficiently from the app container. A CDN becomes worthwhile when users are spread across regions, when asset traffic is a significant part of your bandwidth, or when you run several servers and want old assets to survive deploys.

How do I serve Rails assets from a CDN?

Set config.asset_host to the CDN’s URL, point the CDN’s origin at your app, and cache paths under /assets according to the origin’s cache headers. Rails then writes asset URLs with the CDN hostname, and fingerprinted filenames mean you never need to purge the CDN cache after a deploy.

Why do I get 404s for assets right after a deploy?

Browsers that loaded HTML from the previous release still request the previous release’s fingerprinted files, which the new container no longer contains. Keep old assets available, using Kamal’s asset_path bridging, a CDN cache or a bucket where old digests are never deleted.

Sources