<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.fastruby.io/blog/rss.xml" rel="self" type="application/atom+xml" /><link href="https://www.fastruby.io/blog/" rel="alternate" type="text/html" /><updated>2026-09-03T13:59:43-04:00</updated><id>https://www.fastruby.io/blog/rss.xml</id><title type="html">The Rails Tech Debt Blog</title><subtitle>| FastRuby.io</subtitle><author><name>OmbuLabs</name></author><entry><title type="html">Turning Audit Findings into CI Checks</title><link href="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.html" rel="alternate" type="text/html" title="Turning Audit Findings into CI Checks" /><published>2026-09-03T06:30:00-04:00</published><updated>2026-09-03T06:30:00-04:00</updated><id>https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks</id><content type="html" xml:base="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.html"><![CDATA[<p>You get a site audit report and it looks manageable. A few dozen findings, most of them small: a page with barely any text on it, a link whose text is just “here”, a page whose title tag is a copy of its H1, a hero image heavy enough to hurt the <a href="https://www.fastruby.io/blog/lcp">largest contentful paint</a>. None of it is that hard. You spend an afternoon on it, close the tickets, and move on.</p>

<p>Then a few months pass, a dozen new pages ship, and the next audit reports the same findings again. Not because anyone ignored the first round, but because the first round fixed pages instead of fixing the process that produces pages.</p>

<p>That happened to us, on this site and on <a href="https://www.ombulabs.ai">OmbuLabs.ai</a>. So the second time around we spent the effort somewhere else. Instead of just fixing the pages, we wrote checks that run on every build and say when a new page has the same problem. It is roughly the same idea as <a href="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code">automating a tech debt audit</a>: most of the value is not in the report, it is in being able to produce the report again for free. No two of them wanted the same kind of check.</p>

<p>The goal here is search traffic, not a clean report. Thin pages, vague link text, and duplicated title tags are the things that hold a page back in search results, and a page that ships with them costs us traffic until the next audit finds it. A check on every build moves that discovery from months later to the pull request.</p>

<p>In this article, you will learn how we turned three kinds of audit findings into checks that run in CI.</p>

<!--more-->

<h2 id="word-count-text-ratio-and-semantic-ratio">Word count, text ratio, and semantic ratio</h2>

<p>The first group of findings was about the content itself, the pages a crawler treats as thin. By crawler I mean Googlebot, Bing and the AI crawlers that feed answer engines. There are three metrics that matter here:</p>

<p><strong>Word count.</strong> How many words the page actually says.</p>

<p><strong>Text ratio.</strong> The size of the visible text divided by the size of the whole HTML file. A page can look full to you and still be 5% words and 95% tags, and a crawler reads that as a page built to hold a layout rather than to say something.</p>

<p><strong>Semantic ratio.</strong> How many of the page’s tags are ones that carry meaning (<code class="language-plaintext highlighter-rouge">h2</code>, <code class="language-plaintext highlighter-rouge">p</code>, <code class="language-plaintext highlighter-rouge">ul</code>, <code class="language-plaintext highlighter-rouge">article</code>, <code class="language-plaintext highlighter-rouge">footer</code>) instead of plain <code class="language-plaintext highlighter-rouge">div</code> and <code class="language-plaintext highlighter-rouge">span</code>. Semantic tags are what tell a crawler which part is a heading and which part is the body. A page made of nested <code class="language-plaintext highlighter-rouge">div</code>s says nothing about its own structure.</p>

<p>We put all three in one class, <code class="language-plaintext highlighter-rouge">ContentQualityChecker</code>, that parses the page with Nokogiri, with the thresholds at the top:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ContentQualityChecker</span>
  <span class="no">WORD_COUNT_MIN</span> <span class="o">=</span> <span class="mi">200</span>
  <span class="no">TEXT_RATIO_MIN</span> <span class="o">=</span> <span class="mf">0.10</span>
  <span class="no">SEMANTIC_RATIO_MIN</span> <span class="o">=</span> <span class="mf">0.115</span>

  <span class="no">SEMANTIC_ELEMENTS</span> <span class="o">=</span> <span class="sx">%w[
    header nav main article section aside footer figure figcaption time mark details summary
    h1 h2 h3 h4 h5 h6 ul ol li p blockquote table tr td th
  ]</span><span class="p">.</span><span class="nf">freeze</span>
</code></pre></div></div>

<p>Those numbers are not set in stone. They are in the range the common audit tools complain about, and they are low enough that a real article never trips them, which is what you actually want from a threshold. If yours flag half your posts, they are set too high for your layout.</p>

<p>The one number that needs care is the word count. Counting the whole <code class="language-plaintext highlighter-rouge">&lt;body&gt;</code> sounds right but it is not, because every page carries the same nav, sidebar, and footer, and on our pages that added roughly 40% to the number. Thin pages looked fine. So the count cuts out everything but the main content area, <code class="language-plaintext highlighter-rouge">doc.at("main")</code>, falling back to the body for anything without one.</p>

<p>With the checker in place the rest is two rake tasks, and they do not work the same way. Our blog is a Jekyll build, so the pages are already HTML on disk and that task just globs the files and needs nothing but Nokogiri. Rails pages only exist once something renders them, so that task opens an <code class="language-plaintext highlighter-rouge">ActionDispatch::Integration::Session</code> and requests each path the way a visitor would. It is the cheapest way we found to measure a rendered page without a browser in the loop.</p>

<p>The report prints one line per flagged page, worst first:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/blog/tags/railsconf                  195w  text  11.0%  semantic  16.2%  low word count
/blog/fortify-rails-security-webinar  280w  text   9.7%  semantic  25.8%  low text ratio
</code></pre></div></div>

<p>Today it flags 20 pages out of the 304 it measures on the blog, and most of them are edge cases we are fine with. Tag and author listings are thin because they are navigation, not articles. A group of long posts from years ago trips the semantic ratio because their markup is older than the current layout. Expect a tail like that of your own, every site has one. The report is there to tell the pages that are thin on purpose from the one somebody shipped last week without noticing.</p>

<h2 id="anchor-text-that-describes-the-destination">Anchor text that describes the destination</h2>

<p>The second group is the interesting one, because the audit finding and an accessibility bug turn out to be the same bug.</p>

<p>Anchor text is the visible words inside a link. A crawler just extracts that text, so <code class="language-plaintext highlighter-rouge">click here</code> gets read as exactly that, “click here”, with no information about where the link takes you. A screen reader user gets the same nothing, out of context, from a list of links on the page.</p>

<p>Two findings came up. Links with no anchor text at all, and links whose anchor text describes nothing: <code class="language-plaintext highlighter-rouge">here</code>, <code class="language-plaintext highlighter-rouge">click here</code>, <code class="language-plaintext highlighter-rouge">read more</code>, <code class="language-plaintext highlighter-rouge">learn more</code>, <code class="language-plaintext highlighter-rouge">details</code>, <code class="language-plaintext highlighter-rouge">download</code>, <code class="language-plaintext highlighter-rouge">continue</code>. If you have read anything about accessibility, and I covered some of this in <a href="https://www.fastruby.io/blog/usability-and-accessibility-for-better-user-experience">Usability Meets Accessibility</a> and <a href="https://www.fastruby.io/blog/testing-for-accessibility">From Code to Compliance: Accessibility Testing</a>, that list is familiar. The way people usually try to fix the first one misses the point.</p>

<p>Take an icon-only link, a social icon in the footer for example. It has no text node at all. The first thing people try is an <code class="language-plaintext highlighter-rouge">aria-label</code> on the <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code>, or <code class="language-plaintext highlighter-rouge">alt</code> text on the image inside it, and then they consider it handled. For a screen reader that mostly works. For a crawler it does not, because it reads the anchor’s text content, and neither an attribute on the link nor an attribute on a child image is text content. The fix that works for both readers is a real text node that happens to be invisible: a <code class="language-plaintext highlighter-rouge">&lt;span&gt;</code> with the <code class="language-plaintext highlighter-rouge">sr-only</code> class, a CSS convention that pushes text off screen while leaving it in the document. The name says “screen reader only”, but it fixes crawlers too: both read text content, not what is visible on screen.</p>

<p>Our scroll-to-top link is the smallest example. Before, an image and an attribute:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"scrollto"</span> <span class="na">href=</span><span class="s">"#top"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"circle-black-lg.svg"</span> <span class="na">alt=</span><span class="s">"Scroll to top"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>After, the image is marked as decoration and the words live in the document:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">class=</span><span class="s">"scrollto"</span> <span class="na">href=</span><span class="s">"#top"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"circle-black-lg.svg"</span> <span class="na">alt=</span><span class="s">""</span> <span class="na">aria-hidden=</span><span class="s">"true"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"sr-only"</span><span class="nt">&gt;</span>Scroll to top<span class="nt">&lt;/span&gt;</span>
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>Nothing changes on screen. The difference is that the second link has anchor text and the first one has none.</p>

<p>Then there was the part that gave me the most headaches, and it came from one of our own gems. Our blog builds with <a href="https://github.com/fastruby/jekyll-external-link-accessibility">jekyll-external-link-accessibility</a>, a plugin we maintain that appends a note to every external link so screen reader users know it opens a new tab. That is a good thing to do, and it also means that by the time the checker sees the HTML, a link reading “here” reads “here opens a new window”, which matches nothing. Every non-descriptive link on the blog passed clean. So the text has to be cleaned up before it is judged:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">DECORATION</span> <span class="o">=</span> <span class="sr">/\s*opens a new window\s*\z/i</span>

<span class="k">def</span> <span class="nf">visible_text</span><span class="p">(</span><span class="n">anchor</span><span class="p">)</span>
  <span class="n">stripped</span> <span class="o">=</span> <span class="n">anchor</span><span class="p">.</span><span class="nf">dup</span>
  <span class="n">stripped</span><span class="p">.</span><span class="nf">css</span><span class="p">(</span><span class="s2">"[aria-hidden='true']"</span><span class="p">).</span><span class="nf">each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:remove</span><span class="p">)</span>
  <span class="n">stripped</span><span class="p">.</span><span class="nf">text</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s2">" "</span><span class="p">).</span><span class="nf">strip</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="no">DECORATION</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">aria-hidden</code> removal is the same idea running the other way. An icon marked <code class="language-plaintext highlighter-rouge">aria-hidden="true"</code> is decoration nobody hears, so it should not count as anchor text either.</p>

<h2 id="a-baseline-spec-for-page-metadata">A baseline spec for page metadata</h2>

<p>The third group was metadata. A rake task is the wrong tool here. There is no threshold to report on, the tags are either present or they are not. So this one is a request spec. It requests each public page and asserts the tags a crawler and a link preview need:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">aggregate_failures</span><span class="p">(</span><span class="s2">"SEO/social baseline for </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/&lt;meta\s+name="description"\s+content="[^"]+"/</span><span class="p">),</span>
    <span class="sx">%(Missing or empty &lt;meta name="description"&gt; on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. Add static.&lt;action&gt;.description in config/locales/en.yml.)</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">not_to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/translation missing/i</span><span class="p">),</span>
    <span class="sx">%(Found an unresolved I18n key ("translation missing") on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. Add the missing key in config/locales/en.yml.)</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">to</span> <span class="n">match</span><span class="p">(</span><span class="sr">/&lt;meta\s+property="og:url"\s+content="http[^"]+"/</span><span class="p">),</span>
    <span class="sx">%(Missing or non-canonical og:url on </span><span class="si">#{</span><span class="n">path</span><span class="si">}</span><span class="sx">. It should be the full URL of the page, not a hardcoded value.)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>There are a dozen more like that, covering the rest of the <code class="language-plaintext highlighter-rouge">og:</code> tags and the JSON-LD blocks. <code class="language-plaintext highlighter-rouge">aggregate_failures</code> reports everything wrong with the page in one run, and every message names where the fix goes, down to the locale key. A spec that fails with “expected false to equal true” gets skipped by the next person who hits it.</p>

<p>None of that matters if the spec never visits the page. Keep a list of the paths left out on purpose, with a reason for each one, so a new page never slips through unnoticed:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">IGNORED_PATHS</span> <span class="o">=</span> <span class="p">{</span>
  <span class="s2">"/search"</span> <span class="o">=&gt;</span> <span class="s2">"redirects to /blog when query is blank, so does not render HTML"</span><span class="p">,</span>
  <span class="s2">"/success"</span> <span class="o">=&gt;</span> <span class="s2">"redirects to /roadmap after Stripe checkout"</span><span class="p">,</span>
  <span class="s2">"/robots.txt"</span> <span class="o">=&gt;</span> <span class="s2">"non-HTML"</span><span class="p">,</span>
  <span class="s2">"/sitemap.xml"</span> <span class="o">=&gt;</span> <span class="s2">"non-HTML"</span>
<span class="p">}.</span><span class="nf">freeze</span>
</code></pre></div></div>

<p>Writing the reason down is worth more than it looks. An ignore list without reasons becomes a place to hide failures, and six months later nobody remembers whether <code class="language-plaintext highlighter-rouge">/success</code> is skipped because it redirects or because someone was in a hurry.</p>

<p>The list only works if it cannot go stale, so one example asks the router what exists and compares. Trimmed a little:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">it</span> <span class="s2">"covers every public GET route or lists it in IGNORED_PATHS"</span> <span class="k">do</span>
  <span class="n">discovered</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">flat_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">route</span><span class="o">|</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">unless</span> <span class="no">Array</span><span class="p">(</span><span class="n">route</span><span class="p">.</span><span class="nf">verb</span><span class="p">).</span><span class="nf">first</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"GET"</span><span class="p">)</span>

    <span class="n">spec</span> <span class="o">=</span> <span class="n">route</span><span class="p">.</span><span class="nf">path</span><span class="p">.</span><span class="nf">spec</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sr">/\(\.:format\)\z/</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">if</span> <span class="n">spec</span><span class="p">.</span><span class="nf">match?</span><span class="p">(</span><span class="sr">/[:*]/</span><span class="p">)</span>

    <span class="n">controller</span> <span class="o">=</span> <span class="n">route</span><span class="p">.</span><span class="nf">defaults</span><span class="p">[</span><span class="ss">:controller</span><span class="p">].</span><span class="nf">to_s</span>
    <span class="k">next</span> <span class="p">[]</span> <span class="k">if</span> <span class="n">ignored_controller_prefixes</span><span class="p">.</span><span class="nf">any?</span> <span class="p">{</span> <span class="o">|</span><span class="n">prefix</span><span class="o">|</span> <span class="n">controller</span><span class="p">.</span><span class="nf">start_with?</span><span class="p">(</span><span class="n">prefix</span><span class="p">)</span> <span class="p">}</span>

    <span class="p">[</span><span class="n">spec</span><span class="p">]</span>
  <span class="k">end</span><span class="p">.</span><span class="nf">uniq</span>

  <span class="n">expect</span><span class="p">(</span><span class="n">discovered</span> <span class="o">-</span> <span class="p">(</span><span class="no">PUBLIC_PAGE_PATHS</span> <span class="o">+</span> <span class="no">IGNORED_PATHS</span><span class="p">.</span><span class="nf">keys</span><span class="p">)).</span><span class="nf">to</span> <span class="n">be_empty</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Routes with parameters are skipped, since you cannot request them without making up a record, and so are admin, API, and framework controllers, since nothing there is meant to be indexed. Everything else has to be in one list or the other. Add a public page and the spec fails with the path in the message. It fails the other way too, when a listed path no longer exists in <code class="language-plaintext highlighter-rouge">routes.rb</code>, so old pages cannot stay in the list.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article, we went through three ways to keep an audit finding from coming back, so the pages we ship keep earning search traffic instead of losing it a little at a time. By setting a concrete metric or rule for each kind of finding, a word count and markup ratio for content, a matching tag for metadata, we could check it automatically in CI on every pull request. That let us tell which problems needed fixing right away and which ones were fine to track and fix over time.</p>

<p>Automate the metadata spec first. It pays for itself the first time somebody adds a page in a hurry.</p>

