{"slug": "the-propshaft-version-lever-you-were-told-was-gone", "title": "The Propshaft Version Lever You Were Told Was Gone", "summary": "A developer team migrating to Rails 8.1.3 with Propshaft mistakenly believed the asset version string for cache busting had been removed. The version setting remains functional, as confirmed by reading Propshaft's source code and testing a fresh Rails app; it is wired into the digest via `config.assets.version` and defaults to \"1.0\" in generated initializers. The confusion stems from missing documentation in Propshaft's README, not from removal of the feature.", "body_md": "A piece of feedback to the Rails community crossed my feed this week. A team had migrated an application to Rails 8.1.3, adopted Propshaft — the asset pipeline that replaced Sprockets as the Rails 8 default — and concluded that it had removed the ability to set a version string to force new fingerprints on precompile. Their words were that this introduced “a weakness to the platform.” The reasoning was sound: they used that lever to be certain a client was running the latest deployed assets, and now it appeared to be gone.\n\nThe instinct is correct. That lever matters. But the conclusion is wrong, and the way I know it is wrong is the point of this post: I cloned Propshaft, read the source, and then generated a fresh Rails 8.1.3 app and tested it, rather than trusting the blog posts. The version setting is still there. It is wired into the digest. The Rails generator writes it into your initializer with a comment explaining what it does. And when I precompiled twice to prove it, it behaved exactly as advertised. It is missing from Propshaft’s README — which is a different problem from being removed, and a much smaller one.\n\nSearch for “Propshaft cache busting” and every result says the same thing, more or less correctly:\n\nPropshaft appends a content-based fingerprint to each filename.\n\n`application.css`\n\nbecomes`application-a1b2c3d4.css`\n\n. When the content changes, the digest changes, the filename changes, and the browser fetches the new file. Unlike Sprockets, there is no`config.assets.version`\n\nto manage — the content hash handles everything.\n\nThat last sentence is the one that does the damage. It is repeated across tutorials, and it is the source of the belief that the lever was deleted. The first half is true. The second half is folklore.\n\nPropshaft is small enough to read in a sitting, which is exactly why I reach for `git clone`\n\nbefore I reach for an opinion. The whole digest mechanism is one method in `lib/propshaft/asset.rb`\n\n:\n\n```\ndef digest\n  @digest ||= Digest::SHA1.hexdigest(\"#{content_with_compile_references}#{load_path.version}\").first(8)\nend\n```\n\nRead it slowly. The string being hashed is not just the file’s content. It is the content **concatenated with load_path.version**. The fingerprint is a SHA1 of\n\nSo where does `load_path.version`\n\ncome from? Two short hops up. The load path is built in `lib/propshaft/assembly.rb`\n\n:\n\n```\ndef load_path\n  @load_path ||= Propshaft::LoadPath.new(\n    config.paths,\n    compilers: compilers,\n    version: config.version,          # <- right here\n    file_watcher: config.file_watcher,\n    integrity_hash_algorithm: config.integrity_hash_algorithm\n  )\nend\n```\n\nAnd `config.version`\n\nis `config.assets.version`\n\n, which the Propshaft railtie sets a default for in `lib/propshaft/railtie.rb`\n\n:\n\n```\nconfig.assets.version = \"1\"\n```\n\nThe chain is unbroken:\n\n```\nflowchart LR\n    A[\"config.assets.version<br/>(generated app: &quot;1.0&quot;)\"] --> B[Assembly]\n    B -->|\"version: config.version\"| C[LoadPath.version]\n    C --> D[\"Asset#digest<br/>SHA1(content + version)\"]\n    D --> E[\"application-a1b2c3d4.css\"]\n\n    style A fill:#e8a838,stroke:#b07828,color:#fff\n    style D fill:#4a90d9,stroke:#2c5f8a,color:#fff\n    style E fill:#27ae60,stroke:#1e8449,color:#fff\n```\n\n`config.assets.version`\n\nexists in Propshaft. The railtie defaults it to `\"1\"`\n\n, it is folded into every single asset digest, and this is Propshaft 1.3.2 — the version shipping with current Rails 8.\n\nAnd here is the part that turns “undocumented” into “actually documented, in your own repository.” Generate a fresh Rails 8.1.3 app and open `config/initializers/assets.rb`\n\n, and the generator has already written this for you:\n\n```\n# Version of your assets, change this if you want to expire all your assets.\nRails.application.config.assets.version = \"1.0\"\n```\n\nThe generated app overrides the railtie’s `\"1\"`\n\nwith `\"1.0\"`\n\n, but the point is the comment. The exact “enter version information to force new fingerprints” feature the feedback believed was disabled is scaffolded into every new Rails app, on a line whose comment names the precise use case: *change this if you want to expire all your assets*. Nobody removed the lever. It is sitting in an initializer the generator wrote, one `git grep version config/`\n\naway.\n\nBecause the version string is part of the hashed input, changing it changes the hash for **every asset**, regardless of whether any file content changed. Edit the line the generator already gave you:\n\n```\n# config/initializers/assets.rb\nRails.application.config.assets.version = \"2.0\"\n```\n\nPrecompile, and every fingerprint differs from the previous build — the same shift for every file in the pipeline. The asset URLs embedded in your views all change, and any client requesting an old URL gets a cache miss and pulls the fresh file. That is precisely the “force new fingerprint generation on precompile” behaviour the feedback assumed had been taken away.\n\nIt is worth noting this is *more* reliable than the feature people remember. Sprockets had a long-standing bug ([sprockets-rails#240](https://github.com/rails/sprockets-rails/issues/240)) where bumping `config.assets.version`\n\nproduced identical digests anyway — the lever was connected to nothing. Propshaft’s version genuinely participates in the hash. The thing that was supposedly removed actually works better than the original.\n\nReading the source tells you what *should* happen. Before publishing this I generated a clean Rails 8.1.3 app (`propshaft (1.3.2)`\n\n, no Sprockets in the lockfile), added one declaration to `app/assets/stylesheets/application.css`\n\n, and precompiled it twice. Between the two builds I changed nothing except the version string, and I checked that the CSS file was byte-for-byte identical (same MD5) across both runs.\n\n**Build one**, with the default `config.assets.version = \"1.0\"`\n\n, produced this manifest:\n\n```\n{ \"application.css\": { \"digested_path\": \"application-a863ad16.css\" } }\n```\n\n**Build two**, after changing only the version to `\"2.0\"`\n\n— no content change, identical MD5 — produced:\n\n```\n{ \"application.css\": { \"digested_path\": \"application-09b5bd28.css\" } }\n```\n\nEvery digest moved, not just the stylesheet’s:\n\n| asset | `version = \"1.0\"` |\n`version = \"2.0\"` |\n|---|---|---|\n`application.css` |\n`a863ad16` |\n`09b5bd28` |\n`rails-ujs.esm.js` |\n`e925103b` |\n`a4ead74f` |\n`rails-ujs.js` |\n`20eaf715` |\n`0b7c6ef1` |\n\nThen, to be sure I understood *why*, I reproduced the digest by hand from the formula in `asset.rb`\n\n— `SHA1(content + version)`\n\ntruncated to eight characters:\n\n```\nrequire \"digest\"\ncontent = File.read(\"app/assets/stylesheets/application.css\")\nDigest::SHA1.hexdigest(\"#{content}1.0\").first(8)  # => \"a863ad16\"  ✓ matches build one\nDigest::SHA1.hexdigest(\"#{content}2.0\").first(8)  # => \"09b5bd28\"  ✓ matches build two\n```\n\nBoth hand-computed digests matched the precompiled filenames exactly. The lever works, it works for the documented reason, and the mechanism is no deeper than concatenating a string before hashing.\n\nHere is the more interesting half, and the reason the lever is quietly scaffolded rather than loudly advertised.\n\nWith Sprockets, the version string earned its keep because Sprockets’ digests were not purely content-addressed and were occasionally inconsistent between environments and gem versions. The version knob was the manual override you reached for when you did not trust the automatic digest. It was a workaround for unpredictability.\n\nPropshaft’s digest is a plain SHA1 of the content. It is deterministic: identical bytes always produce the identical fingerprint, and any byte change produces a new one. The automatic case is now trustworthy, so the manual override has almost nothing left to do. If you changed an asset, its fingerprint already changed — you do not bump a version to “make sure,” because the content *is* the version.\n\nThe only scenario where the global lever is the right tool is when you want every asset to get a new URL *without changing any content*: forcing a CDN that keyed on something unexpected to re-pull, recovering from a poisoned edge cache, or invalidating after a build-toolchain change that altered how files are produced but not what they contain. Real situations, but rare ones. That rarity is why the generated app sets the field to `\"1.0\"`\n\nand most teams never touch it again — not because it was deleted.\n\nThere is a deeper point hiding in the original feedback, and it is the part worth carrying away even if you never touch `config.assets.version`\n\n.\n\nThe stated goal was “make sure the client indeed has the latest asset being deployed.” Bumping the asset version does not, on its own, guarantee that — because the fingerprinted URL lives **inside your HTML**:\n\n```\n<link rel=\"stylesheet\" href=\"/assets/application-9f8e7d6c.css\">\n```\n\nA fingerprinted asset is safe to cache forever, which is the whole win: the URL only points at one immutable version of the file. But the *document* that references it is a different cache layer entirely. If a CDN, a reverse proxy, or the browser is holding an old copy of the HTML, the client keeps reading the *old* asset URL — and your shiny new `version = \"2.0\"`\n\ndigests sit on the server unrequested.\n\nSo “did the client get the latest assets?” is really two questions stacked on top of each other:\n\n`max-age`\n\nset to a year and `immutable`\n\n.`max-age`\n\non the document, `ETag`\n\n/`Last-Modified`\n\nrevalidation, and correct CDN cache rules for HTML responses. If this layer serves stale HTML, no amount of fingerprinting downstream will help.Reaching for `config.assets.version`\n\nto fix a stale-client problem is, most of the time, fixing the layer that already works and ignoring the one that does not. The fingerprint was never the weak link. The document cache is.\n\nThere are two, and they are both cheap habits.\n\n**Read the source, then run the experiment, before you declare a feature gone.** Propshaft is a few hundred lines. The entire digest behaviour — the thing three dozen blog posts summarise, sometimes wrongly — is one method you can read in under a minute. Cloning the repository and grepping for `version`\n\ntook less time than writing the post that announced its removal, and it would have produced the opposite, correct conclusion. Generating a throwaway app and precompiling it twice took five more minutes and turned “the source says it should work” into “I watched it work.” When a tool’s behaviour surprises you, the library’s own code is the primary source and a two-build experiment is the proof. Tutorials are secondary, and they inherit each other’s mistakes.\n\n**Match the fix to the layer.** “The client has stale assets” feels like one problem but spans two caches with two different owners. Fingerprinting owns the asset layer and has owned it well since Propshaft shipped. HTTP cache headers own the document layer. Bumping an asset version to solve a document-caching symptom is the kind of fix that appears to work — because you redeployed and cleared something — while leaving the actual cause in place to resurface on the next deploy.\n\nThe lever you were told was gone is still bolted to the dashboard. It is just that the car mostly steers itself now, and the warning light you are chasing is wired to a different system entirely.\n\n*The code in this post is from Propshaft 1.3.2, the asset pipeline that ships by default with Rails 8. The digest method is lib/propshaft/asset.rb; the railtie default is in lib/propshaft/railtie.rb; the generated config.assets.version line is in config/initializers/assets.rb. Every digest in this post was reproduced from a clean rails new on Rails 8.1.3 — two precompiles, one version change, identical content — not from memory.*\n\n*If you want the longer story on building Rails applications that stay maintainable as they grow — boundaries, engines, testing, and honest trade-offs — that is what Modular Rails: Architecture for the Long Game covers in depth. Read it free on the web, or get the paperback (UK).*", "url": "https://wpnews.pro/news/the-propshaft-version-lever-you-were-told-was-gone", "canonical_source": "https://davidslv.uk/2026/06/23/propshaft-version-lever-cache-busting.html", "published_at": "2026-06-23 00:00:00+00:00", "updated_at": "2026-06-30 17:54:13.804719+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Propshaft", "Rails", "Sprockets", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/the-propshaft-version-lever-you-were-told-was-gone", "markdown": "https://wpnews.pro/news/the-propshaft-version-lever-you-were-told-was-gone.md", "text": "https://wpnews.pro/news/the-propshaft-version-lever-you-were-told-was-gone.txt", "jsonld": "https://wpnews.pro/news/the-propshaft-version-lever-you-were-told-was-gone.jsonld"}}