<p>Is your team carrying a backlog of findings that nobody has time to keep fixed? <a href="https://www.fastruby.io/monthly-rails-maintenance">We can take that off your hands</a>, send us a message.</p>]]></content><author><name>juliolucero</name></author><category term="best-practices" /><summary type="html"><![CDATA[A site audit is a snapshot. Here is how we turned thin-content, anchor-text, and page-metadata findings into rake tasks and specs, so the same findings stop coming back.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/turning-audit-findings-into-ci-checks.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to Avoid APM Bill Surprises</title><link href="https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises.html" rel="alternate" type="text/html" title="How to Avoid APM Bill Surprises" /><published>2026-09-01T16:01:29-04:00</published><updated>2026-09-01T16:01:29-04:00</updated><id>https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-avoid-apm-bill-surprises.html"><![CDATA[<p>You approved an APM tool at a modest monthly rate. A year later, the renewal invoice bears little resemblance to what you signed, and nobody remembers deciding to spend that much more.</p>

<p>APM is worth having when it’s used well: it resolves incidents faster by showing you where in the stack a problem started, it gives you warning before a threshold alert turns into an outage, and it gives you a defensible answer when a client or stakeholder asks whether you hit your SLA (often measured through <a href="https://www.fastruby.io/blog/performance/response-times-and-what-to-make-of-their-percentile-values.html">percentile response times</a> like p95 and p99). None of that is in question here. What tends to go unexamined is whether the bill still matches what you’re getting for it.</p>

<p>In this article, we’ll walk through where APM spend typically concentrates, the warning signs that a bill has drifted from usage-driven growth into unmanaged creep, and a concrete checklist for getting ahead of it before your next renewal.</p>

<!--more-->

<h2 id="where-the-money-goes">Where the Money Goes</h2>

<p>Annual APM spend scales with organization size, and the gap between the low end and the high end is wide. As a rough illustration: a small startup running a handful of hosts might land in the low five figures a year, a mid market company running dozens of hosts often lands in the mid five to low six figures, and a large enterprise running hundreds of hosts across multiple environments can move well into six or seven figures. Treat these as illustrative bands, not a quote; your own host count, environment count, and data volume are what actually set the number.</p>

<p>APM monthly spend usually concentrates in a few areas. The exact split depends on the size of your organization and how log or host heavy your workload is, so treat the following as a description of where the money tends to go, not shares that add up to 100%.</p>

<p>Host and container monitoring is the steady, predictable baseline, since it scales directly with host count and rarely surprises anyone. Data ingestion for metrics and traces adds a smaller share on top, and scales with traffic. Log ingestion is where the real growth happens, and it often ends up the largest line on the bill, because it’s priced at a premium and billed by volume rather than by host.</p>

<p>The rest are smaller but add up: custom metrics (billed per metric per host, and easy to accumulate without anyone noticing), retention that multiplies ingestion cost rather than showing up on its own line, unused user seats sitting as dead cost, and synthetic or RUM (Real User Monitoring) checks that can spike if uncapped.</p>

<p>None of this is unreasonable on its own. The trouble starts when nobody is responsible for where those costs compound over time.</p>

<h2 id="where-the-cost-hides">Where the Cost Hides</h2>

<p>This is the part that catches executives off guard, because it rarely shows up as a single suspicious charge. It shows up as gradual drift, and it tends to hide in the same handful of places.</p>

<p>The data itself is the usual culprit. Custom metrics get priced at a premium and added incrementally without anyone tracking the cumulative bill, and high cardinality tags (user IDs, request IDs, and the like) multiply billing under per series pricing (each unique combination of tags counted as its own billable metric), often invisibly. Logs are the big one: priced far higher than metrics or traces and billed by volume rather than by host, so a single verbose service, especially one left on DEBUG level logging in production, can generate a disproportionate share of your billable data relative to its actual footprint. This is one of the most common places log spend runs away from teams.</p>

<p>The contract and the environment hide the rest. Usage beyond your committed or budgeted volume typically bills at a higher rate, whether that’s an on demand rate above a negotiated price or the next pricing tier on a self-serve plan. The exact multiplier is contract and vendor specific; check yours rather than assuming a standard rate. Uncapped auto scaling settings let billing grow with no ceiling at all, negotiated or otherwise. Meanwhile the waste you have stopped noticing adds up: non production environments instrumented at the production tier, agents (the small process each host runs to report data back to the vendor) left running on decommissioned services and still billing for data nobody uses, and redundant coverage across APM, logging, and infrastructure platforms that means paying twice for the same signal.</p>

<p>Individually, each of these is minor. Together, over a year, they are the difference between a tool that pays for itself and one that becomes a budget problem nobody can explain.</p>

<h2 id="cost-leak-warning-signs">Cost Leak Warning Signs</h2>

<p>Whoever manages the tool day to day usually spots the drift first, since it shows up as more logs enabled, more custom metrics added, or an extra environment instrumented. Whoever pays the bill only sees the invoice. If those aren’t the same person, and they aren’t comparing notes when they’re not, nobody can tell a legitimate increase from a creeping one until the bill is already high enough to raise a question. You don’t need to be an APM expert to catch it, you need whoever’s watching usage and whoever’s watching spend looking at the same signals.</p>

<p>The financial tells come first: invoice variance with no matching change in infrastructure or business growth, and a flat host count next to a rising bill, which points to volume or feature growth rather than scale.</p>

<p>The contractual and structural ones are just as telling: plan or contract terms with no ceiling or advance notice clause on overages, licensed seats or modules nobody uses, APM spend sitting next to overlapping logging or observability tooling, and production tier instrumentation still running on non production environments. Underneath all of them is the same root cause: when no single person is accountable for this line item, it goes unmanaged by default, not by decision.</p>

<h2 id="apm-renewal-preparation">APM Renewal Preparation</h2>

<p>These costs go unnoticed because no single vantage point sees the whole picture. Someone needs to know the instrumentation scope, retention settings, and which environments are monitored. Someone needs to track spend against usage over time. Someone needs to know the contract terms, renewal date, and any negotiated caps. And someone needs to reconcile the invoice against budget and catch variance early. At a larger company those are four different people, in engineering, FinOps, procurement, and finance. At a smaller one, it might be the same engineer or founder wearing all four hats, which makes it easier to miss the gap between what one hat knows and what another hat is tracking.</p>

<p>In practice, whoever owns the budget for this line item usually drives the renewal, whether that’s a dedicated FinOps team or the person who first approved the tool. Either way, preparing usage data on a regular cadence, quarterly if you can manage it, beats scrambling at contract time. The preparation itself comes down to six steps:</p>

<ol>
  <li>Audit actual usage against contract terms, starting 60 to 90 days out (or ahead of your next plan review if you’re on a self-serve plan without a fixed renewal date). Pull 12 months of usage (host count at peak, not average; ingestion volume by category for metrics, traces, and logs; and retention actually used versus contracted) and flag any gap between what you’re paying for and what you’re actually using, in either direction.</li>
  <li>Identify what is inflating the bill. Check log ingestion volume specifically, look for orphaned agents on decommissioned hosts still reporting data, watch custom metric count and high cardinality tags for creep, and confirm non production environments are not running production tier instrumentation.</li>
  <li>Right size before renewing, not after. Cut unused seats, disable unused modules, reduce retention windows that exceed actual need, and turn off verbose logging left on in production, so you negotiate, or simply re-subscribe, from your actual usage number rather than the inflated one.</li>
  <li>Review the terms themselves, not just the price, whether or not you have a negotiated contract. Look at the overage rate versus the base rate (push for a cap or a lower multiplier if you’re in a position to negotiate one), any auto scaling clause and whether it carries advance notice or a ceiling, how often actual usage is reconciled against the plan, and termination and data export terms that confirm you are not locked in if you switch later.</li>
  <li>Benchmark before deciding. Get at least one competitive quote (Datadog, New Relic, Grafana Cloud, and so on), even if you do not intend to switch, and bring your actual usage number to the table, not the vendor’s projected one. If the bill has fully outgrown what you’re getting from it, <a href="https://www.fastruby.io/blog/open-source-apms">self-hosting your observability stack</a> is also worth pricing out, not just switching vendors.</li>
  <li>Assign an owner. One person, whether that’s a dedicated FinOps lead or simply whoever manages the account, owns the renewal outcome and stays accountable for usage monitoring afterward.</li>
</ol>

<p>That’s the same group as the quarterly cost review, however many people that group actually is; a renewal is just its highest leverage version.</p>

<h2 id="conclusion">Conclusion</h2>

<p>APM cost creep is rarely about APM. It’s a symptom of a familiar pattern in infrastructure decisions generally: tools get adopted quickly under real pressure, nobody assigns ongoing ownership, and by the time the cost or the technical debt is visible, it’s expensive to unwind.</p>

<p>If your APM bill has grown faster than your infrastructure, that’s worth a look. The tool itself is probably fine; the growth was likely never a decision at all, just the default outcome of nobody watching it.</p>

<p>That’s usually true of the surrounding infrastructure too. If you’re re-examining monitoring spend, it’s a good moment to ask the same question about your broader stack: what’s running today because someone chose it, and what’s running today because nobody revisited it?</p>

<p><em>Is your APM bill outgrowing your infrastructure? <a href="https://www.fastruby.io/#contactus">We can help</a> you audit where it’s leaking.</em></p>]]></content><author><name>shpm55</name></author><category term="best-practices" /><summary type="html"><![CDATA[APM bills grow because usage creeps up unmanaged, not because the tool changed. See where the spend actually goes, the warning signs to catch, and a renewal-prep checklist.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/image-fast-ruby-blog-real-cost-apm.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/image-fast-ruby-blog-real-cost-apm.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Repay Tech Debt with the Strangler Fig Pattern</title><link href="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.html" rel="alternate" type="text/html" title="Repay Tech Debt with the Strangler Fig Pattern" /><published>2026-08-28T05:23:08-04:00</published><updated>2026-08-28T05:23:08-04:00</updated><id>https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt</id><content type="html" xml:base="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.html"><![CDATA[<p>Replacing a business-critical legacy system does not have to mean committing to a risky, all-at-once rewrite. The strangler fig pattern offers a practical alternative: migrate one capability at a time, run the old and new systems side by side, and gradually retire the legacy code as each replacement is validated. This post walks through how it works, where it fits, and a practical Rails-based example of migrating one piece of functionality without stopping the business to do it.</p>

<!--more-->

<h2 id="the-problem-a-business-critical-system-youre-afraid-to-touch">The Problem: A Business-Critical System You’re Afraid to Touch</h2>

<p>Most organizations don’t wake up one day and decide to replace a legacy system. It happens gradually, as the cost and <a href="/blog/hidden-costs-of-technical-debt">risk of maintaining the system tends to increase over time</a>.</p>

<p>One common problem is that the application becomes difficult to change. As a result, teams may avoid making necessary improvements because the risk of introducing regressions is too high. Older dependencies may no longer be supported and infrastructure may require specialized operational knowledge. Security vulnerabilities accumulate. Old dependencies stop receiving patches, frameworks fall out of active support, and the gap between “known vulnerable” and “actually fixed” widens.</p>

<p>Despite these problems, replacing the application can be risky. The system may contain years of accumulated business logic, including edge cases that are not documented anywhere else. A complete replacement must account for those behaviors while continuing to support current users, integrations, and operational requirements. This makes a full rewrite difficult to estimate and potentially disruptive.</p>

<p>Doing nothing, however, is not a sustainable strategy. Technical debt continues to accumulate as dependencies age, knowledge is lost, and temporary workarounds become permanent parts of the application. Over time, the organization has fewer safe options and less flexibility in deciding how and when to replace the system.</p>

<h2 id="why-just-rewrite-it-doesnt-work">Why “Just Rewrite It” Doesn’t Work</h2>

<p>Faced with a system like this, the instinct is often to start fresh: freeze the old system, build a new one properly, and cut over once it’s ready. In practice, this approach carries its own set of serious risks, which is why full rewrites so often stall, run over budget, or get abandoned partway through.</p>

<p>Instead of a series of small, reversible changes, the business is betting on one large release, often after months (or years) of work, with no real-world validation until the very end. If something is wrong it surfaces all at once, after the cost of building the replacement has already been paid.</p>

<p>It is also rarely practical to freeze feature development while the replacement is being built. The existing application still supports an active business, and users continue to request changes. Regulations, market conditions, internal processes, and customer expectations may also change during the rewrite. As a result, the legacy system continues to evolve while the new system is under development.</p>

<p>If the old system keeps changing while the new one is being built, then the team is effectively maintaining two systems in parallel (one in production, one in development). This work grows more difficult the longer the rewrite takes, and it’s easy for parity to quietly erode without anyone noticing until launch.</p>

<h2 id="the-strangler-fig-pattern">The Strangler Fig Pattern</h2>

<p>The name comes from a real botanical phenomenon: strangler fig vines take root in the branches of a host tree, gradually growing around it. Over years, the fig develops its own root and trunk system, until eventually it no longer needs the host at all. Applied to software, this idea gives us a middle path between keeping a legacy system indefinitely and replacing it all at once.</p>

<p>The mechanics are straightforward:</p>

<ol>
  <li>First <strong>create a seam</strong>, a well-defined point where requests or behavior can be intercepted, such as a router, API gateway, or facade layer.</li>
  <li>Next <strong>intercept requests at that seam</strong> and decide, for each one, whether it should be handled by the legacy system or the new implementation.</li>
  <li>Then <strong>redirect traffic to the new implementation gradually</strong>, one slice of functionality at a time, validating each piece before moving to the next.</li>
  <li>Finally <strong>decommission the corresponding old code path</strong> once a piece has been fully and reliably replaced.</li>
</ol>

<p>The old and new systems therefore operate together for a period of time. As each replacement is tested and proven, more requests are routed to the new system and the corresponding legacy code path is decommissioned. This cycle continues until the legacy application has few or no remaining responsibilities and can be retired.</p>

<h2 id="how-this-helps-with-technical-debt">How This Helps With Technical Debt</h2>

<p>Before we get to the mechanics, it’s worth connecting this pattern to the problem it’s actually solving. The strangler fig pattern allows teams to reduce technical debt without stopping product development. The process can also improve the structure of the application. Creating seams between capabilities encourages clearer architectural boundaries, while migrating individual areas often leads to better automated tests, monitoring, and operational visibility. If you’re also looking to improve the code you’re keeping, our post on <a href="https://www.fastruby.io/blog/how-we-approach-refactoring-projects">refactoring Rails applications</a> covers how we approach that work alongside debt reduction.</p>

<p>However, creating a modern system is not enough on its own. Technical debt is only reduced when the corresponding legacy capability is removed. If the old code path remains active, the organization must maintain both implementations and may end up increasing complexity instead. For that reason, decommissioning should be part of the definition of done. Over time, this turns technical-debt reduction from a one-time project into an ongoing delivery practice: identify a costly area, replace it safely, remove the old implementation, and repeat.</p>

<h2 id="running-the-old-and-new-systems-together">Running the Old and New Systems Together</h2>

<p>The strangler fig pattern requires the legacy and modern systems to operate at the same time. A routing mechanism decides which system should handle each request.</p>

<p><strong>The routing mechanism can take a few different forms</strong>, depending on where the seam naturally falls in the architecture. This mechanism might be a reverse proxy, an API gateway, an application façade, or routing logic inside the application itself. For asynchronous workflows, a message broker or event stream can serve a similar purpose by directing events to legacy or modern consumers.</p>

<p>The other key decision we need to make is how to divide the system into pieces that can be migrated independently. There are two common approaches. Divide the system by <strong>route or endpoint</strong>, migrating one API endpoint or URL path at a time, well suited to systems with a clear request/response structure. Alternativly, use <strong>modules or domains</strong> as boundaries, then migrate one area of business logic at a time (billing, authentication, reporting), better suited to systems where the seams are conceptual rather than purely technical.</p>

<h2 id="managing-data-during-the-transition">Managing Data During the Transition</h2>

<p>Data is often the most difficult part of running the legacy and modern systems together. The key question is not only where data is stored, but: <strong>which system is the source of truth for it, right now?</strong></p>

<p>A few common approaches:</p>

<ul>
  <li>The <strong>legacy system remains the source of truth</strong>, even for capabilities that have already been migrated. The new system reads from or defers to the legacy system’s data, which keeps ownership simple but limits how independently the new system can operate.</li>
  <li>The <strong>new system takes ownership of data for capabilities it has already migrated</strong>, with the legacy system deferring to it instead. This is usually the direction migrations move toward over time.</li>
  <li>The two systems’ <strong>data is kept synchronized</strong>, with neither fully deferring to the other. This can work as a transitional state, but it requires clear rules about how conflicts are resolved when both systems can write.</li>
  <li>Changes are <strong>propagated via events</strong>, so that whichever system makes a change publishes it, and the other system consumes and applies it. This decouples the two systems more cleanly than direct synchronization, at the cost of eventual (rather than immediate) consistency.</li>
</ul>

<p>Whichever combination of these is used, the risk of leaving ownership ambiguous is the same. The main safeguard is explicit ownership. For each category of data, the team should define which system can write it, how changes are propagated, and how inconsistencies are detected and corrected. Without those restrictions, both systems can begin modifying the same information independently, making errors difficult to trace and resolve.</p>

<blockquote>
  <p>The data strategy may change as the migration progresses, but ownership should never be ambiguous.</p>
</blockquote>

<h2 id="mitigating-risk-along-the-way">Mitigating Risk Along the Way</h2>

<p>The strangler fig pattern reduces risk by design, simply by breaking a migration into smaller steps. But smaller steps need to be paired with safeguards that catch problems early and make it easy to back out when something isn’t working.</p>

<p>Feature flags and incremental traffic routing allow the team to control what traffic to send to the new implementation. A new capability can first be enabled for internal users, a small customer segment, or a limited percentage of requests. If problems appear, traffic can be redirected to the legacy implementation without needing to redeploy or reverse the entire migration.</p>

<p>Automated tests are important, but they are not sufficient on their own. The new system must also be <strong>observed in production</strong>, at minimum, this means tracking completed transactions, processing times, and mismatched records between the two systems.</p>

<p>Reconciliation processes help detect issues that normal monitoring may miss. For example, a scheduled comparison can verify that both systems processed the same orders, produced the same totals, or reached the same final state.</p>

<p>Every migration step should also have a documented <strong>rollback procedure</strong>. The team should know how to restore the previous routing, what happens to data created by the new system, and whether any actions need to be reconciled after the rollback.</p>

<h2 id="a-practical-game-plan">A Practical Game Plan</h2>

<p>A strangler fig migration usually follows a repeatable sequence.</p>

<ol>
  <li>Identify a seam. Look for a boundary that’s already relatively clean such as a single API endpoint, a self-contained module, or a domain with limited dependencies on the rest of the system.</li>
  <li>Build the interception point. Stand up whatever routing mechanism fits the seam (a proxy rule, a facade method, a conditional branch) so that traffic for this piece <em>can</em> be redirected, without yet redirecting all of it.</li>
  <li>Migrate one capability. The first candidate should be meaningful enough to provide value, but contained enough to limit risk. Moving one well-defined capability also gives the team a chance to test the migration approach before applying it more broadly.</li>
  <li>Validate before fully switching over. Tests confirm the new implementation behaves correctly in isolation. Shadow traffic sends real production requests to the new implementation without using its response, so its behavior can be observed under real conditions before it’s trusted. Finally we compare logging outputs of both systems side by side, looking for differences between the legacy and new implementations.</li>
  <li>Expand. Once the new capability behaves reliably, traffic can be shifted gradually. The same process can then be repeated for the next capability.</li>
  <li>Know what “done” looks like. It is critical that we define what “done” means for both the migration as a whole and each individual slice. We must know what capabilities need to move, what the legacy system’s retirement looks like, and how success will be measured along the way.</li>
</ol>

<h2 id="when-it-goes-wrong">When It Goes Wrong</h2>

<p>The strangler fig pattern is straightforward to describe, but easy to execute poorly. Most failures come from losing sight of the discipline the pattern requires, rather than from the pattern itself being wrong for the job. One common mistake is building the new system without retiring the old one, but there are other important missteps worth being aware of.</p>

<p>One such misstep is choosing the wrong migration boundaries. Moving technical components, such as a database table or utility module, may not remove a complete responsibility from the legacy system. Migrating an end-to-end business capability usually creates a clearer ownership boundary and makes decommissioning more practical.</p>

<p>A newer technology stack does not automatically produce a better system. Teams can reproduce the same coupling and unclear boundaries if they copy the legacy architecture too closely. At the same time, they may underestimate undocumented behavior and discover late in the process that important edge cases were not included in the replacement.</p>

<p>Trying to migrate too many areas at once can make these problems harder to control. Each stream introduces its own routing, data, testing, and operational concerns. A smaller number of focused migrations makes it easier to learn from each step and apply those lessons to the next one.</p>

<p>Modernization also needs business ownership. If it is treated as a side project, feature work will usually take priority and the legacy system will remain in place. The migration is more likely to succeed when each step has a business reason, a responsible owner, and a specific decommissioning outcome.</p>

<h2 id="a-practical-example">A Practical Example</h2>

<p>To make this concrete, consider a hypothetical order-management application, imagine a Rails monolith that’s been in production for years. It handles order creation, payment processing, shipment tracking, customer notifications, and reporting, all in one codebase.</p>

<p>The team decides to migrate customer notifications first. Notifications have a clear boundary, depend on a limited set of order data, and can be validated without impacting the critical path for order or payment processing.</p>

<p>Initially, the legacy application sends notifications directly after an order changes state:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_changed</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 1: The legacy system publishes an event.</strong></p>

<p>The first step is to introduce an event at the point where the order status changes. The specific mechanism depends on what’s already in the stack, this could be <a href="/blog/rails-event-notify.html"><code class="language-plaintext highlighter-rouge">ActiveSupport::EventReporter</code></a>, <code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications</code>, a Sidekiq job if the codebase already leans on background jobs, or a message broker like Kafka or SQS if the team wants stronger decoupling from the start. The example below uses <code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications</code>, since it requires no new infrastructure and is available in any Rails app.</p>

<p>The legacy application is changed to publish an event describing the change:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_changed</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This is the seam: a single, well-defined point where order status changes become observable to other systems, without yet changing who acts on them.</p>

<p><strong>Step 2: The new notification service consumes the event.</strong></p>

<p>For this example migration, the new notification logic lives in an isolated module within the same repository (<code class="language-plaintext highlighter-rouge">app/services/notifications_v2/</code>). A subscriber listens for <code class="language-plaintext highlighter-rouge">order.status_changed</code> and hands the payload off to a builder, which is defined in Step 3:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/notifications_v2/order_status_subscriber.rb</span>

<span class="k">module</span> <span class="nn">NotificationsV2</span>
  <span class="k">class</span> <span class="nc">OrderStatusSubscriber</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="s2">"order.status_changed"</span><span class="p">)</span> <span class="k">do</span> <span class="o">|*</span><span class="n">args</span><span class="o">|</span>
      <span class="n">event</span> <span class="o">=</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="o">::</span><span class="no">Event</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">)</span>
      <span class="c1"># MessageBuilder#build_and_log is shown in Step 3.</span>
      <span class="c1"># Here we construct and log a message, it does not send anything.</span>
      <span class="no">NotificationsV2</span><span class="o">::</span><span class="no">MessageBuilder</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">).</span><span class="nf">build_and_log</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>At this point, nothing customer-facing has changed. The new service is simply listening and building notifications for its own internal record.</p>

<p><strong>Step 3: Notifications are generated by both systems, but sent only by the legacy system.</strong></p>

<p>The legacy application continues to send every notification, exactly as before. The new service does not send anything, instead it generates what it <em>would</em> send, but its output is logged rather than delivered:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/services/notifications_v2/message_builder.rb</span>

<span class="k">module</span> <span class="nn">NotificationsV2</span>
  <span class="k">class</span> <span class="nc">MessageBuilder</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
      <span class="vi">@payload</span> <span class="o">=</span> <span class="n">payload</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">build_and_log</span>
      <span class="n">message</span> <span class="o">=</span> <span class="n">build_message</span>
      <span class="no">ComparisonLog</span><span class="p">.</span><span class="nf">record</span><span class="p">(</span>
        <span class="ss">order_id: </span><span class="vi">@payload</span><span class="p">[</span><span class="ss">:order_id</span><span class="p">],</span>
        <span class="ss">source: </span><span class="s2">"notifications_v2"</span><span class="p">,</span>
        <span class="ss">message: </span><span class="n">message</span>
      <span class="p">)</span>
      <span class="n">message</span>
    <span class="k">end</span>

    <span class="kp">private</span>

    <span class="k">def</span> <span class="nf">build_message</span>
      <span class="c1"># message construction logic</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>For this comparison to mean anything, the legacy mailer also needs to log what it actually sends. A small change records that:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/mailers/order_mailer.rb</span>

<span class="k">class</span> <span class="nc">LegacyOrderMailer</span> <span class="o">&lt;</span> <span class="no">ApplicationMailer</span>
  <span class="k">def</span> <span class="nf">status_notification</span><span class="p">(</span><span class="n">order</span><span class="p">)</span>
    <span class="no">LegacyNotificationLog</span><span class="p">.</span><span class="nf">record</span><span class="p">(</span>
      <span class="ss">order_id: </span><span class="n">order</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span>
      <span class="ss">message: </span><span class="n">notification_body</span><span class="p">(</span><span class="n">order</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="n">mail</span><span class="p">(</span><span class="ss">to: </span><span class="n">order</span><span class="p">.</span><span class="nf">customer</span><span class="p">.</span><span class="nf">email</span><span class="p">,</span> <span class="ss">subject: </span><span class="s2">"Order update"</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 4: Outputs are compared.</strong></p>

<p>A comparison job checks the legacy system’s sent notifications against the new service’s logged output for the same events, flagging any mismatches in content, timing, or recipients:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/jobs/notification_comparison_job.rb</span>

<span class="k">class</span> <span class="nc">NotificationComparisonJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>
    <span class="n">legacy</span> <span class="o">=</span> <span class="no">LegacyNotificationLog</span><span class="p">.</span><span class="nf">for_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>
    <span class="n">new_service</span> <span class="o">=</span> <span class="no">ComparisonLog</span><span class="p">.</span><span class="nf">for_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>

    <span class="k">unless</span> <span class="n">legacy</span><span class="p">.</span><span class="nf">message</span> <span class="o">==</span> <span class="n">new_service</span><span class="p">.</span><span class="nf">message</span>
      <span class="no">MismatchReporter</span><span class="p">.</span><span class="nf">report</span><span class="p">(</span><span class="n">order_id</span><span class="p">:,</span> <span class="n">legacy</span><span class="p">:,</span> <span class="n">new_service</span><span class="p">:)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This shadow period gives the team production evidence without changing customer-facing behavior. Differences can be reviewed to determine whether there are defects in the new service or undocumented behavior in the legacy application.</p>

<p><strong>Step 5: The new service starts sending notifications.</strong></p>

<p>Once mismatches have dropped to zero (or an accepted, understood baseline), a feature flag controls which implementation sends the notification. Before the flag is enabled, the new service is updated to actually deliver notifications. The <code class="language-plaintext highlighter-rouge">MessageBuilder</code> gains a <code class="language-plaintext highlighter-rouge">build_and_send</code> path alongside the existing <code class="language-plaintext highlighter-rouge">build_and_log</code>, using the same message construction logic already validated during the shadow period. The subscriber checks the feature flag to decide which path to call.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:send_status_notification</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">send_status_notification</span>
    <span class="c1"># Legacy mailer runs when the flag is off; new system takes over when enabled</span>
    <span class="no">LegacyOrderMailer</span><span class="p">.</span><span class="nf">status_notification</span><span class="p">(</span><span class="nb">self</span><span class="p">).</span><span class="nf">deliver_later</span> <span class="k">unless</span> <span class="no">Feature</span><span class="p">.</span><span class="nf">enabled?</span><span class="p">(</span><span class="ss">:modern_order_notifications</span><span class="p">,</span> <span class="n">customer</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="c1"># Always fires; the subscriber decides whether to act or just log</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The flag can first be enabled for internal accounts, then for a small percentage of customers, and eventually for all traffic. During the rollout, the team monitors delivery failures, processing times, duplicate notifications, and differences in message content.</p>

<p><strong>Step 6: Notification code is removed from the legacy application.</strong></p>

<p>The legacy notification-sending code is deleted, not just disabled. This includes everything from templates, delivery logic and related configuration. This is the step that actually pays down technical debt, everything before it was preparation.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/order.rb</span>

<span class="k">class</span> <span class="nc">Order</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">after_update</span> <span class="ss">:publish_status_event</span><span class="p">,</span> <span class="ss">if: :saved_change_to_status?</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">publish_status_event</span>
    <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
        <span class="s2">"order.status_changed"</span><span class="p">,</span>
        <span class="ss">order_id: </span><span class="nb">id</span><span class="p">,</span>
        <span class="ss">status: </span><span class="n">status</span><span class="p">,</span>
        <span class="ss">customer_id: </span><span class="n">customer_id</span><span class="p">,</span>
        <span class="ss">occurred_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span>
        <span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Step 7: The team moves on to shipment tracking.</strong></p>

<p>With one capability fully migrated and one seam retired, the same process (publish an event, build the new implementation, compare outputs, cut over, remove the old code) is applied to the next capability. Order creation, payment processing, and reporting remain untouched in the legacy system for now, each waiting its turn.</p>

<p>Nothing in this example required stopping the order-management system, freezing feature work, or making a single high-stakes cutover. The legacy and new systems coexisted for exactly as long as it took to validate one piece of functionality.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Replacing a legacy system does not require a single high-risk launch. As this post has covered, the strangler fig pattern offers a way to modernize gradually. It allows us to validate each replacement in production before committing to it, while keeping the business operating throughout the transition.</p>

<p>It is particularly useful when the legacy system is large enough that a full rewrite is not practical, must remain available, and cannot be replaced without interrupting ongoing feature development. It also works best when the application contains distinct capabilities that can be separated and migrated independently.</p>

<p>The pattern may be unnecessary for a small system that can be replaced safely in a short period. For a large, business-critical application, however, spreading risk across a series of controlled releases is often more practical than concentrating it in one cutover.</p>

<p>Technical debt reduction, approached this way, stops being a single high-stakes project and becomes something closer to routine engineering practice. Done well, modernization becomes a sequence of measurable and reversible improvements. Each step reduces the scope of the legacy system, lowers a specific source of technical debt, and creates a safer foundation for the next migration.</p>

<p>Is your Rails application carrying more legacy code than it should? <a href="/#contactus">We can help</a>.</p>]]></content><author><name>fbuys</name></author><category term="technical-debt" /><summary type="html"><![CDATA[Learn how the strangler fig pattern lets teams modernize legacy Rails apps gradually, reducing technical debt without a risky all-at-once rewrite.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/how-the-strangler-fig-pattern-helps-teams-pay-down-technical-debt.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">What Replacing React with Hotwire Really Costs</title><link href="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.html" rel="alternate" type="text/html" title="What Replacing React with Hotwire Really Costs" /><published>2026-08-25T05:00:00-04:00</published><updated>2026-08-25T05:00:00-04:00</updated><id>https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs</id><content type="html" xml:base="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.html"><![CDATA[<p>It’s common to see a Rails app using React to handle front-end interactions, with a Redux store that mostly mirrors the database and <code class="language-plaintext highlighter-rouge">react-router</code> re-declaring routes Rails already knows about. React does its job well enough, but every user-facing feature now costs twice, once in Ruby and once in JavaScript.</p>

<p>So eventually the question will appear: <em>what would it take to delete all of this and use a Rails-way solution like <a href="https://hotwired.dev/">Hotwire</a>?</em> The honest answer is “it depends.” In this article, we will go through what the code difference actually looks like, what you can expect from bundle size, which pain points to plan for, and how to scope the work before committing to it.</p>

<!--more-->

<h2 id="the-code-difference">The code difference</h2>

<p>Creating a plain list of todos in classic React + Redux takes an action creator, a reducer, and a connected component. That is three JS files, plus an API controller and serializer on the Rails side. Redux Toolkit collapses a lot of this boilerplate, but the apps that raise this question are rarely on it:</p>

<div class="language-jsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// actions/todos.js</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">fetchTodos</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">async </span><span class="p">(</span><span class="nx">dispatch</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_REQUEST</span><span class="dl">"</span> <span class="p">});</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/todos</span><span class="dl">"</span><span class="p">);</span>
    <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_SUCCESS</span><span class="dl">"</span><span class="p">,</span> <span class="na">payload</span><span class="p">:</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="p">});</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">dispatch</span><span class="p">({</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_FAILURE</span><span class="dl">"</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">e</span><span class="p">.</span><span class="nx">message</span> <span class="p">});</span>
  <span class="p">}</span>
<span class="p">};</span>

<span class="c1">// reducers/todos.js</span>
<span class="kd">const</span> <span class="nx">initialState</span> <span class="o">=</span> <span class="p">{</span> <span class="na">items</span><span class="p">:</span> <span class="p">[],</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="kc">null</span> <span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="kd">function</span> <span class="nf">todos</span><span class="p">(</span><span class="nx">state</span> <span class="o">=</span> <span class="nx">initialState</span><span class="p">,</span> <span class="nx">action</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">switch </span><span class="p">(</span><span class="nx">action</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_REQUEST</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">true</span> <span class="p">};</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_SUCCESS</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">items</span><span class="p">:</span> <span class="nx">action</span><span class="p">.</span><span class="nx">payload</span> <span class="p">};</span>
    <span class="k">case</span> <span class="dl">"</span><span class="s2">FETCH_TODOS_FAILURE</span><span class="dl">"</span><span class="p">:</span> <span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">state</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">error</span><span class="p">:</span> <span class="nx">action</span><span class="p">.</span><span class="nx">error</span> <span class="p">};</span>
    <span class="nl">default</span><span class="p">:</span> <span class="k">return</span> <span class="nx">state</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// components/TodoList.jsx</span>
<span class="kd">class</span> <span class="nc">TodoList</span> <span class="kd">extends</span> <span class="nc">Component</span> <span class="p">{</span>
  <span class="nf">componentDidMount</span><span class="p">()</span> <span class="p">{</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">.</span><span class="nf">fetchTodos</span><span class="p">();</span> <span class="p">}</span>
  <span class="nf">render</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">loading</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">loading</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Loading…<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;;</span>
    <span class="k">return</span> <span class="p">&lt;</span><span class="nt">ul</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">items</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">t</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">&lt;</span><span class="nt">li</span> <span class="na">key</span><span class="p">=</span><span class="si">{</span><span class="nx">t</span><span class="p">.</span><span class="nx">id</span><span class="si">}</span><span class="p">&gt;</span><span class="si">{</span><span class="nx">t</span><span class="p">.</span><span class="nx">title</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">li</span><span class="p">&gt;)</span><span class="si">}</span><span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;;</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="k">export</span> <span class="k">default</span> <span class="nf">connect</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">items</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nx">todos</span><span class="p">.</span><span class="nx">items</span><span class="p">,</span> <span class="na">loading</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nx">todos</span><span class="p">.</span><span class="nx">loading</span> <span class="p">}),</span> <span class="p">{</span> <span class="nx">fetchTodos</span> <span class="p">})(</span><span class="nx">TodoList</span><span class="p">);</span>
</code></pre></div></div>

<p>The same feature in Hotwire would require only a controller and a view, no store, no serializer, no loading state, because the server sends HTML that’s already populated:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/todos_controller.rb</span>
<span class="k">class</span> <span class="nc">TodosController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@todos</span> <span class="o">=</span> <span class="n">current_user</span><span class="p">.</span><span class="nf">todos</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/todos/index.html.erb %&gt;</span>
<span class="nt">&lt;ul&gt;</span>
  <span class="cp">&lt;%</span> <span class="vi">@todos</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">todo</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;li&gt;</span><span class="cp">&lt;%=</span> <span class="n">todo</span><span class="p">.</span><span class="nf">title</span> <span class="cp">%&gt;</span><span class="nt">&lt;/li&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/ul&gt;</span>
</code></pre></div></div>

<p>That gap is why the migration is worth doing, and it’s also where the cost comes from. Porting a screen means deleting a whole layer and moving its responsibilities back to the server, which is slower work than translating code line for line. That cost lands on developers, and it’s paid during the migration rather than after.</p>

<p>It’s hard to port a screen mechanically. Each one has to be rethought: where its state lived, what the server can render directly, and what genuinely needs to stay in JavaScript. The client-side tests won’t carry over either, so every flow has to be re-covered.</p>

<h2 id="interactivity">Interactivity</h2>

<p>Hotwire covers interactivity in two ways. For server-driven updates, Turbo Streams replace just the piece that changed, no reducer, no re-rendering the whole list. A simple toggle action that used to be a Redux action and a connected component becomes a single controller action and a Turbo Stream view that re-renders the todo partial:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/todos/toggle.turbo_stream.erb %&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">turbo_stream</span><span class="p">.</span><span class="nf">replace</span> <span class="vi">@todo</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>For client-side behavior, a small Stimulus controller replaces what used to be a stateful component. The mental shift is the whole point: a Redux store that mirrors the database makes the client the source of truth and syncs it to the server; Hotwire keeps the server as the source of truth.</p>

<p>The case for the second model isn’t speed. React can be fast, and on most screens users won’t feel the difference either way. The real cost is keeping two copies of the truth in sync: every mutation updates the server, then reconciles the local store, invalidates its cache, and manages its own loading, error, and rollback states. A data layer like React Query or RTK Query automates much of that, but the apps that prompt this question are usually on a hand-rolled Redux store where all of it is yours to maintain.</p>

<p>Hotwire deletes most of that work: with one source of truth, there’s no client store to reconcile. Cache invalidation is still yours, and Turbo’s page cache can still show a stale preview on a restoration visit, but you maintain one copy of the truth instead of two. Where a screen genuinely needs rich client state, React earns that overhead; most screens don’t, and that’s the trade the migration is really making.</p>

<h2 id="bundle-size">Bundle size</h2>

<p>This is the number stakeholders actually feel. <code class="language-plaintext highlighter-rouge">react</code> + <code class="language-plaintext highlighter-rouge">react-dom</code> alone are about 45 KB gzipped before Redux, the router, and your own code, and a mature SPA bundle usually lands in the low hundreds of KB gzipped. Turbo and Stimulus together are about 35 KB gzipped, and with <a href="https://www.fastruby.io/blog/the-assets-pipeline-history">importmaps</a> you can often drop the JS build step entirely. That leaves a hundred KB or more of JavaScript that never has to be downloaded, parsed, and executed on every page load.</p>

<h2 id="what-drives-the-cost-and-what-bites">What drives the cost, and what bites</h2>

<p>Two apps with the same page count can differ in effort by an order of magnitude. The estimate is driven by how much <em>genuine client-side state</em> and <em>app-like behavior</em> you have (optimistic UI, wizards, real-time dashboards, drag-and-drop), far more than by raw page count.</p>

<p>A handful of pain points show up in this work every time, and they are worth planning for before the first screen moves.</p>

<p>Most of the Redux store turns out to be server state in disguise, and it disappears along with the API that fed it. The hard cases are optimistic UI and unsaved multi-step forms, and for each of those you have to decide deliberately whether it becomes a server round trip, a bit of Stimulus state, or a screen you leave in JavaScript for now. Routing is a similar story. Anything stored <em>in</em> the front-end route, a modal as a URL or a wizard step, needs a home on the server, so it’s worth verifying deep links, the back button, and scroll restoration as you go.</p>

<p>The complex widgets deserve to be identified first. They are usually the wrong thing to rebuild in Hotwire and the right thing to keep as islands, since reimplementing a mature JS date picker or drag-and-drop grid in Stimulus is where effort tends to blow up. The JSON API is easier to reason about: if it only feeds React, you delete it along with the front end, but if a mobile app or a partner also consumes it, you will be maintaining two paradigms for the length of the transition.</p>

<p>Testing and authentication are the two slices teams tend to underestimate. The React app’s component tests won’t translate, and end-to-end coverage of the flows you touch is what makes this safe, so expect to <a href="https://www.fastruby.io/blog/how-to-make-tests-better.html">write tests</a> ideally <em>before</em> each migration rather than after. And if the SPA authenticates with tokens, moving to Rails sessions and cookies touches login, session expiry, and CSRF, which is enough surface area to plan as its own slice instead of folding it into a screen.</p>

<h2 id="how-to-scope-it">How to scope it</h2>

<p>A practical approach is to turn the unknown into a spreadsheet: inventory every screen, Redux slice, and front-end-only API endpoint, then bucket each screen. <strong>A</strong> trivially server-rendered, <strong>B</strong> needs a Stimulus sprinkle, <strong>C</strong> needs a real JS island, <strong>D</strong> stays as React for now.</p>

<p>Starting with the A/B buckets gets the cheapest wins first, and builds the team’s Hotwire fluency before anything hard gets touched. It’s the same logic we apply to <a href="https://www.fastruby.io/blog/can-you-upgrade-in-increments">upgrading Rails in increments</a>, where small shippable slices beat a long-lived branch. To keep the work fundable, track numbers people can watch: lines of JavaScript deleted, dependencies removed, bundle size, and screens migrated out of the total.</p>

<h2 id="conclusion">Conclusion</h2>

<p>One caveat before you start: this work pays off least when the app is genuinely application-like, such as heavy real-time collaboration, serious offline support, or rich client state a round trip would ruin. There the SPA earns its keep, and the better investment is modernizing it.</p>

<p>For everything else, the honest estimate comes out of the inventory: how many screens land in the A and B buckets, how many complex widgets you have to keep as islands, and how much of the Redux store turns out to be server state in disguise. Start with the cheap buckets, track the numbers people can watch, and let the hard screens wait until the team has the Hotwire fluency to handle them. One thing worth keeping in mind: the D bucket is allowed to stay a D bucket. An app that serves most of its screens with Hotwire and keeps three React islands is a perfectly reasonable place to stop, and stopping there is usually cheaper than chasing the last few screens.</p>

<p>Weighing a move off React and Redux and want a realistic read on the cost and the right incremental path? <a href="https://www.fastruby.io/#contactus">Talk to us today!</a></p>]]></content><author><name>hmdros</name></author><category term="javascript" /><summary type="html"><![CDATA[Replacing a legacy React and Redux layer with Hotwire sounds tempting. Here is an honest look at the effort, the trade-offs, and the pain points to weigh first.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.png" /><media:content medium="image" url="https://www.fastruby.io/blog/what-replacing-react-with-hotwire-really-costs.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">SimpleCov is now version 1!</title><link href="https://www.fastruby.io/blog/simplecov-1-0-release.html" rel="alternate" type="text/html" title="SimpleCov is now version 1!" /><published>2026-08-20T15:05:36-04:00</published><updated>2026-08-20T15:05:36-04:00</updated><id>https://www.fastruby.io/blog/simplecov-1-0-release</id><content type="html" xml:base="https://www.fastruby.io/blog/simplecov-1-0-release.html"><![CDATA[<p>Here at FastRuby.io we work with test suites and test coverage reports every single day, given our specialty. In the Ruby world that means
we interact with SimpleCov every day. Not only that, we explicitely rely on SimpleCov reports in two of the open source projects we maintain,
<a href="https://github.com/whitesmith/rubycritic">RubyCritic</a> and <a href="https://github.com/fastruby/skunk">Skunk</a>.</p>

<p>Which is why, after more than a decade at 0.x, we were super excited to see <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a> ship its first stable release on July 12, 2026.
In this article we’d like to share the details on the breaking changes, the deprecations and how to address them, if you’re impacted by them.</p>

<!--more-->

<p>Everything below refers only to the <a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">1.0.0 entry in the changelog</a>.</p>

<p>Also, it’s worth noting that the minimum required Ruby version for this release is 3.2.</p>

<h2 id="breaking-changes">Breaking changes</h2>

<h3 id="how-filters-match-paths">How filters match paths</h3>

<p>Two related changes can quietly alter which files end up in your report.</p>

<p>The first is that <code class="language-plaintext highlighter-rouge">SourceFile#project_filename</code> now returns a relative path, <code class="language-plaintext highlighter-rouge">lib/foo.rb</code> instead of <code class="language-plaintext highlighter-rouge">/lib/foo.rb</code>, which affects any anchored <code class="language-plaintext highlighter-rouge">RegexFilter</code> that relied on the leading slash. Because this breaks without a warning, you need to grep your codebase for patterns relying on the leading slash:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s2">"add_filter</span><span class="se">\|</span><span class="s2">remove_filter</span><span class="se">\|</span><span class="s2">add_group"</span> .simplecov spec/spec_helper.rb <span class="nb">test</span>/test_helper.rb
</code></pre></div></div>

<p>Any pattern that anchors on a slash needs rewriting:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">add_filter</span> <span class="sr">%r{^/lib/}</span>

<span class="c1"># After</span>
<span class="n">add_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">lib/}</span>
</code></pre></div></div>

<p>The second change is that <code class="language-plaintext highlighter-rouge">StringFilter</code> now matches at path-segment boundaries, so <code class="language-plaintext highlighter-rouge">"lib"</code> matches <code class="language-plaintext highlighter-rouge">/lib/</code> but no longer matches <code class="language-plaintext highlighter-rouge">/library/</code>. If you were deliberately relying on substring behavior, like saying <code class="language-plaintext highlighter-rouge">add_filter "_spec"</code> to catch <code class="language-plaintext highlighter-rouge">user_spec.rb</code>, switch that filter to a <code class="language-plaintext highlighter-rouge">Regexp</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before: matched anything containing the substring</span>
<span class="n">add_filter</span> <span class="s2">"generated"</span>

<span class="c1"># After: explicit substring matching</span>
<span class="n">add_filter</span><span class="p">(</span><span class="sr">/generated/</span><span class="p">)</span>
</code></pre></div></div>

<p>This change has one very important observation to note, however: An argument containing a dot is still treated as a filename pattern and still matches as a substring, so <code class="language-plaintext highlighter-rouge">add_filter "test.rb"</code> continues to catch <code class="language-plaintext highlighter-rouge">faked_test.rb</code>. A trailing slash likewise still means directory-only matching. Only dot-free arguments like <code class="language-plaintext highlighter-rouge">"lib"</code> or <code class="language-plaintext highlighter-rouge">"_spec"</code> became segment-anchored.</p>

<h3 id="the-status-line-moved-to-stderr">The status line moved to stderr</h3>

<p>The “Coverage report generated for X to Y” line, and the per-criterion totals under it, now go to stderr instead of stdout. If a CI script of yours parsed that line from stdout, it will now see nothing. You can suppress the message entirely on the formatter:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">formatter</span> <span class="o">=</span> <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">HTMLFormatter</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">silent: </span><span class="kp">true</span><span class="p">)</span>
</code></pre></div></div>

<p>Or you can restore the old behavior at the call site:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>rspec 2&gt;&amp;1
</code></pre></div></div>

<h3 id="htmlformatter-now-outputs-coveragejson">HTMLFormatter now outputs coverage.json</h3>

<p>SimpleCov used to automatically activate <code class="language-plaintext highlighter-rouge">JSONFormatter</code> when the <code class="language-plaintext highlighter-rouge">CC_TEST_REPORTER_ID</code> environment variable was set, and that special case is gone. It is no longer needed, because the default <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> now writes <code class="language-plaintext highlighter-rouge">coverage.json</code> alongside the HTML report, serializing the same payload <code class="language-plaintext highlighter-rouge">JSONFormatter</code> would. If you relied on the old auto-activation and you <em>weren’t</em> using the HTML formatter, you’ll need to add either the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> or the <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> explicitly. Note that only one of them is needed
and using <code class="language-plaintext highlighter-rouge">HTMLFormatter</code> gives you the <code class="language-plaintext highlighter-rouge">JSONFormatter</code> output plus the HTML report.</p>

<h3 id="coveragejson-schema-change"><code class="language-plaintext highlighter-rouge">coverage.json</code> schema change</h3>

<p><code class="language-plaintext highlighter-rouge">coverage.json</code> changed shape, from <code class="language-plaintext highlighter-rouge">{ "covered_percent": 80.0 }</code> to the full <code class="language-plaintext highlighter-rouge">{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }</code>, with <code class="language-plaintext highlighter-rouge">covered_percent</code> renamed to <code class="language-plaintext highlighter-rouge">percent</code>. On a related note, both <code class="language-plaintext highlighter-rouge">simplecov_json_formatter</code> and <code class="language-plaintext highlighter-rouge">simplecov-html</code> have been merged into the main gem. The old requires still work through a shim, so you can drop the separate gems from your <code class="language-plaintext highlighter-rouge">Gemfile</code> whenever it is convenient.</p>

<h3 id="simplecovstart-now-loads-the-test_frameworks-profile-by-default">SimpleCov.start now loads the test_frameworks profile by default</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> with no arguments now loads the <code class="language-plaintext highlighter-rouge">test_frameworks</code> profile by default, which filters out paths under <code class="language-plaintext highlighter-rouge">test/</code>, <code class="language-plaintext highlighter-rouge">spec/</code>, <code class="language-plaintext highlighter-rouge">features/</code>, and
<code class="language-plaintext highlighter-rouge">autotest/</code>. Rails applications usually call <code class="language-plaintext highlighter-rouge">SimpleCov.start "rails"</code> instead, and the <code class="language-plaintext highlighter-rouge">rails</code> profile has loaded <code class="language-plaintext highlighter-rouge">test_frameworks</code> for as long as the profile has
existed, so those projects need no change.</p>

<p>If you do call <code class="language-plaintext highlighter-rouge">start</code> with no arguments, your report will drop its overall coverage percentage, and how much depends on the ratio of test code to application
code. Should you also set the <code class="language-plaintext highlighter-rouge">minimum_coverage</code> attribute and the new number fall below that threshold, you’ll need to either establish a new acceptable
threshold or remove the filters:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Option A: accept the more honest number and re-baseline the threshold</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage</span> <span class="mi">80</span>

<span class="c1"># Option B: keep the old behavior by dropping the new filter</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">remove_filter</span> <span class="sr">%r{</span><span class="se">\A</span><span class="sr">(test|features|spec|autotest)/}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Option B is genuinely useful if you want to surface dead test helpers that nothing calls anymore. For everything else, re-baselining is the better move. If the
number you land on is lower than you are comfortable with, we put together <a href="https://www.fastruby.io/blog/10-strategies-for-upgrading-ruby-or-rails-applications-with-low-test-coverage">10 strategies for upgrading apps with low test coverage</a>
that should help.</p>

<h3 id="parallel-waits-and-two-removals">Parallel waits and two removals</h3>

<p>Under <code class="language-plaintext highlighter-rouge">parallel_tests</code>, SimpleCov now waits in the first process rather than the last, using <code class="language-plaintext highlighter-rouge">ParallelTests.first_process?</code>, which matches what the <code class="language-plaintext highlighter-rouge">parallel_tests</code> README recommends. For most people this fixes a deadlock, and the old <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS=1</code> workaround is no longer needed. The rare project that wired up its own <code class="language-plaintext highlighter-rouge">wait_for_other_processes_to_finish</code> in an <code class="language-plaintext highlighter-rouge">after(:suite)</code> hook keyed on <code class="language-plaintext highlighter-rouge">last_process?</code> now hits the symmetric deadlock and has to switch to <code class="language-plaintext highlighter-rouge">first_process?</code>.</p>

<p>Two removals round out this section. <code class="language-plaintext highlighter-rouge">SimpleCov.coverage_criterion</code> is gone and <code class="language-plaintext highlighter-rouge">primary_coverage</code> (or <code class="language-plaintext highlighter-rouge">coverage :branch, primary: true</code>) replaces it and the <code class="language-plaintext highlighter-rouge">docile</code> dependency is gone too, with <code class="language-plaintext highlighter-rouge">SimpleCov.configure</code> blocks now evaluated via <code class="language-plaintext highlighter-rouge">instance_exec</code> and instance variable proxying, which needs no action on your side unless you were doing something exotic inside a configure block.</p>

<h2 id="deprecations">Deprecations</h2>

<p>Below we give a small description of the deprecations that were created in this version. While these don’t break now, we recommend effecting these changes
so that future upgrades go smoothly.</p>

<h3 id="the-configuration-api-was-redesigned">The configuration API was redesigned</h3>

<p>The config API has been reorganized around a smaller, more consistent set of verbs:</p>

<table>
  <thead>
    <tr>
      <th>Legacy</th>
      <th>Replacement</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_filter</code></td>
      <td><code class="language-plaintext highlighter-rouge">skip</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">add_group</code></td>
      <td><code class="language-plaintext highlighter-rouge">group</code></td>
      <td>Identical matcher grammar, no behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">track_files</code></td>
      <td><code class="language-plaintext highlighter-rouge">cover</code></td>
      <td>Behavior differs, see below</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">use_merging</code></td>
      <td><code class="language-plaintext highlighter-rouge">merging</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_for_subprocesses</code></td>
      <td><code class="language-plaintext highlighter-rouge">merge_subprocesses</code></td>
      <td>No behavior change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage_for_eval</code></td>
      <td><code class="language-plaintext highlighter-rouge">enable_coverage :eval</code></td>
      <td>Folds into the same call as <code class="language-plaintext highlighter-rouge">:line</code> / <code class="language-plaintext highlighter-rouge">:branch</code> / <code class="language-plaintext highlighter-rouge">:method</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">print_error_status</code> (reader)</td>
      <td><code class="language-plaintext highlighter-rouge">print_errors</code></td>
      <td>The <code class="language-plaintext highlighter-rouge">print_error_status=</code> writer is unaffected for now</td>
    </tr>
  </tbody>
</table>

<p>Most of these are a straight rename:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">add_filter</span> <span class="s2">"/vendor/"</span>
  <span class="n">add_group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">use_merging</span> <span class="kp">true</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/vendor/"</span>
  <span class="n">group</span> <span class="s2">"Services"</span><span class="p">,</span> <span class="s2">"app/services"</span>
  <span class="n">merging</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The one to pay attention to is <code class="language-plaintext highlighter-rouge">cover</code>. It includes unloaded files the way <code class="language-plaintext highlighter-rouge">track_files</code> did, but it also restricts the report to the matching set given to it, so a single <code class="language-plaintext highlighter-rouge">cover "lib/**/*.rb"</code> will drop <code class="language-plaintext highlighter-rouge">app/</code> from your report entirely. To keep the old behavior, pass every directory you want reported:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">track_files</span> <span class="s2">"lib/**/*.rb"</span>

<span class="c1"># After: list everything, not just the previously-tracked glob</span>
<span class="n">cover</span> <span class="s2">"lib/**/*.rb"</span><span class="p">,</span> <span class="s2">"app/**/*.rb"</span>
</code></pre></div></div>

<h3 id="simplecovstart-cannot-be-called-from-simplecov">SimpleCov.start cannot be called from .simplecov</h3>

<p>Calling <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> from <code class="language-plaintext highlighter-rouge">.simplecov</code> is deprecated. Tracking still begins for backward compatibility, but you get a one-time warning, and a future release will require the explicit call to live in <code class="language-plaintext highlighter-rouge">spec_helper.rb</code> or <code class="language-plaintext highlighter-rouge">test_helper.rb</code>. Treat <code class="language-plaintext highlighter-rouge">.simplecov</code> as configuration only:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .simplecov</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span>
  <span class="n">skip</span> <span class="s2">"/spec/"</span>
  <span class="n">minimum_coverage</span> <span class="mi">90</span>
<span class="k">end</span>

<span class="c1"># spec/spec_helper.rb, at the very top, before anything else is required</span>
<span class="nb">require</span> <span class="s2">"simplecov"</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="s2">"rails"</span>
</code></pre></div></div>

<h3 id="nocov-comments-and-associated-configurations-are-deprecated">:nocov: comments and associated configurations are deprecated</h3>

<p>This means specifically the <code class="language-plaintext highlighter-rouge">SimpleCov.nocov_token</code> and <code class="language-plaintext highlighter-rouge">SimpleCov.skip_token</code> configurations. Every file still using <code class="language-plaintext highlighter-rouge"># :nocov:</code> emits a one-time warning to stderr at load time. Instances of <code class="language-plaintext highlighter-rouge"># :nocov:</code> must be replaced with the new directive comments, <code class="language-plaintext highlighter-rouge"># simplecov:enable</code> and <code class="language-plaintext highlighter-rouge"># simplecov:disable</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="c1"># :nocov:</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># :nocov:</span>

<span class="c1"># After</span>
<span class="c1"># simplecov:disable</span>
<span class="k">def</span> <span class="nf">hard_to_test</span>
  <span class="n">legacy_thing</span>
<span class="k">end</span>
<span class="c1"># simplecov:enable</span>
</code></pre></div></div>

<p>It’s worth mentioning that the directive comments also allow you to specify if you want to disable only line, branch or method coverage, like so: <code class="language-plaintext highlighter-rouge"># simplecov:disable line</code>. By default, all 3 are enabled.</p>

<h3 id="branches_coverage_percent-and-methods_coverage_percent-are-deprecated"><code class="language-plaintext highlighter-rouge">branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">methods_coverage_percent</code> are deprecated</h3>

<p>This will likely affect you only if you have custom formatters. <code class="language-plaintext highlighter-rouge">SimpleCov::SourceFile#branches_coverage_percent</code> and <code class="language-plaintext highlighter-rouge">#methods_coverage_percent</code> are now replaced by <code class="language-plaintext highlighter-rouge">covered_percent</code> which take a criterion argument that defaults to <code class="language-plaintext highlighter-rouge">:line</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="n">file</span><span class="p">.</span><span class="nf">branches_coverage_percent</span>
<span class="n">file</span><span class="p">.</span><span class="nf">methods_coverage_percent</span>

<span class="c1"># After</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:branch</span><span class="p">)</span>
<span class="n">file</span><span class="p">.</span><span class="nf">covered_percent</span><span class="p">(</span><span class="ss">:method</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="minimum_coverage_by_-setters-deprecated"><code class="language-plaintext highlighter-rouge">minimum_coverage_by_*</code> setters deprecated</h3>

<p>Finally, the <code class="language-plaintext highlighter-rouge">minimum_coverage_by_file</code> and <code class="language-plaintext highlighter-rouge">minimum_coverage_by_group</code> setters give way to the new <code class="language-plaintext highlighter-rouge">coverage</code> method’s <code class="language-plaintext highlighter-rouge">minimum_per_file</code> and <code class="language-plaintext highlighter-rouge">minimum_per_group</code> verbs:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">minimum_coverage_by_file</span> <span class="ss">line: </span><span class="mi">70</span><span class="p">,</span> <span class="s2">"app/x.rb"</span> <span class="o">=&gt;</span> <span class="mi">100</span>

<span class="c1"># After</span>
<span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage</span><span class="p">(</span><span class="ss">:line</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">minimum_per_file</span> <span class="mi">70</span>
  <span class="n">minimum_per_file</span> <span class="mi">100</span><span class="p">,</span> <span class="ss">only: </span><span class="s2">"app/x.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The no-arg getters are unchanged, and only the setter forms warn.</p>

<h2 id="new-features-worth-your-time">New features worth your time</h2>

<p>Once you are migrated, there is a real payoff here.</p>

<h3 id="configuring-criteria-and-scoping-the-report">Configuring criteria and scoping the report</h3>

<p>The new criterion-first <code class="language-plaintext highlighter-rouge">coverage</code> method configures each criterion (<code class="language-plaintext highlighter-rouge">:line</code>, <code class="language-plaintext highlighter-rouge">:branch</code>, <code class="language-plaintext highlighter-rouge">:method</code>) in one place, with identical syntax regardless of which one you are configuring:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">coverage</span> <span class="ss">:line</span> <span class="k">do</span>
    <span class="n">minimum</span> <span class="mi">90</span>
    <span class="n">minimum_per_file</span> <span class="mi">80</span>
    <span class="n">maximum_drop</span> <span class="mi">5</span>
  <span class="k">end</span>

  <span class="n">coverage</span> <span class="ss">:branch</span><span class="p">,</span> <span class="ss">minimum: </span><span class="mi">80</span><span class="p">,</span> <span class="ss">primary: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The options are <code class="language-plaintext highlighter-rouge">minimum</code>, <code class="language-plaintext highlighter-rouge">maximum</code>, <code class="language-plaintext highlighter-rouge">exact</code>, <code class="language-plaintext highlighter-rouge">maximum_drop</code>, <code class="language-plaintext highlighter-rouge">minimum_per_file</code> (with <code class="language-plaintext highlighter-rouge">only:</code> overrides), and <code class="language-plaintext highlighter-rouge">minimum_per_group</code>.</p>

<p>Alongside it, <code class="language-plaintext highlighter-rouge">SimpleCov.cover</code> finally provides an allowlist. Where <code class="language-plaintext highlighter-rouge">add_filter</code> could only ever subtract, <code class="language-plaintext highlighter-rouge">cover</code> is the positive counterpart:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">cover</span> <span class="s2">"app/**/*.rb"</span><span class="p">,</span> <span class="s2">"lib/**/*.rb"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>It accepts string globs, Regexps, blocks, or arrays of those, and multiple calls union together.</p>

<p>Finally, if you want to start from a blank slate, <code class="language-plaintext highlighter-rouge">SimpleCov.no_default_skips</code> opts out of the filters <code class="language-plaintext highlighter-rouge">SimpleCov.start</code> installs.</p>

<h3 id="directive-comments-and-synthetic-branches">Directive comments and synthetic branches</h3>

<p>On the branch side, <code class="language-plaintext highlighter-rouge">SimpleCov.ignore_branches</code> lets you opt out of the synthetic <code class="language-plaintext highlighter-rouge">:else</code> branches that Ruby’s <code class="language-plaintext highlighter-rouge">Coverage</code> library reports for constructs with no literal <code class="language-plaintext highlighter-rouge">else</code> keyword, which includes exhaustive <code class="language-plaintext highlighter-rouge">case/in</code> matches, <code class="language-plaintext highlighter-rouge">case/when</code> without <code class="language-plaintext highlighter-rouge">else</code>, <code class="language-plaintext highlighter-rouge">||=</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;=</code>, and <code class="language-plaintext highlighter-rouge">if</code> or <code class="language-plaintext highlighter-rouge">unless</code> without <code class="language-plaintext highlighter-rouge">else</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">start</span> <span class="k">do</span>
  <span class="n">enable_coverage</span> <span class="ss">:branch</span>
  <span class="n">ignore_branches</span> <span class="ss">:implicit_else</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Rails applications get something else useful here: <code class="language-plaintext highlighter-rouge">ignore_branches :eval_generated</code> and the new <code class="language-plaintext highlighter-rouge">ignore_methods :eval_generated</code> drop the phantom branch and method entries that macros like <code class="language-plaintext highlighter-rouge">delegate</code> inject.</p>

<h3 id="parallel-runners">Parallel runners</h3>

<p>SimpleCov’s coordination with parallel runners now goes through a pluggable <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters</code> chain instead of hard-coding the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem’s API. Two adapters ship, one wrapping the <code class="language-plaintext highlighter-rouge">parallel_tests</code> gem and a generic one for any runner following the <code class="language-plaintext highlighter-rouge">TEST_ENV_NUMBER</code> and <code class="language-plaintext highlighter-rouge">PARALLEL_TEST_GROUPS</code> convention. Custom runners can register their own adapter by subclassing <code class="language-plaintext highlighter-rouge">SimpleCov::ParallelAdapters::Base</code>. There is also a new <code class="language-plaintext highlighter-rouge">SimpleCov.parallel_wait_timeout</code> (default 60 seconds) for the case where one worker runs much heavier files and routinely finishes well after the others, so raise it if you want that worker’s coverage in the merge rather than having the threshold checks run against a partial total.</p>

<h2 id="conclusion">Conclusion</h2>

<p>There are other features we decided not to mention for brevity’s sake. They can all be found in the changelog, at any rate. Given what has changed, most migrations to SimpleCov 1.0 should be straightforward and we recommend to go through with it because the new features add many quality of life improvements for gathering good coverage reports especially in CI, which is essential for dealing with tech debt and guaranteeing your app is functioning as expected.</p>

<p>Here at FastRuby.io we use SimpleCov heavily and it’s one of the staple gems we always end up recommending to clients if they aren’t using anything.</p>

<p>If you want to also know what we can do for you and your team to make you move faster and not get dragged down by endless bugs, performance issues and legacy problems no one understands anymore, <a href="https://www.fastruby.io/#contactus">send us a message and let’s talk!</a>.</p>

<h2 id="resources-and-further-reading">Resources and Further Reading</h2>

<ul>
  <li><a href="https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md#100-2026-07-12">SimpleCov 1.0.0 changelog</a></li>
  <li><a href="https://github.com/simplecov-ruby/simplecov#readme">SimpleCov README</a></li>
  <li><a href="https://docs.ruby-lang.org/en/master/Coverage.html">Ruby’s Coverage library</a></li>
</ul>]]></content><author><name>mateuspereira</name></author><category term="upgrades" /><summary type="html"><![CDATA[SimpleCov released its first stable 1.0 version. Here is a quick guide to the breaking changes and deprecations, how to tell if they affect you, and how to fix each one.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/simplecov-is-now-version-1.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How to Distribute Private Gems</title><link href="https://www.fastruby.io/blog/how-to-distribute-private-gems.html" rel="alternate" type="text/html" title="How to Distribute Private Gems" /><published>2026-08-18T11:54:56-04:00</published><updated>2026-08-18T11:54:56-04:00</updated><id>https://www.fastruby.io/blog/how-to-distribute-private-gems</id><content type="html" xml:base="https://www.fastruby.io/blog/how-to-distribute-private-gems.html"><![CDATA[<p>When I first started thinking about private gem distribution, I approached the problem the way I always do, backward from <code class="language-plaintext highlighter-rouge">bundle install</code>. What does a developer need to make that command succeed for a private library? Three things: a source (URL or repo), credentials that work in CI and locally, and a reliable way to receive updates without breaking deployments.</p>

<p>Not all code belongs on <a href="https://www.rubygems.org">RubyGems.org</a>, and that’s okay. Sometimes the goal is internal reuse- sharing an SDK or auth client across services. Sometimes it’s a business model: you ship a private gem behind a license. Either way, the constraints are the same: authentication, delivery, and trust.</p>

<p>In this blog post, we’ll give an overview of the options for distributing private gems.</p>

<!--more-->

<h2 id="how-private-gems-are-born">How Private Gems Are Born</h2>

<p>Organizations use private gems for practical reasons. Gems that are built for internal usage can be seen as a convenient way to share libraries such as SDKs, service clients, and shared models across many services without coupling through a monorepo. Organizations can also have advanced functionality as closed source gems and sell access to it.</p>

<p>These use cases share common operational challenges: you must authenticate and authorize installs, deliver updates and prevent unauthorized redistribution. Addressing those concerns influences the distribution method you pick and the infrastructure you operate.</p>

<h2 id="how-rubygems-and-bundler-handle-sources">How RubyGems and Bundler Handle Sources</h2>

<p>By default <code class="language-plaintext highlighter-rouge">gem push</code> targets RubyGems.org and Bundler resolves dependencies from the sources declared in your <code class="language-plaintext highlighter-rouge">Gemfile</code>. For example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://rubygems.org"</span>

<span class="n">gem</span> <span class="s2">"rails"</span><span class="p">,</span> <span class="s2">"~&gt; 7.1"</span>
</code></pre></div></div>

<p>However, both behaviors are configurable. <code class="language-plaintext highlighter-rouge">gem push</code> accepts a <code class="language-plaintext highlighter-rouge">--host</code> flag so you can upload to another gem server, and the RubyGems client will also read credentials from <code class="language-plaintext highlighter-rouge">~/.gem/credentials</code> or the <code class="language-plaintext highlighter-rouge">GEM_HOST_API_KEY</code> environment variable. Meanwhile, Bundler reads sources from the <code class="language-plaintext highlighter-rouge">Gemfile</code>, but you can store credentials with <code class="language-plaintext highlighter-rouge">bundle config</code> (which writes to <code class="language-plaintext highlighter-rouge">.bundle/config</code>), configure mirrors, or point specific hosts to private registries.</p>

<p>Behind the scenes <code class="language-plaintext highlighter-rouge">bundle install</code> downloads metadata from the configured registry, verifies checksums, and installs gems locally. Because RubyGems.org lacks private access controls and payment hooks, organizations that aim to distribute their private gems usually use a private registry or use private Git repos as an alternative.</p>

<h2 id="private-gem-distribution">Private Gem Distribution</h2>
<h3 id="git-based-distribution">Git-Based Distribution</h3>

<p>It’s the simplest option to distribute a private gem. Publish the gem source to a private repository and reference it in your Gemfile. This leverages existing Git authentication (SSH keys or OAuth) and avoids running a registry. For small teams or internal tooling this is often the path.</p>

<p>Referencing a Git repo moves work to the client. Bundler cannot use a pre-built index, so installs can be slower and metadata caching is limited. Because Bundler doesn’t have an index for Git sources, automating version access or licensing controls (for example, restricting which versions a particular customer can install) becomes harder to manage at scale.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem "my_private_gem", git: "git@github.com:org/my_private_gem.git"
# or with specific branch/tag
gem "my_private_gem", git: "https://github.com/org/my_private_gem.git", branch: "main"
# or with specific ref
gem "my_private_gem", git: "git@github.com:org/my_private_gem.git", ref: "v1.2.3"
</code></pre></div></div>

<h3 id="private-gem-servers">Private Gem Servers</h3>

<p>Private gem servers, whether managed SaaS or self-hosted, address the shortcomings of Git-based installs by providing an indexable registry with access control, logging, and lifecycle management. Example commercial providers include <a href="https://gemfury.com/">Gemfury</a> and <a href="https://cloudsmith.com/">Cloudsmith</a>; larger organizations often choose <a href="https://jfrog.com/artifactory/">Artifactory</a>.</p>

<p>If you prefer an open-source server, two lightweight and commonly used options are <a href="https://www.gemstash.org/">Gemstash</a> and <a href="https://github.com/geminabox/geminabox">Geminabox</a>.</p>

<h4 id="gemstash-lightweight-rubygems-friendly">Gemstash (lightweight, RubyGems-friendly)</h4>

<p>Gemstash is a small server that can host private gems, proxy and cache RubyGems.org, and provide simple authentication. It’s maintained by contributors in the RubyGems ecosystem and is opinionated for standard Bundler/Rubygems workflows.</p>

<p>Here’s a quick start to run a local server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># install and run a local server</span>
gem <span class="nb">install </span>gemstash
gemstash start   <span class="c"># serves at http://localhost:9292 by default</span>

<span class="c"># build and push a gem</span>
gem build mygem
gem push <span class="nt">--host</span> http://localhost:9292 pkg/mygem-0.1.0.gem

<span class="c"># point your Gemfile at the server</span>
<span class="nb">source</span> <span class="s1">'http://localhost:9292'</span>
</code></pre></div></div>

<p>It’s also possible to set up authentication and tokens in this Gemstash local server. Check <a href="https://github.com/rubygems/gemstash">their documentation</a> for more information.</p>

<h4 id="geminabox-simple-small-footprint">Geminabox (simple, small footprint)</h4>

<p>Geminabox is Sinatra app that serves gems from a directory. It’s extremely lightweight and easy to deploy (single process), making it a good choice for small teams or quick internal registries.</p>

<p>Here’s a quick start to run a local server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># install and run the server</span>
gem <span class="nb">install </span>geminabox
geminabox   <span class="c"># runs a server (defaults to http://localhost:9292)</span>

<span class="c"># build and push a gem (uses 'gem inabox' which is provided by the geminabox toolchain)</span>
gem build mygem
gem inabox pkg/mygem-0.1.0.gem <span class="nt">--host</span> http://localhost:9292

<span class="c"># point your Gemfile at the server</span>
<span class="nb">source</span> <span class="s1">'http://localhost:9292'</span>
</code></pre></div></div>

<p>For production use, Geminabox or Gemstash can be set up behind an authenticated reverse proxy strategy (Nginx with basic auth or an SSO gateway) or use TLS + token-based access. Both servers are widely used, well-documented, and simple to operate.</p>

<p>Bundler can be pointed at a private source using an authenticated URL, for example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://USERNAME:TOKEN@gem.fury.io/myorg/"</span>
</code></pre></div></div>

<p>Tokens or API keys are typically injected through environment variables or stored in Bundler’s local config (<code class="language-plaintext highlighter-rouge">.bundle/config</code>). Running a registry gives you practical operational controls: you can revoke access, audit downloads, and integrate the registry with CI/CD for automated publishing.</p>

<h3 id="private-gem-registries">Private Gem Registries</h3>

<p>When productizing a gem, distribution and authentication become core product components. Projects like Sidekiq provide license keys that act as credentials against a private registry; others (for example, providers of long-term support) gate patches and releases behind subscription checks. The implementation is typically the same: a license service issues short-lived or revocable tokens, a registry enforces access, and CI/CD pipelines publish releases and reconcile entitlement after successful payments.</p>

<p>Fundamentally, a commercial private gem system typically rests on three pillars:</p>

<ul>
  <li>Entitlement Management: A service (like a license server) that knows who is allowed to access what.</li>
  <li>Enforcement Point: A private registry that checks these entitlements before serving gems.</li>
  <li>Automation Bridge: Pipelines that automatically grant or revoke access based on events like payment or subscription changes.</li>
</ul>

<p>It also should account for other security controls like: rotating tokens, scoped credentials, and signing artifacts to reduce risks.</p>

<h2 id="security-and-devops-considerations">Security and DevOps Considerations</h2>

<p>Shipping a private gem rests on a practical security responsibility: controlling who can fetch your packages.</p>

<p>Implement least-privilege tokens (separate tokens for publishing vs. install), integrate with SSO where possible, and have a token rotation and revocation process for offboarding or compromised keys.</p>

<h2 id="real-world-examples">Real-World Examples</h2>
<h3 id="sidekiq-pro"><a href="https://sidekiq.org/products/pro/">Sidekiq Pro</a></h3>

<ul>
  <li>Uses token-based authentication for Bundler installs.</li>
  <li>Paid customers receive personal tokens tied to their license.</li>
  <li>Gems are hosted on a private server with strict version control.</li>
</ul>

<h3 id="rails-lts"><a href="https://railslts.com/en">Rails LTS</a></h3>

<ul>
  <li>Customers manually configure credentials after purchasing.</li>
  <li>Patches are distributed as gem updates to specific Rails versions.</li>
  <li>Focuses on compliance and security rather than automation.</li>
</ul>

<h3 id="internal-gems-enterprise-use">Internal Gems (Enterprise Use)</h3>

<ul>
  <li>Common in microservice organizations.</li>
  <li>Teams use self-hosted Gemstash for internal SDKs.</li>
  <li>Easy integration with GitHub Actions or CircleCI.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Private gems effectively bridge the gap between open-source collaboration and commercial or internal sustainability. By choosing the right distribution method, from a simple Git repo to a fully-featured private registry, and adhering to sound security principles, you can share your gem safely. Whether you’re building a shared internal SDK or selling a pro-tier library, the goal remains the same: secure your pipeline, manage trust, and respect your users’ access.</p>

<p>Need help setting up secure private gem distribution for your team? <a href="https://www.fastruby.io/#contactus">We can help.</a></p>]]></content><author><name>hmdros</name></author><category term="rails" /><summary type="html"><![CDATA[This post is an overview of the best methods for distributing private Ruby gems, from simple Git repos to secure, token-based registries for commercial products.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/how_to_distribute_private_gems.png" /><media:content medium="image" url="https://www.fastruby.io/blog/how_to_distribute_private_gems.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why To Use A Multi-Stage Dockerfile</title><link href="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.html" rel="alternate" type="text/html" title="Why To Use A Multi-Stage Dockerfile" /><published>2026-08-13T12:54:59-04:00</published><updated>2026-08-13T12:54:59-04:00</updated><id>https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile</id><content type="html" xml:base="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.html"><![CDATA[<p>Docker has made it easy to use the same environment everywhere, from development to production. But the most basic Dockerfile, where all your dependencies are lumped together in one image, has hidden costs. In this article, we’ll learn the advantages of multi-stage Dockerfiles both from a security and a performance standpoint, primarily for production images.</p>

<!--more-->

<h2 id="what-is-a-multi-stage-dockerfile">What is a Multi-Stage Dockerfile?</h2>

<p>You might have a Dockerfile in your application that looks something like this:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> ruby:3.2</span>

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="c"># Install system dependencies needed to compile native gems</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\
</span>    gcc <span class="se">\
</span>    make <span class="se">\
</span>    libpq-dev

<span class="c"># Install gems</span>
<span class="k">COPY</span><span class="s"> Gemfile Gemfile.lock ./</span>
<span class="k">RUN </span>bundle <span class="nb">install</span>

<span class="c"># Copy application code</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="k">EXPOSE</span><span class="s"> 3000</span>
<span class="k">CMD</span><span class="s"> ["ruby", "app.rb"]</span>
</code></pre></div></div>

<p>This is a typical single-stage setup. There’s one base image, for example, <code class="language-plaintext highlighter-rouge">ruby:3.2</code>, and everything your application needs gets installed inside it: the full Bundler toolchain, build utilities like <code class="language-plaintext highlighter-rouge">gcc</code> and <code class="language-plaintext highlighter-rouge">make</code> for compiling native gem extensions, and dev dependencies like RSpec or Pry, all living side by side. The appeal is obvious. It’s straightforward to write, easy to reason about, and gets the job done.</p>

<p>But that simplicity comes with a trade-off. For example, every tool you used to build your application is still sitting inside the image you ship to production even though you no longer need it there.</p>

<p>In contrast, a multi-stage Dockerfile introduces the idea of stages, discrete phases of your build process, each defined by its own <code class="language-plaintext highlighter-rouge">FROM</code> statement. Here’s what that looks like in practice:</p>
<ul>
  <li>Multiple <code class="language-plaintext highlighter-rouge">FROM</code> statements: each one starts a new stage with its own base image and its own filesystem. You might have a builder stage that installs compilers and build tools, and a separate production stage that only contains what your application needs to run.</li>
  <li>Selective artifact copying stages can pull specific files from a previous stage using <code class="language-plaintext highlighter-rouge">COPY --from=&lt;stage&gt;</code>. This means your final image gets only the output of the build such as compiled code and static assets, and not the tools that produced it.</li>
</ul>

<p>Here’s an example of what a multi-stage Dockerfile looks like in practice. This multi-stage Dockerfile is from the most recent version of Rails:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># syntax=docker/dockerfile:1</span>
<span class="c"># check=error=true</span>

<span class="c"># This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:</span>
<span class="c"># docker build -t rails_demo .</span>
<span class="c"># docker run -d -p 80:80 -e RAILS_MASTER_KEY=&lt;value from config/master.key&gt; --name rails_demo rails_demo</span>

<span class="c"># For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html</span>

<span class="c"># Make sure RUBY_VERSION matches the Ruby version in .ruby-version</span>
<span class="k">ARG</span><span class="s"> RUBY_VERSION=4.0.4</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">docker.io/library/ruby:$RUBY_VERSION-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span>

<span class="c"># Rails app lives here</span>
<span class="k">WORKDIR</span><span class="s"> /rails</span>

<span class="c"># Install base packages</span>
<span class="k">RUN </span>apt-get update <span class="nt">-qq</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    apt-get <span class="nb">install</span> <span class="nt">--no-install-recommends</span> <span class="nt">-y</span> curl libjemalloc2 libvips sqlite3 <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">ln</span> <span class="nt">-s</span> /usr/lib/<span class="si">$(</span><span class="nb">uname</span> <span class="nt">-m</span><span class="si">)</span><span class="nt">-linux-gnu</span>/libjemalloc.so.2 /usr/local/lib/libjemalloc.so <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists /var/cache/apt/archives

<span class="c"># Set production environment variables and enable jemalloc for reduced memory usage and latency.</span>
<span class="k">ENV</span><span class="s"> RAILS_ENV="production" \</span>
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development" \
    LD_PRELOAD="/usr/local/lib/libjemalloc.so"

# Throw-away build stage to reduce size of final image
<span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span>

<span class="c"># Install packages needed to build gems</span>
<span class="k">RUN </span>apt-get update <span class="nt">-qq</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    apt-get <span class="nb">install</span> <span class="nt">--no-install-recommends</span> <span class="nt">-y</span> build-essential git libvips libyaml-dev pkg-config <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists /var/cache/apt/archives

<span class="c"># Install application gems</span>
<span class="k">COPY</span><span class="s"> vendor/* ./vendor/</span>
<span class="k">COPY</span><span class="s"> Gemfile Gemfile.lock ./</span>

<span class="k">RUN </span>bundle <span class="nb">install</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="nb">rm</span> <span class="nt">-rf</span> ~/.bundle/ <span class="s2">"</span><span class="k">${</span><span class="nv">BUNDLE_PATH</span><span class="k">}</span><span class="s2">"</span>/ruby/<span class="k">*</span>/cache <span class="s2">"</span><span class="k">${</span><span class="nv">BUNDLE_PATH</span><span class="k">}</span><span class="s2">"</span>/ruby/<span class="k">*</span>/bundler/gems/<span class="k">*</span>/.git <span class="o">&amp;&amp;</span> <span class="se">\
</span>    <span class="c"># -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495</span>
    bundle exec bootsnap precompile -j 1 --gemfile

<span class="c"># Copy application code</span>
<span class="k">COPY</span><span class="s"> . .</span>

<span class="c"># Precompile bootsnap code for faster boot times.</span>
<span class="c"># -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495</span>
<span class="k">RUN </span>bundle <span class="nb">exec </span>bootsnap precompile <span class="nt">-j</span> 1 app/ lib/

<span class="c"># Precompiling assets for production without requiring secret RAILS_MASTER_KEY</span>
<span class="k">RUN </span><span class="nv">SECRET_KEY_BASE_DUMMY</span><span class="o">=</span>1 ./bin/rails assets:precompile

<span class="c"># Final stage for app image</span>
<span class="k">FROM</span><span class="s"> base</span>

<span class="c"># Run and own only the runtime files as a non-root user for security</span>
<span class="k">RUN </span>groupadd <span class="nt">--system</span> <span class="nt">--gid</span> 1000 rails <span class="o">&amp;&amp;</span> <span class="se">\
</span>    useradd rails <span class="nt">--uid</span> 1000 <span class="nt">--gid</span> 1000 <span class="nt">--create-home</span> <span class="nt">--shell</span> /bin/bash
<span class="k">USER</span><span class="s"> 1000:1000</span>

<span class="c"># Copy built artifacts: gems, application</span>
<span class="k">COPY</span><span class="s"> --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"</span>
<span class="k">COPY</span><span class="s"> --chown=rails:rails --from=build /rails /rails</span>

<span class="c"># Entrypoint prepares the database.</span>
<span class="k">ENTRYPOINT</span><span class="s"> ["/rails/bin/docker-entrypoint"]</span>

<span class="c"># Start server via Thruster by default, this can be overwritten at runtime</span>
<span class="k">EXPOSE</span><span class="s"> 80</span>
<span class="k">CMD</span><span class="s"> ["./bin/thrust", "./bin/rails", "server"]</span>

</code></pre></div></div>

<p>Here’s what’s happening across the three stages:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">base</code>: this stage sets up the common foundation (slim Ruby image, shared env vars) that both other stages build on.</li>
  <li><code class="language-plaintext highlighter-rouge">build</code>: the throw-away stage. Installs <code class="language-plaintext highlighter-rouge">build-essential</code>, <code class="language-plaintext highlighter-rouge">git</code>, compiles gems, precompiles assets. None of these tools ship to production.</li>
  <li>the unnamed third <code class="language-plaintext highlighter-rouge">FROM base</code>: this copies only the built artifacts from <code class="language-plaintext highlighter-rouge">build</code> and runs as a non-root user.</li>
</ul>

<h2 id="why-use-a-multi-stage-dockerfile">Why Use a Multi-Stage Dockerfile?</h2>

<p>Now that we know what the differences are between a single-stage and multi-stage Dockerfile, let’s talk about why you might choose a multi-stage Dockerfile.</p>

<p>Two main advantages of multi-stage builds are performance and security:</p>

<h3 id="performance">Performance</h3>

<p>Multi-stage builds usually have a smaller final image size since the build tools and dev dependencies don’t make it into the final image. Multi-stage builds also play nicely with Docker’s layer caching. Because each stage is isolated, Docker can cache them independently. If your dependencies haven’t changed, the builder stage can be skipped entirely on the next run. You only rebuild the stages that actually changed, which adds up to significant time savings over hundreds of pipeline runs. Smaller images and cached stages mean faster pulls, faster deployments, and less time waiting around in your CI/CD pipeline.</p>

<h3 id="security">Security</h3>

<p>A multi-stage build is more secure than a single-stage build because the production image contains only what’s needed to run the application, nothing more.</p>

<p>Every package installed in your image is a potential vulnerability. Build tools like <code class="language-plaintext highlighter-rouge">gcc</code>, <code class="language-plaintext highlighter-rouge">make</code>, and <code class="language-plaintext highlighter-rouge">curl</code> are common in single-stage images and they’re also common exploit vectors. With a multi-stage build, those tools never make it into your production image. Fewer packages means fewer CVEs to worry about, and a much smaller surface for attackers to target.</p>

<p>You can reduce this even further by pairing your final stage with a slim base image. For example, <a href="https://hub.docker.com/layers/library/ruby/4.0.0-slim/images/sha256-581e1c0a771beb4260290349ce63b8de3e50c041ff3d509543a0b7f1ac11921f"><code class="language-plaintext highlighter-rouge">ruby:4.0-slim</code></a> strips out packages that aren’t needed for most Ruby apps, and <a href="https://hub.docker.com/layers/library/ruby/4.0-alpine/images/sha256:c20865a22bd9feec512ee4deb2ec526f3827c512a295b4b271f325eacc4ec501"><code class="language-plaintext highlighter-rouge">ruby:4.0-alpine</code></a> goes even further, using Alpine Linux as its base, a minimal OS that keeps only the bare essentials by default.</p>

<h2 id="tips-and-common-pitfalls">Tips and Common Pitfalls</h2>

<ul>
  <li>Skip multi-stage for development and test environments. The security and build-performance wins mostly matter for what you ship to production. In dev you’re rebuilding constantly and want to run commands like <code class="language-plaintext highlighter-rouge">bundle install</code>, <code class="language-plaintext highlighter-rouge">rails console</code>, or add a gem on the fly. A multi-stage setup adds friction for little payoff there, since your dev tools need to stay in the image anyway. Keep a single-stage Dockerfile (or a dedicated dev stage in the same file) for local development and testing, and reserve the multi-stage build for your production image.</li>
  <li>Name your stages. Using <code class="language-plaintext highlighter-rouge">AS builder</code> and <code class="language-plaintext highlighter-rouge">AS production</code> in your <code class="language-plaintext highlighter-rouge">FROM</code> statements makes your Dockerfile much easier to read and maintain, especially as the number of stages grows.</li>
  <li>Know what to copy. The most common stumbling block is figuring out exactly which files need to move between stages. For a Ruby app, that’s typically your installed gems directory and your application code. Take the time to map this out before you write the <code class="language-plaintext highlighter-rouge">COPY --from</code> lines.</li>
  <li>Run as a non-root user in your final stage, like the Rails example above does. Even if an attacker gets into the container, they won’t have root access to work with.</li>
  <li>Build cache ordering matters: Put the steps least likely to change at the top of each stage and most likely to change at the bottom. For example, copy your Gemfile and run <code class="language-plaintext highlighter-rouge">bundle install</code> before copying your application code. That way, if you change a Ruby file, Docker doesn’t re-run <code class="language-plaintext highlighter-rouge">bundle install</code> unnecessarily.</li>
  <li>Match your base images when going slim or Alpine. If your final stage switches to a musl-based image like <code class="language-plaintext highlighter-rouge">ruby:4.0-alpine</code>, but your builder stage compiled native gem extensions on a glibc-based image like <code class="language-plaintext highlighter-rouge">ruby:4.0</code> or <code class="language-plaintext highlighter-rouge">ruby:4.0-slim</code>, those extensions may fail at runtime. Either build and run on matching bases, or compile your gems inside an Alpine builder stage.</li>
  <li>Forgetting build dependencies at runtime. A very common mistake is copying your compiled gems into the final stage but forgetting to install the runtime system libraries they depend on. For example, the <code class="language-plaintext highlighter-rouge">pg</code> gem needs <code class="language-plaintext highlighter-rouge">libpq5</code> at runtime even though it only needs <code class="language-plaintext highlighter-rouge">libpq-dev</code> to compile. Your app will build fine but crash at runtime. This is probably the most common real-world gotcha with multi-stage builds.</li>
  <li>Secret leakage between stages. People sometimes assume that because secrets (API keys, credentials) used in the build stage don’t get copied to the final stage, they’re safe. But they can still be exposed in the image’s layer history. Use Docker BuildKit secrets instead:
    <div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span><span class="nt">--mount</span><span class="o">=</span><span class="nb">type</span><span class="o">=</span>secret,id<span class="o">=</span>my_secret ...
</code></pre></div>    </div>
  </li>
  <li>If you want to go even further than slim images, look into distroless images. These images contain absolutely nothing except your app and its runtime- no shell, no package manager, nothing. They represent the ultimate reduction in attack surface. Just be aware that Ruby support for distroless is still limited compared to languages like Go or Java, so do your research before committing to it for a Ruby project.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Multi-stage Dockerfiles require slightly more work upfront. But in return, you get images that are dramatically smaller, meaningfully more secure, and faster to build. That’s a trade-off worth making for any production workload.</p>

<p>Want to convert your single-stage Dockerfiles to multi-stage? <a href="https://www.fastruby.io/#contactus">We can help!</a></p>]]></content><author><name>gelseyt</name></author><category term="devops" /><summary type="html"><![CDATA[Your Dockerfile might be shipping gcc, make, and RSpec into production without realizing it. See why multi-stage Dockerfiles improve performance and security for Ruby apps.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/why-to-use-a-multi-stage-dockerfile.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Measuring Code Coverage For Non-Ruby Runners</title><link href="https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners.html" rel="alternate" type="text/html" title="Measuring Code Coverage For Non-Ruby Runners" /><published>2026-08-10T15:51:50-04:00</published><updated>2026-08-10T15:51:50-04:00</updated><id>https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners</id><content type="html" xml:base="https://www.fastruby.io/blog/ruby-coverage-for-non-ruby-runners.html"><![CDATA[<p>When we think about Ruby code coverage, our go-to gem for this is <a href="https://github.com/simplecov-ruby/simplecov">SimpleCov</a>, which works great when the test suite uses <a href="https://rubygems.org/gems/minitest">Minitest</a>, <a href="https://rubygems.org/gems/rspec">RSpec</a>, <a href="https://rubygems.org/gems/cucumber">Cucumber</a>, <a href="https://rubygems.org/gems/capybara">Capybara</a>, and all these tools that are integrated with Ruby and Rails. But many applications also use other tools like <a href="https://playwright.dev">Playwright</a> or <a href="https://www.cypress.io">Cypress</a> to run e2e tests, and we can’t use SimpleCov the same way.</p>

<p>Most of the time, what we have seen is that the Ruby code executed when running these tools ends up left behind and not being counted for the total code coverage, even though we know the code is actually being tested.</p>

<!--more-->

<h2 id="sample-application">Sample Application</h2>

<p>To make it easier to try this, we created <a href="https://github.com/fastruby/coverage-with-cypress-sample-app">a sample application</a> that uses the <a href="https://rubygems.org/gems/cypress-on-rails"><code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem</a> along with Minitest, and includes the instructions and custom Rake tasks to get the final code coverage when running both test suites.</p>

<p>Note that this could be done with any other tool like Playwright or <a href="https://github.com/angular/protractor">Protractor</a> (deprecated) since the actual integration is independent of the tool as long as it runs the Rails server.</p>

<h2 id="approach-summary">Approach Summary</h2>

<p>The main problem that we are facing when using these external tools is that the Rails server is controlled by this runner and not started by a Ruby runner, so we can’t easily control the lifecycle of the server to measure and extract the coverage information.</p>

<p>We can see how the <code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem already overcomes some of these limitations with custom helpers to execute fixtures, factories, and db cleanup in <a href="https://github.com/shakacode/cypress-playwright-on-rails/blob/3ba1ba4fa9d8e5e4864c4022dea7084dcb51aa05/lib/generators/cypress_on_rails/templates/spec/cypress/e2e/rails_examples/using_fixtures.cy.js#L7">their generated sample tests</a>.</p>

<p>We have a few moving pieces here, so this is a quick summary of the process:</p>

<ul>
  <li>when we run <code class="language-plaintext highlighter-rouge">rails test</code> we get the code coverage of the Minitest test suite</li>
  <li>then we’ll run Coverband’s standalone server on the side, so it collects stats from the Rails servers started by Cypress</li>
  <li>once the e2e tests are done (running <code class="language-plaintext highlighter-rouge">rails cypress:run</code>), we can use a custom Rake task to extract the code coverage from Coverband into a JSON file</li>
  <li>finally, we can merge the code coverage of the 2 tools into a single final report</li>
</ul>

<h2 id="the-details">The Details</h2>

<h3 id="minitest-code-coverage">Minitest Code Coverage</h3>

<p>This step is the standard SimpleCov setup: we only need to add the SimpleCov gem in <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/Gemfile#L66">the <code class="language-plaintext highlighter-rouge">:test</code> group</a>, and enable SimpleCov at <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/test_helper.rb#L4">the beginning of our <code class="language-plaintext highlighter-rouge">test_helper.rb</code> file</a>.</p>

<p>After this we can do a quick test, run <code class="language-plaintext highlighter-rouge">rails test</code> and see the generated report:</p>

<p><img src="/blog/assets/images/cypress-coverage/minitest-list.png" alt="Minitest total coverage" /></p>

<p>We can see the total coverage is around 35%, and we can see the details of the <code class="language-plaintext highlighter-rouge">UsersController</code> coverage that we’ll use for this example too:</p>

<p><img src="/blog/assets/images/cypress-coverage/minitest-users-controller.png" alt="Minitest UsersController coverage" /></p>

<p>Note the total coverage of this file is around 39%, with many lines not covered (lines 15, 24, and 26). This is expected, as we are <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/controllers/users_controller_test.rb#L9">only testing the index action</a> in our Minitest suite.</p>

<p>But we know we have more tests for this file; the Cypress test suite is testing an <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/e2e/cypress/e2e/user_creation.cy.js#L6">invalid form submission of the new user form</a>.</p>

<h3 id="coverband-standalone-server">Coverband Standalone Server</h3>

<p>As mentioned above, we can’t simply add SimpleCov to the Cypress tests, so we are going to use <a href="https://rubygems.org/gems/coverband">Coverband</a> (which uses SimpleCov internally) to capture the code that is being executed by the tests, similar to what we would do if running Coverband in production.</p>

<p>Coverband provides a standalone server that we can use so we can control the lifecycle of the code coverage tracking independently of the lifecycle of the Cypress runner.</p>

<p>First we have to add the <code class="language-plaintext highlighter-rouge">coverband</code> gem (note that we are adding it only in the <code class="language-plaintext highlighter-rouge">test</code> group, we are not using it to track production coverage!). Then, we can execute the server with <code class="language-plaintext highlighter-rouge">RAILS_ENV=test rails coverband:coverage_server</code> and leave it running on the side.</p>

<blockquote>
  <p>Note that, now that Coverband is added, we get an error message at the end of the Minitest run. Ideally, we could use a different Rails environment for the e2e tests so the gem wouldn’t be loaded, but the <code class="language-plaintext highlighter-rouge">cypress-on-rails</code> gem hardcodes the <code class="language-plaintext highlighter-rouge">test</code> Rails environment.</p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E, [2026-01-02T16:29:34.221521 #322239] ERROR -- : coverage failed to store
E, [2026-01-02T16:29:34.221579 #322239] ERROR -- : Coverband Error: #&lt;RuntimeError: coverage measurement is not enabled&gt; coverage measurement is not enabled
</code></pre></div></div>

<h3 id="running-cypress-tests">Running Cypress Tests</h3>

<p>Now it’s time to run the e2e tests. In this example we run <code class="language-plaintext highlighter-rouge">rails cypress:run</code>, but it would be the same for any other tool, as long as we set the same RAILS_ENV variable as the Coverband server (so the gem is required).</p>

<p>When the tests finish, we won’t see any information about the code coverage, but we can open the Coverband server in the browser to check how the percentage is being tracked in <code class="language-plaintext highlighter-rouge">http://localhost:9022</code>.</p>

<p>It’s important to note that Coverband will calculate all the coverage of any code that gets executed (even for Ruby files we don’t really care about when looking at the final code coverage value) and we get some information like coverage of config files or test files that will affect the coverage percentage. With SimpleCov, we use <a href="https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/test/test_helper.rb#L4">the <code class="language-plaintext highlighter-rouge">'rails'</code> profile</a> to ignore those extra files, but we don’t need to worry about it at this point for Coverband. We’ll clean that up at the end.</p>

<h3 id="extracting-the-code-coverage-from-coverband">Extracting the Code Coverage from Coverband</h3>

<p>Coverband provides a <code class="language-plaintext highlighter-rouge">coverband:coverage_html</code> task that uses SimpleCov to process the internal coverage data. There’s also a <code class="language-plaintext highlighter-rouge">coverband:coverage_json</code> but these tasks don’t generate the raw data we need to merge it with the Minitest resultset. To solve this, we created a custom Rake task that will extract the raw data and format the JSON file to have a structure that we can then merge.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/lib/tasks/json_coverage.rake#L2</span>

<span class="n">desc</span> <span class="s2">"JSON formatted report of Coverband code coverage"</span>
<span class="n">task</span> <span class="ss">:simplecov_json_report</span> <span class="k">do</span>
  <span class="nb">require</span> <span class="s2">"coverband"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/result"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/file_list"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/source_file"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/lines_classifier"</span>
  <span class="nb">require</span> <span class="s2">"coverband/utils/results"</span>

  <span class="nb">require</span> <span class="s2">"simplecov"</span>
  <span class="nb">require</span> <span class="s2">"simplecov_json_formatter"</span>

  <span class="sb">`mkdir -p </span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="sb">`</span>

  <span class="c1"># SimpleCov hardcodes this constant as `coverage.json`, we are renaming this here to make it more</span>
  <span class="c1"># clear, but this step is not necessary</span>
  <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="ss">:remove_const</span><span class="p">,</span> <span class="s2">"FILENAME"</span><span class="p">)</span>
  <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="o">::</span><span class="no">FILENAME</span> <span class="o">=</span> <span class="s2">"cypress_coverage.json"</span>

  <span class="n">coverband_reports</span> <span class="o">=</span> <span class="no">Coverband</span><span class="o">::</span><span class="no">Reporters</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">report</span><span class="p">(</span><span class="no">Coverband</span><span class="p">.</span><span class="nf">configuration</span><span class="p">.</span><span class="nf">store</span><span class="p">)</span>
  <span class="no">Coverband</span><span class="o">::</span><span class="no">Reporters</span><span class="o">::</span><span class="no">Base</span><span class="p">.</span><span class="nf">fix_reports</span><span class="p">(</span><span class="n">coverband_reports</span><span class="p">)</span>
  <span class="n">result</span> <span class="o">=</span> <span class="no">Coverband</span><span class="o">::</span><span class="no">Utils</span><span class="o">::</span><span class="no">Results</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">coverband_reports</span><span class="p">)</span>
  <span class="no">SimpleCov</span><span class="o">::</span><span class="no">Formatter</span><span class="o">::</span><span class="no">JSONFormatter</span><span class="p">.</span><span class="nf">new</span><span class="p">.</span><span class="nf">format</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>

  <span class="c1"># fix json structure so it can be merged with coverage:merge</span>
  <span class="c1"># we want a `"Cypress"` key at the root of the JSON object, with the `"coverage"` inside it</span>
  <span class="n">generated_json_file</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="p">,</span> <span class="no">SimpleCovJSONFormatter</span><span class="o">::</span><span class="no">ResultExporter</span><span class="o">::</span><span class="no">FILENAME</span><span class="p">)</span>
  <span class="n">content</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">"Cypress"</span> <span class="o">=&gt;</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">read</span><span class="p">(</span><span class="n">generated_json_file</span><span class="p">))</span> <span class="p">}</span>
  <span class="no">File</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="n">generated_json_file</span><span class="p">,</span> <span class="n">content</span><span class="p">.</span><span class="nf">to_json</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now we can run <code class="language-plaintext highlighter-rouge">rails simplecov_json_report</code> to generate the <code class="language-plaintext highlighter-rouge">coverage/cypress_coverage.json</code> file.</p>

<blockquote>
  <p>Note that this step will show a really high code coverage percentage, over 75%, but this is not correct since it includes config, test, and other files for this calculation!</p>
</blockquote>

<h3 id="merging-results">Merging Results</h3>

<p>Now we get to the final step: we have the <code class="language-plaintext highlighter-rouge">coverage/.resultset.json</code> and <code class="language-plaintext highlighter-rouge">coverage/cypress_coverage.json</code> files that we need to merge into a single file.</p>

<p>For this, we have created another custom Rake task that uses SimpleCov’s <code class="language-plaintext highlighter-rouge">collate</code> method:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># https://github.com/fastruby/coverage-with-cypress-sample-app/blob/main/lib/tasks/json_coverage.rake#L34</span>

<span class="n">namespace</span> <span class="ss">:coverage</span> <span class="k">do</span>
  <span class="n">desc</span> <span class="s2">"Merge Minitest's and Cypress' code coverage json files into one"</span>
  <span class="n">task</span> <span class="ss">:merge</span> <span class="k">do</span>
    <span class="nb">require</span> <span class="s2">"simplecov"</span>

    <span class="c1"># change this if you use different json result names</span>
    <span class="n">coverage_files</span> <span class="o">=</span> <span class="no">Dir</span><span class="p">[</span><span class="s2">"</span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="s2">/.resultset.json"</span><span class="p">]</span> <span class="o">+</span> <span class="no">Dir</span><span class="p">[</span><span class="s2">"</span><span class="si">#{</span><span class="no">SimpleCov</span><span class="p">.</span><span class="nf">coverage_path</span><span class="si">}</span><span class="s2">/cypress_coverage.json"</span><span class="p">]</span>

    <span class="c1"># make sure to use the `rails` profile to not add noise with files we won't test</span>
    <span class="no">SimpleCov</span><span class="p">.</span><span class="nf">collate</span> <span class="n">coverage_files</span><span class="p">,</span> <span class="s2">"rails"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>And we can run this task with <code class="language-plaintext highlighter-rouge">rails coverage:merge</code> and see the new generated code coverage report:</p>

<p><img src="/blog/assets/images/cypress-coverage/merged-list.png" alt="Merged total percentage" /></p>

<p>Here we can see the total percentage went up to almost 53%.</p>

<p><img src="/blog/assets/images/cypress-coverage/merged-users-controller.png" alt="Merged UsersController coverage" /></p>

<p>And here we can see the <code class="language-plaintext highlighter-rouge">UsersController</code> coverage is over 60% and we can see the lines inside the <code class="language-plaintext highlighter-rouge">new</code> and <code class="language-plaintext highlighter-rouge">create</code> actions are now green as we expected from the Cypress test.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This is a basic example of the approach; in a real production application it may require some tweaks depending on the tool being used, along with the proper setup for CI and proper automation of the extraction and merging of the coverage data.</p>

<p>A similar problem happens when we want to measure the JavaScript/TypeScript code coverage and we are using a Ruby runner like Capybara along with runners like Jest or Cypress. You can read about capturing all the code coverage and merge results together in this article: <a href="/blog/rails/javascript/code-coverage/js-code-coverage-in-rails.html">JavaScript Test Code Coverage in Rails</a>.</p>

<p>Do you need help with your tests? <a href="/#contactus">Let’s talk!</a></p>]]></content><author><name>arieljuod</name></author><category term="rails" /><summary type="html"><![CDATA[Learn how to measure and merge Ruby code coverage when using non-Ruby e2e test runners like Cypress or Playwright, combining SimpleCov and Coverband into one report.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/ruby-code-coverage-for-non-ruby-runners.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/ruby-code-coverage-for-non-ruby-runners.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Opening a Repo Is Now an Execution Event</title><link href="https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event.html" rel="alternate" type="text/html" title="Opening a Repo Is Now an Execution Event" /><published>2026-08-06T10:32:40-04:00</published><updated>2026-08-06T10:32:40-04:00</updated><id>https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event</id><content type="html" xml:base="https://www.fastruby.io/blog/opening-a-repo-is-now-an-execution-event.html"><![CDATA[<p>If you work on Rails for a living, you clone repositories you didn’t write all week long: a gem you’re debugging, a client’s application you’re about to audit, a bug reproduction attached to an issue. For years, opening one of those in your editor was the safe part. You were reading someone else’s code, not running it, and the only real rule was to not run anything until you had looked.</p>

<p>That rule quietly stopped being enough. AI coding editors like Claude Code and Cursor read project-local configuration the moment you open a repository, and some of that configuration is executable. A repo you cloned five seconds ago can hand your editor a command to run before you have read a line of it, and this is not hypothetical: the npm worm that hit <code class="language-plaintext highlighter-rouge">keyv</code> this week (<a href="https://socket.dev/blog/popular-npm-packages-in-the-keyv-and-cacheable-namespaces-compromised-in-active-supply-chain">Socket</a>, <a href="https://www.aikido.dev/blog/keyv-and-friends-compromised-in-npm-supply-chain-attack">Aikido</a>, and <a href="https://www.microsoft.com/en-us/security/blog/2026/08/04/chaindrop-supply-chain-compromise-anatomy-self-propagating-worm/">Microsoft</a> all covered it) weaponized exactly this, writing itself into <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> so it runs again for anyone who opens the project.</p>

<p>In this post, we’ll look at why opening a repository in an AI editor is now an execution event, how the keyv worm turned that into a live attack, and what to check in a cloned repo’s <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> directories before you point your editor at it.</p>

<!--more-->

<h2 id="why-opening-a-repo-now-executes-code">Why opening a repo now executes code</h2>

<p>For years the mental model was simple: untrusted code runs when you tell it to. You install a package and its install scripts run, you run the app and its code runs, but opening a repository to read it was passive. Looking wasn’t doing.</p>

<p>AI editors break that model by design. Claude Code reads <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> on every session start, and one of the things that file can configure is a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook, a shell command that runs when the session begins, with no separate step asking you to approve it first. A <code class="language-plaintext highlighter-rouge">.claude/</code> directory you didn’t create can hand your session a command just as easily as one you built yourself.</p>

<p>Cursor and VS Code have the same shape with more friction: a task in <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> can be set to run on folder open, though only in a workspace you have trusted and only once you have opted into automatic tasks. Either way, the config is meant to be read and acted on the moment the project opens, which is exactly what makes it useful to someone who has slipped it into a repo you are about to clone.</p>

<p>A configuration-injection bug in <a href="https://marimo.io">marimo</a>, a Python notebook tool (<a href="https://github.com/advisories/GHSA-v9v8-jp27-55gf">CVE-2026-67618</a>), surfaced the same week and leaked API keys the moment you opened a crafted notebook, a different mechanism with the same shape. Two unrelated tools hitting the same problem in the same week is a sign this is a shift in how tools treat project-local config, not an npm or a Rails quirk. The common thread is that the more a tool reads and acts on configuration shipped inside a project, the more of its behavior a repo you didn’t write controls the moment you open it.</p>

<h2 id="already-weaponized-the-keyv-worm">Already weaponized: the keyv worm</h2>

<p>The keyv worm shows this is not a theoretical risk. The npm package <code class="language-plaintext highlighter-rouge">keyv</code>, along with a few hundred others, was compromised on August 4th, 2026, in a self-propagating worm variously called ChainDrop or “Shai-Hulud: Here We Go Again”. The credential-stealing half of it is ordinary: an install script harvests tokens and keys from the filesystem and ships them out to the attacker. The half that matters here is how it sticks around. As Socket noted, listing it among techniques “not documented in earlier Shai-Hulud reporting,” the payload writes a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook into <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> and a <code class="language-plaintext highlighter-rouge">folderOpen</code> task into <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code>.</p>

<p>Those files are never touched by <code class="language-plaintext highlighter-rouge">npm install</code>. The next time you or a teammate opens the project, they are read by your editor, which means the infection outlives the dependency. You can pull the compromised package out of your lockfile and the hook waiting in <code class="language-plaintext highlighter-rouge">.claude/</code> is still there, ready to run the next time someone opens the repo in Claude Code. That is the difference between “we shipped a bad dependency” and “everyone who opens this repo runs the payload,” and it is why cleaning up after this worm is not just a lockfile edit.</p>

<h2 id="why-your-usual-instincts-miss-it">Why your usual instincts miss it</h2>

<p>None of the usual reflexes catch this. “Don’t run code you haven’t read” and “only install signed, attested packages” are both good habits, and both miss the point here, because the dangerous part isn’t something you run or install at all. The hook rides in the repository’s own files. Provenance, a signed record tying a package back to the exact source commit and CI build that produced it, tells you which pipeline published a dependency; it says nothing about a <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> committed straight into a repo you cloned. Ruby has its own take on verifying the packages you install, the gem signing and <code class="language-plaintext highlighter-rouge">--trust-policy</code> we looked at in <a href="https://www.fastruby.io/blog/rubygems-security">The Forgotten Flag: How –trust-policy Works</a>, and it shares the same blind spot: signing covers the artifacts you install, not the config you open.</p>

<h2 id="why-this-lands-on-rails-teams">Why this lands on Rails teams</h2>

<p>This started as an npm story, but the exposure isn’t really about your dependencies. If your Rails app has a Node asset pipeline it is worth keeping an eye on your lockfile, and keeping <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> honest matters too (we have written before about <a href="https://www.fastruby.io/blog/hidden-dangers-in-your-gemfile">supply-chain risk in your Gemfile</a> and about how <a href="https://www.fastruby.io/blog/why-patching-the-gem-didnt-fix-cve-2026-66066">patching a gem isn’t always the whole fix</a>). But a gem or npm dependency audit looks at what you install. The <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> vector is about what you open, and opening repositories you didn’t write is something Rails teams do constantly.</p>

<p>Consultancies live this at the extreme: at FastRuby.io we open other people’s Rails codebases for a living, cloning client applications to audit and upgrade them, more often than not with an AI editor already running. Every one of those repositories is, by definition, one we did not write, and any of them could ship a <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> that runs the moment the session starts. A dependency audit answers “did I install something bad?” but it has nothing to say about the question that actually applies here: whose repo did I just open in Claude Code?</p>

<h2 id="what-to-check-before-you-open-a-repo">What to check before you open a repo</h2>

<p>So before you open a freshly cloned repository in Claude Code, Cursor, or VS Code, especially one from outside your team, spend a minute on the config it ships with (the kind of check worth eventually baking into a <a href="https://www.fastruby.io/blog/tech-debt-audit-with-claude-code">Claude Code skill</a> so you are not relying on memory):</p>

<ul>
  <li>Open <code class="language-plaintext highlighter-rouge">.claude/settings.json</code> and look for a <code class="language-plaintext highlighter-rouge">hooks</code> key, particularly a <code class="language-plaintext highlighter-rouge">SessionStart</code> hook. A legitimate hook is one your own team added on purpose; anything you don’t recognize is worth reading in full, because it runs on session start whether or not you asked.</li>
  <li>Open <code class="language-plaintext highlighter-rouge">.vscode/tasks.json</code> and look for a task set to run on folder open, and read exactly what its command does.</li>
  <li>If the repo has a JavaScript pipeline, check whether your lockfile resolves <code class="language-plaintext highlighter-rouge">keyv</code>, <code class="language-plaintext highlighter-rouge">flat-cache</code>, <code class="language-plaintext highlighter-rouge">file-entry-cache</code>, or <code class="language-plaintext highlighter-rouge">cacheable</code> to one of the poisoned releases (<code class="language-plaintext highlighter-rouge">keyv@6.0.0</code>, <code class="language-plaintext highlighter-rouge">flat-cache@6.1.24</code>, <code class="language-plaintext highlighter-rouge">file-entry-cache@11.1.6</code>, <code class="language-plaintext highlighter-rouge">cacheable@2.5.1</code>) rather than to the older, clean lines. <code class="language-plaintext highlighter-rouge">yarn why keyv</code> or <code class="language-plaintext highlighter-rouge">npm ls keyv</code> shows what actually resolved.</li>
</ul>

<p>If any of that turns up something you didn’t put there, deleting the file isn’t the end of it. Assume the command already ran, and rotate every credential the machine or CI run could have reached since you opened the repo, from a separate, uncompromised machine. A hook you remove after the fact doesn’t recall whatever it already sent out.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Opening a repository in your editor used to be the safe step, the thing you did precisely because you weren’t ready to run anything yet. AI editors changed that: Claude Code and Cursor read project-local config on open and can act on it, so cloning a repo you didn’t write and pointing an AI editor at it is an execution event in its own right. The keyv worm is the first widely seen case of someone weaponizing that, and it will not be the last.</p>

<p>For Rails teams, and especially for anyone who clones client code for a living, the habit worth building is a small one: before you open an unfamiliar repo in an AI editor, look at its <code class="language-plaintext highlighter-rouge">.claude/</code> and <code class="language-plaintext highlighter-rouge">.vscode/</code> directories the same way you would look at a shell script someone emailed you. A few minutes there is a lot cheaper than rotating every credential on the box afterward.</p>

<p>Wondering what is hiding in the <code class="language-plaintext highlighter-rouge">.claude/</code> or <code class="language-plaintext highlighter-rouge">.vscode/</code> config of the repos your team has been cloning, or want a second set of eyes on a codebase before you trust it? <a href="https://www.fastruby.io/#contactus">Send us a message!</a></p>]]></content><author><name>gelseyt</name></author><category term="security" /><summary type="html"><![CDATA[Opening a gem or a client's Rails app in Claude Code or Cursor now runs its config: what to check in .claude/ and .vscode/ before you trust a cloned repo.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/opening-repo-is-now-execution-event.jpg" /><media:content medium="image" url="https://www.fastruby.io/blog/opening-repo-is-now-execution-event.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why Your Yarn App Suddenly Looks for Bun</title><link href="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.html" rel="alternate" type="text/html" title="Why Your Yarn App Suddenly Looks for Bun" /><published>2026-08-06T09:38:52-04:00</published><updated>2026-08-06T09:38:52-04:00</updated><id>https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun</id><content type="html" xml:base="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.html"><![CDATA[<p>You run bundle update, kick off a build, and asset precompilation stops on this:”</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cssbundling-rails: Command install failed, ensure bun is installed
Tasks: TOP =&gt; assets:precompile =&gt; css:build =&gt; css:install
</code></pre></div></div>

<p>Except your application uses Yarn. It has always used Yarn. Nothing in the project references Bun, and nobody on the team added it.</p>

<p>This is not an exotic edge case. It can happen to ordinary Yarn applications, and it lingers because the fix has been merged upstream but never released. In this post, we’ll walk through why <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> misidentifies your package manager, and how to unblock your build.</p>

<!--more-->

<h2 id="the-short-version">The Short Version</h2>

<p><code class="language-plaintext highlighter-rouge">cssbundling-rails</code> 1.4.3 <a href="https://github.com/rails/cssbundling-rails/pull/165">added <code class="language-plaintext highlighter-rouge">yarn.lock</code> to the list of lockfiles</a> that mark a project as a Bun project. While preparing to install JavaScript dependencies, <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> determines that Bun should be used when the Bun binary exists in the PATH. The issue explored in this post occurs when two things are true at once: the project contains a <code class="language-plaintext highlighter-rouge">yarn.lock</code> file and the build environment happens to have Bun installed.</p>

<p>When both are true, the gem runs <code class="language-plaintext highlighter-rouge">bun install</code> against a project that has never used Bun. If that command fails, the rake task reports <code class="language-plaintext highlighter-rouge">ensure bun is installed</code> even though Bun is installed, because the message is assembled from the name of the command it just tried to run.</p>

<p>The fix is <a href="https://github.com/rails/cssbundling-rails/pull/172">PR #172</a>, commit <code class="language-plaintext highlighter-rouge">466cd57</code>, merged to <code class="language-plaintext highlighter-rouge">main</code> in July 2025. As of August 2026 it is still not in a released version, and 1.4.3 remains the latest gem. So the workaround is to pin to the commit rather than to a version:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"cssbundling-rails"</span><span class="p">,</span> <span class="ss">github: </span><span class="s2">"rails/cssbundling-rails"</span><span class="p">,</span> <span class="ss">ref: </span><span class="s2">"466cd57"</span>
</code></pre></div></div>

<p>You are likely to hit this if all of the following are true:</p>

<ul>
  <li>You use <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> with Yarn, and <code class="language-plaintext highlighter-rouge">yarn.lock</code> is committed.</li>
  <li>Your resolved version is 1.4.3. It is currently the latest release, so <code class="language-plaintext highlighter-rouge">bundle update</code> lands you there by default.</li>
  <li>Bun is reachable on <code class="language-plaintext highlighter-rouge">PATH</code> wherever you run <code class="language-plaintext highlighter-rouge">assets:precompile</code>, whether that is a Docker build, CI, or a PaaS build step. This is easier to hit than it sounds: Render’s images ship Bun by default, plenty of custom base images install it alongside Node, and so does any developer who has ever tried Bun locally.</li>
</ul>

<p>Note that your project <strong>does not need</strong> to use Bun in any way, and you <strong>do not need</strong> both a <code class="language-plaintext highlighter-rouge">yarn.lock</code> and a <code class="language-plaintext highlighter-rouge">bun.lock</code>. An ordinary Yarn application is enough, as long as the Bun binary happens to be sitting in the environment that runs the build.</p>

<h2 id="how-we-ran-into-it">How We Ran Into It</h2>

<p>We hit this while moving a Rails application onto a newer Ruby base image. It had used Yarn for years. Bun had never entered the picture.</p>

<p>Everything built cleanly until <code class="language-plaintext highlighter-rouge">assets:precompile</code>, which stopped on <code class="language-plaintext highlighter-rouge">ensure bun is installed</code>. Our first assumption was that some dependency had quietly started pulling Bun in, so we went looking through <code class="language-plaintext highlighter-rouge">package.json</code> and the lockfile. Neither pointed to Bun. The gem had not failed to run a command; it had decided on its own to run the wrong one.</p>

<p>The answer was in a shared base image. A little above the Ruby setup, it installed Bun, so it would be available to other projects using it, and put it on the path:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span>curl <span class="nt">-fsSL</span> https://bun.com/install | bash
<span class="k">ENV</span><span class="s"> PATH="/root/.bun/bin:${PATH}"</span>
</code></pre></div></div>

<p>Our project did not need Bun, but every build now ran with Bun in PATH. Those two lines (above) are what turned a quirk in a gem’s detection logic into a failed build.</p>

<p>That reframed the investigation. The question was not “why is Bun broken”, it was “why is a Yarn application attempting to use Bun?”</p>

<h2 id="whats-actually-happening">What’s Actually Happening</h2>

<p>The task graph in the error message is the first clue:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>assets:precompile =&gt; css:build =&gt; css:install
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cssbundling-rails</code> hooks into <a href="https://www.fastruby.io/blog/speed-up-assets-precompile"><code class="language-plaintext highlighter-rouge">assets:precompile</code></a> and runs <code class="language-plaintext highlighter-rouge">css:build</code>, which depends on <code class="language-plaintext highlighter-rouge">css:install</code>, which shells out to a package manager to install your JavaScript dependencies. The gem picks that package manager automatically, based on which lockfiles it finds in the project.</p>

<p>For the precompile path, that decision lives in <code class="language-plaintext highlighter-rouge">Cssbundling::Tasks</code>, in <a href="https://github.com/rails/cssbundling-rails/blob/v1.4.3/lib/tasks/cssbundling/build.rake"><code class="language-plaintext highlighter-rouge">lib/tasks/cssbundling/build.rake</code></a>. The relevant parts of version 1.4.3 looks like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">LOCK_FILES</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">bun: </span><span class="sx">%w[bun.lockb bun.lock yarn.lock]</span><span class="p">,</span>
  <span class="ss">yarn: </span><span class="sx">%w[yarn.lock]</span><span class="p">,</span>
  <span class="ss">pnpm: </span><span class="sx">%w[pnpm-lock.yaml]</span><span class="p">,</span>
  <span class="ss">npm: </span><span class="sx">%w[package-lock.json]</span>
<span class="p">}</span>

<span class="k">def</span> <span class="nf">install_command</span>
  <span class="k">case</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:bun</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"bun install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:yarn</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"yarn install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:pnpm</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"pnpm install"</span>
  <span class="k">when</span> <span class="n">using_tool?</span><span class="p">(</span><span class="ss">:npm</span><span class="p">)</span> <span class="k">then</span> <span class="s2">"npm install"</span>
  <span class="k">else</span> <span class="k">raise</span> <span class="s2">"cssbundling-rails: No suitable tool found for installing JavaScript dependencies"</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">tool_exists?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span>
  <span class="nb">system</span> <span class="s2">"command -v </span><span class="si">#{</span><span class="n">tool</span><span class="si">}</span><span class="s2"> &gt; /dev/null"</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">using_tool?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span>
  <span class="n">tool_exists?</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="no">LOCK_FILES</span><span class="p">[</span><span class="n">tool</span><span class="p">].</span><span class="nf">any?</span> <span class="p">{</span> <span class="o">|</span><span class="n">file</span><span class="o">|</span> <span class="no">File</span><span class="p">.</span><span class="nf">exist?</span><span class="p">(</span><span class="n">file</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Two details: <code class="language-plaintext highlighter-rouge">yarn.lock</code> sits in the Bun list, so every Yarn project matches the Bun branch. And <code class="language-plaintext highlighter-rouge">using_tool?</code> joins both checks with <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>, so the Bun match only wins when <code class="language-plaintext highlighter-rouge">tool_exists?</code> actually finds the <code class="language-plaintext highlighter-rouge">bun</code> binary. With Bun absent, the check falls through to Yarn and everything works, which is why this bug stays invisible on a plain <code class="language-plaintext highlighter-rouge">ruby:*</code> image. With Bun present, <code class="language-plaintext highlighter-rouge">install_command</code> returns <code class="language-plaintext highlighter-rouge">bun install</code> for a project that has never used Bun.</p>

<p>From there the misclassification lands in one of two ways. If <code class="language-plaintext highlighter-rouge">bun install</code> succeeds, it quietly writes a <code class="language-plaintext highlighter-rouge">bun.lock</code> into the repository alongside your <code class="language-plaintext highlighter-rouge">yarn.lock</code>, which is what most of the upstream reports describe. If it fails, the <code class="language-plaintext highlighter-rouge">css:install</code> task raises:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">task</span> <span class="ss">:install</span> <span class="k">do</span>
  <span class="n">command</span> <span class="o">=</span> <span class="no">Cssbundling</span><span class="o">::</span><span class="no">Tasks</span><span class="p">.</span><span class="nf">install_command</span>
  <span class="k">unless</span> <span class="nb">system</span><span class="p">(</span><span class="n">command</span><span class="p">)</span>
    <span class="k">raise</span> <span class="s2">"cssbundling-rails: Command install failed, ensure </span><span class="si">#{</span><span class="n">command</span><span class="p">.</span><span class="nf">split</span><span class="p">.</span><span class="nf">first</span><span class="si">}</span><span class="s2"> is installed"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That is the message we saw, and it is built from <code class="language-plaintext highlighter-rouge">command.split.first</code>. It points you at Bun as the problem, when Bun was never part of the application to begin with.</p>

<h2 id="where-the-regression-came-from">Where the Regression Came From</h2>

<p>The git history explains how a change meant to help Bun users introduced a bug that impacted Yarn users.</p>

<p><a href="https://github.com/rails/cssbundling-rails/pull/165">PR #165</a>, “Improve package manager detection for Bun 1.2 compatibility”, was merged in March 2025 and shipped in 1.4.3. Bun 1.2 made a new text-based <code class="language-plaintext highlighter-rouge">bun.lock</code> the default lockfile in place of the older binary <code class="language-plaintext highlighter-rouge">bun.lockb</code>, and <code class="language-plaintext highlighter-rouge">bun install --yarn</code> produces a <code class="language-plaintext highlighter-rouge">yarn.lock</code>. To cover those cases, the PR broadened Bun detection to accept <code class="language-plaintext highlighter-rouge">bun.lockb</code>, <code class="language-plaintext highlighter-rouge">bun.lock</code>, or <code class="language-plaintext highlighter-rouge">yarn.lock</code>.</p>

<p>That last option is the problem. Every Yarn project has a <code class="language-plaintext highlighter-rouge">yarn.lock</code>, so detection meant for a narrow Bun workflow started capturing ordinary Yarn projects too, on any machine where Bun was installed.</p>

<p><a href="https://github.com/rails/cssbundling-rails/pull/172">PR #172</a>, “Remove yarn from bun detect tool”, commit <code class="language-plaintext highlighter-rouge">466cd57</code>, was merged in July 2025 to correct it. It stops treating <code class="language-plaintext highlighter-rouge">yarn.lock</code> as a Bun signal, so a Yarn project is detected as Yarn again.</p>

<p>It is worth knowing that this was not a simple typo. <a href="https://github.com/rails/cssbundling-rails/issues/183">Issue #183</a> is still open and asks for the opposite behavior: Bun preferred for freshly generated applications. The same few lines are being pulled in two directions, and there is no clear consensus upstream on how it should behave.</p>

<h2 id="merged-is-not-yet-released">Merged Is Not Yet Released</h2>

<p>Unfortunately the usual “just bump the version” instinct does not work in this case. The latest released gem is 1.4.3, tagged March 3, 2025. The fix, <code class="language-plaintext highlighter-rouge">466cd57</code>, was merged to <code class="language-plaintext highlighter-rouge">main</code> on July 10, 2025, but not yet released. The only way to get the fix today is to point Bundler at the commit itself:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># cssbundling-rails 1.4.3 misdetects bun for yarn projects (regression from</span>
<span class="c1"># PR #165). The fix is PR #172 / 466cd57, merged to main but unreleased as of</span>
<span class="c1"># August 2026. Unpin only once a release LATER than 1.4.3 ships with that commit.</span>
<span class="n">gem</span> <span class="s2">"cssbundling-rails"</span><span class="p">,</span> <span class="ss">github: </span><span class="s2">"rails/cssbundling-rails"</span><span class="p">,</span> <span class="ss">ref: </span><span class="s2">"466cd57"</span>
</code></pre></div></div>

<p>As a side note, when you pin a gem to a commit, it is a good idea to add a comment that describes the condition that makes it safe to remove. In this case, once a release later than 1.4.3 ships with commit 466cd57. A comment like ‘unpin after v1.4.3’ is easy to misread and does not explain why the dependency was pinned in the first place.</p>

<p>Now we can <code class="language-plaintext highlighter-rouge">bundle install</code>, and confirm that the <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> records a <code class="language-plaintext highlighter-rouge">GIT</code> source pointing at the commit:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GIT
  remote: https://github.com/rails/cssbundling-rails.git
  revision: 466cd57...
  specs:
    cssbundling-rails (1.4.3)
</code></pre></div></div>

<p>Rebuild, and precompilation detects Yarn again. That one-line pin unblocked the applications that still needed a CSS build.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The alarming <code class="language-plaintext highlighter-rouge">ensure bun is installed</code> message is not about Bun at all, it is an ordinary Yarn application tripping over a detection change in <code class="language-plaintext highlighter-rouge">cssbundling-rails</code> 1.4.3, in an environment that happened to have Bun lying around. The fix exists upstream, it is simply stuck there, so a commit pin is the pragmatic move until a new version ships. A shared base image is a dependency too, so check it when a build breaks over something your code never asked for.</p>

<p>Snags like this one slowing down your Rails upgrade? <a href="/#contactus">We can help</a>.</p>]]></content><author><name>hmdros</name></author><category term="upgrades" /><summary type="html"><![CDATA[cssbundling-rails 1.4.3 misdetects Yarn projects as Bun when Bun is on PATH, breaking asset precompilation. Here is why it happens and how to unblock it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.png" /><media:content medium="image" url="https://www.fastruby.io/blog/why-your-yarn-app-suddenly-looks-for-bun.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>