<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seppe Gadeyne</title>
    <description>The latest articles on DEV Community by Seppe Gadeyne (@seppegadeyne).</description>
    <link>https://dev.to/seppegadeyne</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F412443%2Fb770a129-9311-40ef-92f1-7cff76ed7c72.jpeg</url>
      <title>DEV Community: Seppe Gadeyne</title>
      <link>https://dev.to/seppegadeyne</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seppegadeyne"/>
    <language>en</language>
    <item>
      <title>Sitemap lastmod from git history in Astro, and the Vercel shallow clone trap</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Wed, 11 Nov 2026 13:46:58 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/sitemap-lastmod-from-git-history-in-astro-and-the-vercel-shallow-clone-trap-430i</link>
      <guid>https://dev.to/seppegadeyne/sitemap-lastmod-from-git-history-in-astro-and-the-vercel-shallow-clone-trap-430i</guid>
      <description>&lt;p&gt;Google's sitemap docs say it uses &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; "if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate." Most static sites fail that test in one of two ways: they leave &lt;code&gt;lastmod&lt;/code&gt; out, or they stamp every URL with the build time. A build-time date changes on every deploy, so it says nothing about the page.&lt;/p&gt;

&lt;p&gt;On straffesites.com, a static Astro site with Content Collections (MDX and JSON in Git, deployed on Vercel), every sitemap entry now carries the date of the last commit that touched the content of that page. This article shows the code, the one Vercel setting that makes it work, and the trap I hit on the first deploy: a shallow clone that silently gives almost every file the same date.&lt;/p&gt;

&lt;p&gt;Versions used: Astro 7.2.8, &lt;code&gt;@astrojs/sitemap&lt;/code&gt; 3.7.3, Node 26, git 2.43.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one &lt;code&gt;git log&lt;/code&gt; per build
&lt;/h2&gt;

&lt;p&gt;Git already knows when each file last changed. You don't need a date registry in the repo, and file mtimes are useless in CI because a fresh checkout gives every file the same mtime.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;git log&lt;/code&gt; call over the content directory gives you all dates at once. It lists commits newest first, and the first time a path shows up is its most recent change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/git-lastmod.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;execFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'node:child_process'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gitDates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'src'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;git&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'git'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rev-parse'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--is-shallow-repository'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sitemap lastmod needs full git history (shallow clone detected)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// One git call: each commit date (newest first), followed by the files it touched.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;git&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'log'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--format=%x00%cI%x00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--name-only'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\n'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'\0'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'src/pages/blog/hello.md' -&amp;gt; '2026-03-02T09:00:00.000Z'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;%x00&lt;/code&gt; bytes around the date make commit headers easy to tell apart from file paths. &lt;code&gt;%cI&lt;/code&gt; is the committer date in strict ISO 8601, and &lt;code&gt;toISOString()&lt;/code&gt; normalizes it to UTC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into &lt;code&gt;@astrojs/sitemap&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The sitemap integration has a &lt;code&gt;serialize(item)&lt;/code&gt; callback that runs for every entry before the XML is written. Compute the map once in an &lt;code&gt;astro:build:start&lt;/code&gt; hook, then look up each route in &lt;code&gt;serialize&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'astro/config'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sitemap&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@astrojs/sitemap'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;gitDates&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'./scripts/git-lastmod.mjs'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Map a route to the file that holds its main content.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sourceFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'src/pages/index.md'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`src/pages${route.replace(/\/$/, '')}.md`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'https://example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'never'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'file'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'git-lastmod'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s1"&gt;'astro:build:start'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;dates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gitDates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;lastmod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sourceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;lastmod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`No committed date for ${route}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastmod&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this in a throwaway repo with three Markdown pages and three commits with fixed dates: all pages on March 1, an edit to &lt;code&gt;hello.md&lt;/code&gt; on March 2, and a README-only commit on March 3. The entries in the generated &lt;code&gt;sitemap-0.xml&lt;/code&gt;, one per line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://example.com&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;lastmod&amp;gt;&lt;/span&gt;2026-03-01T09:00:00.000Z&lt;span class="nt"&gt;&amp;lt;/lastmod&amp;gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://example.com/blog/hello&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;lastmod&amp;gt;&lt;/span&gt;2026-03-02T09:00:00.000Z&lt;span class="nt"&gt;&amp;lt;/lastmod&amp;gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://example.com/blog/second&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&amp;lt;lastmod&amp;gt;&lt;/span&gt;2026-03-01T09:00:00.000Z&lt;span class="nt"&gt;&amp;lt;/lastmod&amp;gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README commit doesn't move any date, because &lt;code&gt;git log -- src&lt;/code&gt; only looks at commits that touched &lt;code&gt;src&lt;/code&gt;. The integration also wrote a &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; of March 2 on the single entry in &lt;code&gt;sitemap-index.xml&lt;/code&gt;, the newest date in that file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sourceFor&lt;/code&gt; is the part you'll have to adapt. On straffesites.com, routes come from a &lt;code&gt;route:&lt;/code&gt; field in each MDX file's frontmatter, so the real script reads the frontmatter instead of deriving the path from the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: Vercel's shallow clone
&lt;/h2&gt;

&lt;p&gt;Vercel's build docs say it clones your repo with &lt;code&gt;git clone --depth=10&lt;/code&gt;. With only ten commits of history, the oldest commit in the clone is a boundary commit. Git has no parent to compare it with, so &lt;code&gt;git log --name-only&lt;/code&gt; lists every file in the tree under that commit.&lt;/p&gt;

&lt;p&gt;In the demo repo, a &lt;code&gt;--depth=1&lt;/code&gt; clone shows all three pages under the README-only commit (NUL bytes shown as &lt;code&gt;|&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;|2026-03-03T09:00:00+00:00|

src/pages/blog/hello.md
src/pages/blog/second.md
src/pages/index.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran the same parsing on a &lt;code&gt;--depth=10&lt;/code&gt; clone of the straffesites.com repo (1,153 commits at the time). 272 of the 279 tracked content files got the date of the boundary commit, November 10, 2026. With full history, the same 279 files have dates spread from August 26 to November 10, 2026. Without a guard, the sitemap would have claimed that nearly the whole site changed on one day. That's exactly the kind of &lt;code&gt;lastmod&lt;/code&gt; that Google learns to ignore.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;gitDates()&lt;/code&gt; checks &lt;code&gt;git rev-parse --is-shallow-repository&lt;/code&gt; and throws. In the shallow demo clone, the build stops in the hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR] [git-lastmod] An unhandled error occurred while running the "astro:build:start" hook
sitemap lastmod needs full git history (shallow clone detected)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed build keeps the previous production deployment online. I'd rather have that than a sitemap full of wrong dates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;git fetch --unshallow&lt;/code&gt; wasn't enough
&lt;/h3&gt;

&lt;p&gt;My first version tried to fix the history itself: if shallow, run &lt;code&gt;git fetch --unshallow --no-tags origin&lt;/code&gt;. That works on my machine and in a fresh local clone. On Vercel it failed, because the build checkout doesn't keep credentials for &lt;code&gt;origin&lt;/code&gt;, and the repository is private. Here's the same failure reproduced locally with credentials and prompts turned off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;$ &lt;span class="nv"&gt;GIT_TERMINAL_PROMPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 git -c credential.helper= fetch --unshallow --no-tags origin
fatal: could not &lt;span class="nb"&gt;read&lt;/span&gt; Username &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="s1"&gt;'https://github.com'&lt;/span&gt;: terminal prompts disabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a public repository the fetch may succeed. On a private one, it fails every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: &lt;code&gt;VERCEL_DEEP_CLONE=true&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Setting the environment variable &lt;code&gt;VERCEL_DEEP_CLONE=true&lt;/code&gt; on the Vercel project makes Vercel clone the full history instead of ten commits. I couldn't find it in Vercel's own documentation. It shows up in a &lt;a href="https://github.com/vercel/vercel/discussions/5737" rel="noopener noreferrer"&gt;vercel/vercel GitHub discussion (#5737)&lt;/a&gt;, a &lt;a href="https://github.com/meta/docusaurus/issues/10031" rel="noopener noreferrer"&gt;Docusaurus issue&lt;/a&gt; about "last updated" dates, and &lt;a href="https://zudoku.dev/docs/deploy/vercel" rel="noopener noreferrer"&gt;Zudoku's Vercel deploy guide&lt;/a&gt;. All of them need full history for the same reason. Because it's undocumented, treat it as something that can change, and keep the shallow check as a hard failure so you notice when it does. In that same Docusaurus issue, two people report that their deep clone failed on Vercel with a large repository, so check your build log after you turn it on.&lt;/p&gt;

&lt;p&gt;On straffesites.com the variable is set for Production and Preview since September 13, 2026. Today (November 11, 2026) I compared the live &lt;code&gt;sitemap-0.xml&lt;/code&gt; with the dates computed from a full local clone: 244 of 244 URLs match exactly.&lt;/p&gt;

&lt;p&gt;The production script still keeps the &lt;code&gt;fetch --unshallow&lt;/code&gt; path as a defensive fallback with a 60-second timeout and &lt;code&gt;GIT_TERMINAL_PROMPT=0&lt;/code&gt;, so it can't hang on a prompt. If that fetch fails, the build fails with a clear message instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which files count as "the content of a page"?
&lt;/h2&gt;

&lt;p&gt;For a blog post, the answer is its own MDX file. For other pages it isn't that simple. A blog index changes when a post is added or removed. A page that renders a data table changes when the JSON behind it changes.&lt;/p&gt;

&lt;p&gt;The production script builds a set of source files per route and takes the newest date. This is the dependency part, lightly trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`src/content/datasets/${name}.json`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="nc"&gt;WorkGrid&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;CaseGrid&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;CommercialPage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`cases${suffix}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BenchmarkTable&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'agency-benchmark'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="nc"&gt;Articles&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;InsightGrid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Include removals from the directory too: hub membership is content.&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`src/content/articles/${locale}/blog/`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`Sitemap lastmod: no committed date for ${source}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice side effect of reading paths from &lt;code&gt;git log&lt;/code&gt;: deleted files are still in the map. So when a post is removed, the blog index gets the date of the deletion commit, which is correct because the list on that page changed.&lt;/p&gt;

&lt;p&gt;What I deliberately left out: navigation, footer, CSS, related-post rails, and docs-only commits. Those change the HTML of every page, but not what the page is about. The date means "the main content changed," not "some byte in the output changed."&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and edge cases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Committer date, not author date.&lt;/strong&gt; &lt;code&gt;%cI&lt;/code&gt; is when the commit landed. A rebase, amend, or cherry-pick rewrites the committer date, so the touched files will look freshly modified. If you rebase published content often, consider &lt;code&gt;%aI&lt;/code&gt; (author date), which survives a rebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge commits.&lt;/strong&gt; Plain &lt;code&gt;git log&lt;/code&gt; shows no file list for merge commits. A file changed on a feature branch gets the date of the branch commit, not the merge. If you need "date it went live," use squash merges or add &lt;code&gt;--first-parent&lt;/code&gt;. In git 2.43 that lists the merge commit with its changes against the first parent, so the file gets the merge date. I checked this in the demo repo; test it on your git version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uncommitted edits get no new date.&lt;/strong&gt; That's on purpose: a dirty working tree is not published content. It also means a local build right after an edit still shows the old date until you commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No git, no build.&lt;/strong&gt; CLI uploads without a &lt;code&gt;.git&lt;/code&gt; directory can't produce these dates. The script fails instead of falling back to the build time or mtimes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; One &lt;code&gt;git log&lt;/code&gt; call over the content tree, regardless of how many routes you have. On the straffesites.com repo (about 1,150 commits) that call takes under 0.2 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Two checks keep this honest.&lt;/p&gt;

&lt;p&gt;First, a &lt;code&gt;node:test&lt;/code&gt; test that builds a small git fixture with fixed &lt;code&gt;GIT_COMMITTER_DATE&lt;/code&gt; values, including an unrelated commit and a dirty file, and then clones it with &lt;code&gt;--depth=1&lt;/code&gt;. Trimmed, with comments added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-01-01T12:00:00Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// page + dataset&lt;/span&gt;
&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'src/content/datasets/cases.json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"updated":true}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-02-01T12:00:00Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// dataset only&lt;/span&gt;
&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'README.md'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unrelated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-03-01T12:00:00Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'src/content/articles/en/blog/example.mdx'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'---\nroute: /en/blog/example\n---\nDirty draft'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildSitemapLastmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-02-01T12:00:00.000Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// follows the dataset&lt;/span&gt;
&lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/en/blog/example'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01T12:00:00.000Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dirty edit ignored&lt;/span&gt;

&lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'git'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'clone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-q'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'--depth=1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`file://${root}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shallow&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;buildSitemapLastmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shallow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="n"&gt;git&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, a post-build check that parses the generated &lt;code&gt;sitemap-0.xml&lt;/code&gt;, recomputes the expected value for every route, and fails if a single &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; is missing or different. That check runs on every &lt;code&gt;npm run check&lt;/code&gt;, so a change to the sitemap config can't quietly bring back build-time dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Derive &lt;code&gt;lastmod&lt;/code&gt; from &lt;code&gt;git log --name-only&lt;/code&gt; once per build and pass it in through &lt;code&gt;serialize()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fail the build on a shallow clone. On Vercel, a depth-10 clone gives most files the date of the boundary commit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git fetch --unshallow&lt;/code&gt; in the build fails on private repos because the checkout has no credentials. &lt;code&gt;VERCEL_DEEP_CLONE=true&lt;/code&gt; fixes it, but it's undocumented, so keep the guard.&lt;/li&gt;
&lt;li&gt;Decide which files define the content of a page (datasets, hub listings) and leave layout changes out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can see the result in the live sitemap of the site: &lt;a href="https://straffesites.com/sitemap-0.xml" rel="noopener noreferrer"&gt;straffesites.com/sitemap-0.xml&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>git</category>
      <category>seo</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Trailing slashes in a static Astro site on Vercel: five layers and a check that never failed</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:56:33 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/trailing-slashes-in-a-static-astro-site-on-vercel-five-layers-and-a-check-that-never-failed-5f0d</link>
      <guid>https://dev.to/seppegadeyne/trailing-slashes-in-a-static-astro-site-on-vercel-five-layers-and-a-check-that-never-failed-5f0d</guid>
      <description>&lt;p&gt;On &lt;a href="https://straffesites.com" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;, every page has exactly one URL, and that URL has no trailing slash: &lt;code&gt;/en/blog/website-without-cms&lt;/code&gt;, never &lt;code&gt;/en/blog/website-without-cms/&lt;/code&gt;. The site is a static Astro 7 build (Astro 7.2.8 at the time of writing) deployed on Vercel. Setting &lt;code&gt;trailingSlash: 'never'&lt;/code&gt; looks like a one-line decision. In practice it touches five places: the Astro config, the host, the dev server, the canonical tag, and a build check.&lt;/p&gt;

&lt;p&gt;This article walks through each layer with the code from that project, the output from real requests, and two things I got wrong. One of them is a build check that passed on every route for two months without checking anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Astro config: what &lt;code&gt;trailingSlash&lt;/code&gt; actually controls
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://straffesites.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;static&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is in the &lt;a href="https://docs.astro.build/en/reference/configuration-reference/#trailingslash" rel="noopener noreferrer"&gt;Astro configuration reference&lt;/a&gt;: &lt;code&gt;trailingSlash&lt;/code&gt; sets route matching in the dev server and for on-demand rendered pages. For prerendered pages, the docs say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Trailing slashes on prerendered pages are handled by the hosting platform, and may not respect your chosen configuration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a fully static site, that means the option does not redirect anything in production. It documents your intent and changes dev-server behavior. The actual redirect has to come from the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Vercel: one setting, a 308, and the query string survives
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"framework"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputDirectory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trailingSlash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;"trailingSlash": false&lt;/code&gt; in &lt;code&gt;vercel.json&lt;/code&gt;, Vercel redirects the slashed form with a permanent 308. Here is what the live site returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/en/blog/website-without-cms/
308 https://straffesites.com/en/blog/website-without-cms

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://straffesites.com/en/blog/website-without-cms/?a=1'
308 https://straffesites.com/en/blog/website-without-cms?a=1

$ curl -s -o /dev/null -w '%{http_code}\n' https://straffesites.com/en/blog/website-without-cms
200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query string is kept, which matters for campaign parameters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfall: the slashed redirect source costs an extra hop anyway
&lt;/h3&gt;

&lt;p&gt;Google Search Console reported old 404 URLs in both forms, with and without the slash, as separate rows. So each removed URL got two entries in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/seppe-gadeyne"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/team"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"permanent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/seppe-gadeyne/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/team"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"permanent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected the second entry to send &lt;code&gt;/seppe-gadeyne/&lt;/code&gt; straight to &lt;code&gt;/team&lt;/code&gt;. It does not, at least not on this deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/seppe-gadeyne/
308 https://straffesites.com/seppe-gadeyne

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://straffesites.com/seppe-gadeyne
308 https://straffesites.com/team
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing-slash normalization answers first, so the slashed request takes two hops and the slashed &lt;code&gt;source&lt;/code&gt; entry is never reached. Both URLs still end on the right page, which is what matters for old links. But if you want to avoid redirect chains, measure them with &lt;code&gt;curl&lt;/code&gt; instead of assuming that an extra &lt;code&gt;source&lt;/code&gt; line removes a hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The dev server: a 404 where production redirects
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;trailingSlash: 'never'&lt;/code&gt;, the Astro dev server does not redirect &lt;code&gt;/about/&lt;/code&gt;. It rejects it. In Astro 7.2.8, &lt;code&gt;node_modules/astro/dist/vite-plugin-astro-server/trailing-slash.js&lt;/code&gt; contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trailingSlash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;never&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;trailingSlash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hasFileExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;npm run dev&lt;/code&gt; showed a 404 page where production returned a 308. That gap makes local link testing misleading. The fix is a small Vite plugin that adds its own middleware and moves it in front of Astro's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripTrailingSlashDev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strip-trailing-slash-dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;configureServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Returning a function makes this a post hook: it runs after&lt;/span&gt;
    &lt;span class="c1"&gt;// Astro has installed its own dev middlewares.&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
          &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/node_modules/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;308&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
          &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;vite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;stripTrailingSlashDev&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order.&lt;/strong&gt; Astro's own dev plugin (&lt;code&gt;vite-plugin-astro-server/plugin.js&lt;/code&gt;) also uses a post hook, and in it calls &lt;code&gt;viteServer.middlewares.stack.unshift(...)&lt;/code&gt; to put its trailing-slash handler at the front of the stack. A plain &lt;code&gt;server.middlewares.use(...)&lt;/code&gt; would therefore run after Astro's 404. Our plugin is listed in &lt;code&gt;vite.plugins&lt;/code&gt;, so its post hook runs after Astro's, and &lt;code&gt;stack.unshift(stack.pop())&lt;/code&gt; moves our handler in front of everything. The trade-off: &lt;code&gt;middlewares.stack&lt;/code&gt; belongs to Connect, the middleware layer Vite uses, not to a documented Astro API. Recheck it after major upgrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclusions.&lt;/strong&gt; Paths starting with &lt;code&gt;/@&lt;/code&gt; (Vite modules), &lt;code&gt;/_&lt;/code&gt; (Astro internals) and &lt;code&gt;/node_modules/&lt;/code&gt; are left alone, so HMR and module requests keep working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dev server now behaves like production, query string included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:4391/en/blog/website-without-cms/
308 http://localhost:4391/en/blog/website-without-cms

$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'http://localhost:4391/en/blog/website-without-cms/?a=1'
308 http://localhost:4391/en/blog/website-without-cms?a=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Canonical tags: &lt;code&gt;Astro.url&lt;/code&gt; has a slash during the build
&lt;/h2&gt;

&lt;p&gt;Even with &lt;code&gt;trailingSlash: 'never'&lt;/code&gt;, a canonical built from &lt;code&gt;Astro.url&lt;/code&gt; can come out with a trailing slash. The default &lt;code&gt;build.format&lt;/code&gt; is &lt;code&gt;'directory'&lt;/code&gt;, which writes &lt;code&gt;dist/en/blog/website-without-cms/index.html&lt;/code&gt;. The &lt;a href="https://docs.astro.build/en/reference/configuration-reference/#buildformat" rel="noopener noreferrer"&gt;&lt;code&gt;build.format&lt;/code&gt; docs&lt;/a&gt; explain the effect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;directory&lt;/code&gt; - The &lt;code&gt;Astro.url.pathname&lt;/code&gt; will include a trailing slash to mimic folder behavior. (e.g. &lt;code&gt;/foo/&lt;/code&gt;)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the base layout normalizes the path itself before it writes &lt;code&gt;canonical&lt;/code&gt; and &lt;code&gt;og:url&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/layouts/BaseLayout.astro
const canonicalPath = Astro.url.pathname.replace(/\/+$/, '') || '/';
const canonicalUrl = new URL(canonicalPath, Astro.site).href;
---
&amp;lt;link rel="canonical" href={canonicalUrl} /&amp;gt;
&amp;lt;meta property="og:url" content={canonicalUrl} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the homepage, the regex leaves an empty string; &lt;code&gt;|| '/'&lt;/code&gt; keeps that path explicit, so the canonical is &lt;code&gt;https://straffesites.com/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The build check that could never fail
&lt;/h2&gt;

&lt;p&gt;The last layer is a check that runs after &lt;code&gt;astro build&lt;/code&gt;. It reads every generated HTML file and fails when an internal &lt;code&gt;href&lt;/code&gt; (or a &lt;code&gt;content&lt;/code&gt; attribute, which covers &lt;code&gt;og:url&lt;/code&gt;) ends in a slash. Here is the version that went in at the end of August (check labels translated from Dutch):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slashedHrefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;href|content&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;="&lt;/span&gt;&lt;span class="se"&gt;\/(?!\/)[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/(?!&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slashedHrefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: no internal hrefs with a trailing slash`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;ok&lt;/code&gt; on every route, every build. While preparing this article, I tested it on a known bad input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node -e 'console.log("&amp;lt;a href=\"/blog/x/\"&amp;gt;".match(/(?:href|content)="\/(?!\/)[^"]*\/(?!.)"/g))'
null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were two bugs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;\/(?!.)"&lt;/code&gt; asks for a slash that is &lt;strong&gt;not followed by any character&lt;/strong&gt;, and then for a &lt;code&gt;"&lt;/code&gt; right after it. A quote is also a character, so the pattern can never match. I meant "slash at the end of the attribute value," and the closing quote already expresses that.&lt;/li&gt;
&lt;li&gt;The helper &lt;code&gt;matches()&lt;/code&gt; returns &lt;code&gt;match[1]&lt;/code&gt;, but the pattern had no capture group. Even a matching pattern would have produced &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix adds a capture group, drops the lookahead, and adds a self-test that proves the pattern can catch the error it exists for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slashedHrefPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;href|content&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;="&lt;/span&gt;&lt;span class="se"&gt;(\/(?!\/)[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/)&lt;/span&gt;&lt;span class="sr"&gt;"/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;a href="/blog/x/"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slashedHrefPattern&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/blog/x/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;a href="/blog/x"&amp;gt;&amp;lt;a href="/"&amp;gt;&amp;lt;a href="//cdn.example/"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slashedHrefPattern&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trailing-slash check catches a slashed href and ignores / and protocol-relative URLs (self-test)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;(?!\/)&lt;/code&gt; after the first slash skips protocol-relative URLs such as &lt;code&gt;//cdn.example/&lt;/code&gt;. The root &lt;code&gt;/&lt;/code&gt; cannot match, because the pattern needs a second slash. The &lt;code&gt;/assets/&lt;/code&gt; filter remains a separate step.&lt;/p&gt;

&lt;p&gt;Before committing the fix, I ran the corrected pattern over the existing build output: 246 HTML files, 0 violations. The convention had held because the other layers worked, not because the check guarded it. The full &lt;code&gt;npm run check&lt;/code&gt; passed after the fix, including the new self-test.&lt;/p&gt;

&lt;p&gt;The general lesson is not about trailing slashes. A check that has never failed may simply be unable to fail. For each regex-based output check, keep one known bad sample next to it and assert that the pattern finds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Setting or code&lt;/th&gt;
&lt;th&gt;What it handles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Astro config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;trailingSlash: 'never'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intent, dev routing, on-demand pages; not static redirects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"trailingSlash": false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;308 from &lt;code&gt;/path/&lt;/code&gt; to &lt;code&gt;/path&lt;/code&gt;, query string kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;source&lt;/code&gt; with and without slash&lt;/td&gt;
&lt;td&gt;Old URLs in both forms; the slashed form still takes two hops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev server&lt;/td&gt;
&lt;td&gt;Vite post-hook middleware&lt;/td&gt;
&lt;td&gt;308 instead of Astro's 404 locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical&lt;/td&gt;
&lt;td&gt;Strip slash from &lt;code&gt;Astro.url.pathname&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;build.format: 'directory'&lt;/code&gt; adds one during the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build check&lt;/td&gt;
&lt;td&gt;Regex plus self-test&lt;/td&gt;
&lt;td&gt;Fails the build on any slashed internal href&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The convention and these checks are part of the build for &lt;a href="https://straffesites.com" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;. For the setup this builds on, see &lt;a href="https://dev.to/seppegadeyne/set-up-astro-with-svelte-tailwind-css-vercel-prettier-and-typescript-7m0"&gt;Set up Astro with Svelte, Tailwind CSS, Vercel, Prettier, and TypeScript&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>vercel</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>Lab vs field data: Lighthouse LCP was up to 16.7x the CrUX p75 on 7 sites</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:37:21 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/lab-vs-field-data-lighthouse-lcp-was-up-to-167x-the-crux-p75-on-7-sites-j0l</link>
      <guid>https://dev.to/seppegadeyne/lab-vs-field-data-lighthouse-lcp-was-up-to-167x-the-crux-p75-on-7-sites-j0l</guid>
      <description>&lt;p&gt;You run PageSpeed Insights and get a 34 on mobile. Panic? Not yet. That prominent Performance score comes from a Lighthouse lab test under controlled conditions. Real visits span different devices, networks, and cache states; some are faster than the lab test, and some are slower.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://straffesites.com" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;, a small web studio in Belgium, we run a recurring Lighthouse benchmark of web development agencies. For the September 2026 edition, we added Chrome UX Report (CrUX) field data to see how the two sources compared. The gap surprised us: on seven sites with usable field data, lab LCP was up to 16.7x the CrUX LCP (p75). This post covers the measurement setup, the raw numbers, and the pitfalls we ran into when reading the PageSpeed Insights and CrUX History API responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between lab data and field data?
&lt;/h2&gt;

&lt;p&gt;Lab data comes from one controlled test with fixed settings, such as the Lighthouse test in PageSpeed Insights. Field data comes from real visits. The &lt;a href="https://developer.chrome.com/docs/crux" rel="noopener noreferrer"&gt;Chrome UX Report (CrUX)&lt;/a&gt;, for example, collects measurements from eligible Chrome users over 28 days. Both are real measurements, but they answer different questions. Lab data helps you find problems and test changes. Field data shows what measured visits experienced. In our study of 19 web agencies (September 2026), the two sometimes ended up far apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two measurements, two different questions
&lt;/h2&gt;

&lt;p&gt;PageSpeed Insights shows available field data above its lab results, but the prominent Lighthouse score often gets the attention. Lighthouse loads the page in a browser with controlled device and network settings. Those settings make comparisons easier, although results can still vary between runs. Lab tests are useful for tracking down technical problems and checking changes before release.&lt;/p&gt;

&lt;p&gt;CrUX is Google's public source of real-user performance data. It represents an eligible subset of Chrome users, not everyone who visits a site. The CrUX API aggregates measurements over 28-day windows and reports distributions and percentiles. For Largest Contentful Paint (LCP), p75 means that 75% of the measured page loads had an LCP at or below that value. It is not a percentage of unique visitors, and it does not mean every load was fast.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The most important thing to understand about field data is that it is not just one number, it's a distribution of numbers."&lt;/p&gt;

&lt;p&gt;— web.dev, &lt;a href="https://web.dev/articles/lab-and-field-data-differences" rel="noopener noreferrer"&gt;Why lab and field data can be different&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distribution is why one number cannot describe the whole experience. Philip Walton makes this point in Google's web.dev guide to lab and field data. Field data is not a single number but a set of numbers: some page loads are fast, others are slow. A p75 value sums up that distribution in one number. Lab data deliberately limits that variation, so the same page can show different values in each source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement setup
&lt;/h2&gt;

&lt;p&gt;For the September 10, 2026 benchmark, we measured 19 agencies from the top 10 results for "website laten maken" (Dutch for "having a website built") in Belgium and the Netherlands. We ran three mobile Lighthouse tests per homepage through the PageSpeed Insights API and took the median for each metric. One day later, we retrieved the available CrUX data through PageSpeed Insights. The full &lt;a href="https://straffesites.com/en/blog/website-builders-benchmark" rel="noopener noreferrer"&gt;method is documented in the benchmark&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One limitation up front: CrUX requires enough eligible traffic and public discoverability. Of the 19 agencies, 7 had usable current field observations. The other 12 are absent from the table below. A missing result is not a speed verdict, and the lookup alone does not tell us why it is missing.&lt;/p&gt;

&lt;p&gt;Scope matters too. A URL-level CrUX record covers one page; an origin-level record combines measurements across that origin's pages. The current observations came from PageSpeed Insights' &lt;code&gt;loadingExperience&lt;/code&gt; response, while the separate History API sweep below queried mobile data at the origin level. Homepage lab tests, current records, and origin history are not interchangeable measurements of the ranking URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap: up to 16.7x between lab and field
&lt;/h2&gt;

&lt;p&gt;The seven sites show large differences between lab LCP and CrUX LCP (p75). The position in parentheses after each name is its organic ranking on September 10, 2026. It refers to the search results page for "website laten maken" in its own country: &lt;code&gt;NL #1&lt;/code&gt; means position 1 in Google Netherlands.&lt;/p&gt;

&lt;p&gt;Lab LCP vs CrUX LCP (p75), September 10–11, 2026, 7 sites with current field data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agency&lt;/th&gt;
&lt;th&gt;Lab LCP&lt;/th&gt;
&lt;th&gt;CrUX LCP (p75)&lt;/th&gt;
&lt;th&gt;Lab/CrUX ratio&lt;/th&gt;
&lt;th&gt;PSI field category&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Conversal (BE #2)&lt;/td&gt;
&lt;td&gt;18.2 s&lt;/td&gt;
&lt;td&gt;1.09 s&lt;/td&gt;
&lt;td&gt;16.7x&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Green Bananas (BE #4)&lt;/td&gt;
&lt;td&gt;18.8 s&lt;/td&gt;
&lt;td&gt;1.63 s&lt;/td&gt;
&lt;td&gt;11.5x&lt;/td&gt;
&lt;td&gt;Average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yourhosting (NL #1)&lt;/td&gt;
&lt;td&gt;21.4 s&lt;/td&gt;
&lt;td&gt;2.08 s&lt;/td&gt;
&lt;td&gt;10.3x&lt;/td&gt;
&lt;td&gt;Average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scrolla (NL #9)&lt;/td&gt;
&lt;td&gt;10.6 s&lt;/td&gt;
&lt;td&gt;1.31 s&lt;/td&gt;
&lt;td&gt;8.1x&lt;/td&gt;
&lt;td&gt;Average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2manydots (NL #7)&lt;/td&gt;
&lt;td&gt;14.5 s&lt;/td&gt;
&lt;td&gt;2.19 s&lt;/td&gt;
&lt;td&gt;6.6x&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stuurlui (NL #8)&lt;/td&gt;
&lt;td&gt;3.2 s&lt;/td&gt;
&lt;td&gt;1.21 s&lt;/td&gt;
&lt;td&gt;2.6x&lt;/td&gt;
&lt;td&gt;Average&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KMO Shops (BE #5)&lt;/td&gt;
&lt;td&gt;4.8 s&lt;/td&gt;
&lt;td&gt;2.51 s&lt;/td&gt;
&lt;td&gt;1.9x&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The median ratio is 8.1x. Conversal's lab LCP is 18.2 seconds, compared with a CrUX LCP (p75) of 1.09 seconds. Yourhosting, number 1 in the Netherlands, records 21.4 seconds in the lab and 2.08 seconds in the current field data. Those percentiles describe measured page loads, not every visitor's experience.&lt;/p&gt;

&lt;p&gt;The final column preserves the categories returned by PageSpeed Insights' &lt;code&gt;loadingExperience.overall_category&lt;/code&gt;. They are not LCP-only ratings or a Core Web Vitals pass/fail result. A "Fast" or "Average" label there should not be read as a classification of the LCP value next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the lab can diverge this much
&lt;/h2&gt;

&lt;p&gt;A lab test cannot reproduce the full range of real visits. Google's &lt;a href="https://web.dev/articles/lab-and-field-data-differences" rel="noopener noreferrer"&gt;explanation of lab and field differences&lt;/a&gt; describes several possible reasons for a gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cache state and repeat visits: lab tests usually start with an empty cache. Some real visits benefit from cached resources or the back/forward cache, while others do not.&lt;/li&gt;
&lt;li&gt;Devices and networks: Lighthouse uses predefined conditions. Real visits can involve faster or slower devices and connections, as well as different locations and viewport sizes.&lt;/li&gt;
&lt;li&gt;User behavior and timing: scrolling or interacting can affect which element becomes the LCP element. A lab test captures one loading scenario; a CrUX percentile summarizes a distribution over a 28-day window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are possible explanations, not causes established for the seven sites. Our measurements do not prove that third-party scripts load serially only in the lab or that they cause no problems for real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  One site's CrUX history tells a more nuanced story
&lt;/h2&gt;

&lt;p&gt;Yourhosting is a useful example. It had the lowest lab Performance score in the Dutch part of the benchmark: 28, with a lab LCP of 21.4 seconds. That does not tell us how all its pages performed during real visits. The CrUX History API provides a separate view of mobile page loads across its origin.&lt;/p&gt;

&lt;p&gt;Across the 25 weekly collection periods retrieved on September 11, Yourhosting's CrUX LCP (p75) ranged from 1.78 to 2.89 seconds. Each period aggregates 28 days of page loads, so the windows overlap. Around 74 percent of measured loads had a "good" LCP in the most recent period. That is a much less severe picture than the lab result, but it is not "always fast": some periods exceeded the 2.5-second good-LCP threshold. LCP alone also cannot establish whether all Core Web Vitals passed or whether page experience affected the ranking.&lt;/p&gt;

&lt;p&gt;The separate History API sweep returned records for 12 origins, but only ten contained any numeric LCP values. Two origins had null values throughout their series. That is different from the seven usable current observations: a returned record is not necessarily a usable measurement. Both APIs use 28-day aggregations; history does not lower the eligibility threshold. Earlier eligible periods and differences in URL-versus-origin scope can affect availability. The history records are not part of the seven-site current-data comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for rankings
&lt;/h2&gt;

&lt;p&gt;A low lab score alone does not establish a ranking problem. Google uses Core Web Vitals as part of its ranking systems, but good scores do not guarantee a top position. Google says it still prioritizes relevant content even when page experience is weaker; good page experience can help when several results offer useful content. That is more cautious than treating speed as a guaranteed tiebreaker.&lt;/p&gt;

&lt;p&gt;In our sample of 19 agencies, higher lab scores tended to go with slightly worse rankings (Spearman rank correlation +0.32 between position number and score). CrUX LCP (p75) had a near-zero correlation with position (−0.07), based on only seven current observations, not the 12 origins in the history sweep. These are indicative associations for one query and a data-center SERP snapshot, not evidence of cause. A follow-up, &lt;a href="https://straffesites.com/en/blog/why-slow-websites-rank-high" rel="noopener noreferrer"&gt;why slow websites rank high in Google&lt;/a&gt;, compares these results with page authority and link data across the 19 agencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do with it
&lt;/h2&gt;

&lt;p&gt;We will keep running lab tests. They provide a consistent testing method without requiring visitor data, including for the 12 agencies without a current field sample. Adding CrUX data gives readers another perspective, with its own coverage and scope limits. Neither source replaces the other.&lt;/p&gt;

&lt;p&gt;For your own site, start with available field data to identify problems, then use lab tests to investigate them and check improvements. If field data is unavailable, lab tests still give you a starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own site
&lt;/h2&gt;

&lt;p&gt;You can check your own site with these tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pagespeed.web.dev/" rel="noopener noreferrer"&gt;PageSpeed Insights&lt;/a&gt; shows available CrUX field data above the lab score and diagnostics. Check the collection dates, device category, and whether the record covers the URL or origin.&lt;/li&gt;
&lt;li&gt;Search Console's Core Web Vitals report groups similar pages on your site and reports their status based on CrUX field data. Review all the Core Web Vitals, not just LCP.&lt;/li&gt;
&lt;li&gt;Google's &lt;a href="https://developer.chrome.com/docs/crux/history-api" rel="noopener noreferrer"&gt;CrUX History API documentation&lt;/a&gt; explains how to retrieve historical data for eligible URLs and origins. It returns 25 weekly collection periods by default, as in the Yourhosting analysis above, but some periods may have no eligible data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use field data to prioritize the problems observed during real visits, and lab tests to investigate them. Keep the scope and limitations of both measurements next to the numbers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://straffesites.com/en/blog/lab-vs-field-pagespeed" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>lighthouse</category>
      <category>seo</category>
    </item>
    <item>
      <title>Minifying Astro HTML after the build: a post-build script, measured</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Mon, 26 Oct 2026 19:56:50 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/minifying-astro-html-after-the-build-a-post-build-script-measured-42ob</link>
      <guid>https://dev.to/seppegadeyne/minifying-astro-html-after-the-build-a-post-build-script-measured-42ob</guid>
      <description>&lt;p&gt;This is a standalone example for static Astro projects. The site where I measured it, &lt;a href="https://straffesites.com" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;, does not use the script in production. In October 2026, I did run it on a copy of that site's build to measure what a post-build step does to your Astro HTML. The script adds unique IDs to headings that do not have one yet and minifies the files with html-minifier-terser. Here is how to set it up, what the measurement showed, and what to watch out for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you need to get started?
&lt;/h2&gt;

&lt;p&gt;Before you start, make sure you have the following installed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/en" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; (22.23.4 or later: Astro 7.4 needs it; jsdom 30 needs at least 22.22.2)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; (Astro 7, tested with 7.2.8)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Installing the dependencies
&lt;/h2&gt;

&lt;p&gt;The script depends on three packages: &lt;code&gt;globby&lt;/code&gt;, &lt;code&gt;html-minifier-terser&lt;/code&gt;, and &lt;code&gt;jsdom&lt;/code&gt;. Install them as development dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; globby html-minifier-terser jsdom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The process-html.mjs script
&lt;/h2&gt;

&lt;p&gt;Create a file named &lt;code&gt;process-html.mjs&lt;/code&gt; in the root of your Astro project. The code below uses Astro's default static output folder, &lt;code&gt;dist&lt;/code&gt;. If you configured another output directory, adjust the &lt;code&gt;path&lt;/code&gt; variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;globby&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;globby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;minify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html-minifier-terser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;JSDOM&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsdom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Find all HTML files in the output folder&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;globby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/**/*.html`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Processing file:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Preserve existing IDs and add unique IDs to other h2, h3 and h4 elements&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSDOM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h2, h3, h4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usedIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[id]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Mark}+/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\p&lt;/span&gt;&lt;span class="sr"&gt;{Letter}&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Number}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^-|-$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;section&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseId&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
      &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;

      &lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;usedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// Minify the resulting HTML&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;minify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;removeComments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;preserveLineBreaks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;collapseWhitespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Updating package.json
&lt;/h2&gt;

&lt;p&gt;Update &lt;code&gt;package.json&lt;/code&gt; to run the script after the build. Add &lt;code&gt;&amp;amp;&amp;amp; node process-html.mjs&lt;/code&gt; after &lt;code&gt;astro build&lt;/code&gt;, as shown below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro-process-html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro build &amp;amp;&amp;amp; node process-html.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"astro"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What does it change in practice?
&lt;/h2&gt;

&lt;p&gt;I ran the script above exactly as shown on a copy of the straffesites.com build: 236 HTML pages, built with Astro 7.2.8 and the default compressHTML setting. The package versions were globby 16.2.4, html-minifier-terser 7.2.0, and jsdom 30.1.2. The result is the opposite of what you might expect: the Astro HTML did not get smaller, it got larger.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Before the script&lt;/th&gt;
&lt;th&gt;After the script&lt;/th&gt;
&lt;th&gt;Difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All 236 HTML files&lt;/td&gt;
&lt;td&gt;31,807,539 bytes&lt;/td&gt;
&lt;td&gt;32,067,896 bytes&lt;/td&gt;
&lt;td&gt;+0.82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same files with gzip&lt;/td&gt;
&lt;td&gt;6,573,834 bytes&lt;/td&gt;
&lt;td&gt;6,637,927 bytes&lt;/td&gt;
&lt;td&gt;+0.97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same files with Brotli&lt;/td&gt;
&lt;td&gt;5,506,134 bytes&lt;/td&gt;
&lt;td&gt;5,562,603 bytes&lt;/td&gt;
&lt;td&gt;+1.03%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heading IDs added&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;6,552 on 236 pages&lt;/td&gt;
&lt;td&gt;on every page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two steps explain it. The minify step alone, with the same options, removed 408 bytes across the whole build, because Astro had already cleaned up the whitespace. Parsing and serializing with jsdom alone made the files 38,059 bytes larger, because jsdom writes every empty attribute in full (&lt;code&gt;data-site-header&lt;/code&gt; becomes &lt;code&gt;data-site-header=""&lt;/code&gt;). That leaves about 222 kB, roughly 85% of the 260,357-byte growth, that comes from the new IDs. Processing took 25.7 seconds.&lt;/p&gt;

&lt;p&gt;The Astro documentation explains why compressHTML already does so much:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Since v7.0, Astro applies by default the JSX whitespace rules used by frameworks like React."&lt;/p&gt;

&lt;p&gt;— Astro, &lt;a href="https://docs.astro.build/en/reference/configuration-reference/#compresshtml" rel="noopener noreferrer"&gt;Configuration Reference: compressHTML&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the value of the script is in the transformation, not in bytes. Every h2, h3, and h4 gets a stable, unique anchor ID, so you can link to any section. Existing IDs stay in place. The example does not remove empty paragraphs or CMS attributes; that needs extra, tested logic. Measure your own build to decide whether the anchors are worth the extra bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which pitfalls should you know about?
&lt;/h2&gt;

&lt;p&gt;Keep HTML comments when using Svelte 5 or other comment-based hydration. That is why this example sets &lt;code&gt;removeComments: false&lt;/code&gt;. See the &lt;a href="https://svelte.dev/docs/svelte/v5-migration-guide#Hydration-works-differently" rel="noopener noreferrer"&gt;Svelte 5 migration guide&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put the script in your build pipeline rather than running it manually. Every build overwrites the output folder, so the next deploy would discard a manual run. That is why &lt;code&gt;package.json&lt;/code&gt; includes it above.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;preserveLineBreaks: true&lt;/code&gt; enabled. Without it, your HTML collapses onto one line, which makes built files much harder to diff or debug.&lt;/li&gt;
&lt;li&gt;jsdom parses and serializes every page again. That is usually safe for ordinary HTML, but test hydrated Svelte or other framework islands whenever you extend the script.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;collapseWhitespace&lt;/code&gt; can remove a visible space around inline elements in some edge cases. Check the design after the first run, especially around buttons and icons next to text.&lt;/li&gt;
&lt;li&gt;Do not expect smaller files. jsdom writes empty attributes in full and every new ID costs bytes, so on an Astro 7 build your HTML is more likely to grow. Use the script for the anchors, not for the file size.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is there an alternative to a standalone script?
&lt;/h2&gt;

&lt;p&gt;If you only want smaller files and no heading anchors, you do not need to write anything yourself. An existing integration such as &lt;a href="https://github.com/PlayForm/Compress" rel="noopener noreferrer"&gt;@playform/compress&lt;/a&gt; runs html-minifier-terser together with minifiers for CSS, JavaScript, SVG, and images in your Astro build. Measure the difference there too, because Astro has already compressed most of the HTML.&lt;/p&gt;

&lt;p&gt;If you prefer an Astro integration to a standalone npm script, wrap the same logic in one. It runs on the &lt;code&gt;astro:build:done&lt;/code&gt; hook and receives the output directory, so you do not have to hardcode paths. The integration is more reusable but slightly more involved: a standalone script is fine for one site, while an integration pays off across several projects.&lt;/p&gt;

&lt;p&gt;This is only the integration skeleton, not a second complete script. Add the file processing above and convert the &lt;code&gt;dir&lt;/code&gt; URL with &lt;code&gt;fileURLToPath&lt;/code&gt; from &lt;code&gt;node:url&lt;/code&gt; before using it as a globby path. Do not enable this alternative together with the npm post-build script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// astro.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processHtml&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;process-html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:build:done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Run the same globby and minification logic as process-html.mjs,&lt;/span&gt;
        &lt;span class="c1"&gt;// using every .html file in the output directory.&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;processHtml&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both approaches do the same thing: after the build, they process every HTML file. Choose the one that best fits how you manage projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;With a few lines after the build, all your Astro HTML gets stable heading anchors on every deploy. You will not get smaller files: Astro already compresses the HTML, and on the measured site the build grew 0.82%. Use the script for a specific transformation and measure the effect on your own build.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/terser/html-minifier-terser" rel="noopener noreferrer"&gt;html-minifier-terser reference&lt;/a&gt; covers the remaining configuration options.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://straffesites.com/en/blog/optimize-astro-html-post-build" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Patch PHP2Shell on PHP 7.4 and 8.1 compiled from source, without a panel</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:56:38 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/patch-php2shell-on-php-74-and-81-compiled-from-source-without-a-panel-j60</link>
      <guid>https://dev.to/seppegadeyne/patch-php2shell-on-php-74-and-81-compiled-from-source-without-a-panel-j60</guid>
      <description>&lt;p&gt;&lt;strong&gt;PHP2Shell is fixed in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. If your server runs PHP 5.6 to 8.1 that you or your host compiled from source, there is no release to install.&lt;/strong&gt; Your 7.4.33 is the last 7.4 release ever, and it is vulnerable. This guide shows how to patch that PHP in place with three scripts. The first reads how your PHP was built. The second rebuilds it with the fix on another machine. The third swaps the new binaries in, with checks and a rollback.&lt;/p&gt;

&lt;p&gt;No control panel is needed, no Docker, and nothing is compiled on the server itself.&lt;/p&gt;

&lt;p&gt;Earlier this week I wrote about &lt;a href="https://dev.to/seppegadeyne/patching-php-56-to-81-for-php2shell-on-old-ubuntu-servers-by-rebuilding-runclouds-php-1bl7"&gt;patching PHP 5.6 to 8.1 on our RunCloud servers&lt;/a&gt;. Several readers asked the same question: what if I'm not on RunCloud? This article is the answer. It covers the generic mode of the same repository, &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds" rel="noopener noreferrer"&gt;seppegadeyne/php2shell-runcloud-builds&lt;/a&gt;, which works for any PHP that reports its own build flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is this guide for your server?&lt;/li&gt;
&lt;li&gt;First: turn on the mitigation today&lt;/li&gt;
&lt;li&gt;What the fix is and where it comes from&lt;/li&gt;
&lt;li&gt;Step 1: record how your PHP was built&lt;/li&gt;
&lt;li&gt;Step 2: build the patched PHP on another machine&lt;/li&gt;
&lt;li&gt;Step 3: check, swap and roll back&lt;/li&gt;
&lt;li&gt;Verify that the server runs the new binary&lt;/li&gt;
&lt;li&gt;What the scripts refuse to do&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is this guide for your server?
&lt;/h2&gt;

&lt;p&gt;Run this on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(PHP Version|Server API|Configure Command)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are in the right place if all of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The version is 5.6 to 8.1&lt;/strong&gt;, or an 8.2 to 8.5 release older than the fixed ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Configure Command&lt;/code&gt; shows a line&lt;/strong&gt; that starts with &lt;code&gt;'./configure'&lt;/code&gt;. That means PHP was compiled from source: into &lt;code&gt;/usr/local&lt;/code&gt;, into &lt;code&gt;/opt&lt;/code&gt;, or by a hosting setup that did it for you years ago.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server runs Ubuntu 16.04, 18.04, 20.04, 22.04 or 24.04.&lt;/strong&gt; The build is automated for those releases only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PHP runs as PHP-FPM, CGI or the CLI.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are in the wrong place if PHP comes from a distribution package. Ubuntu's own &lt;code&gt;php8.1&lt;/code&gt;, Debian's packages and Ondřej Surý's PPA do not print a configure command, and their vendors ship their own fixes. Update those with &lt;code&gt;apt-get install --only-upgrade 'php*'&lt;/code&gt; and reload PHP-FPM. The collect script below also checks this for you: for a binary that belongs to a package, it searches the package changelog for the two CVE numbers.&lt;/p&gt;

&lt;p&gt;Apache's &lt;code&gt;mod_php&lt;/code&gt; and LiteSpeed's &lt;code&gt;lsphp&lt;/code&gt; are out of scope too. The scripts report them and skip them. Move &lt;code&gt;mod_php&lt;/code&gt; sites to PHP-FPM first, and patch &lt;code&gt;lsphp&lt;/code&gt; through LiteSpeed's own packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: turn on the mitigation today
&lt;/h2&gt;

&lt;p&gt;Do this today, before you build anything. Every known PHP2Shell trigger is a &lt;code&gt;multipart/form-data&lt;/code&gt; request, and the stopgap that hosts and panels rolled out on October 21 turns off the session upload-progress feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; for example /usr/local/etc/php/conf.d/99-php2shell.ini
&lt;/span&gt;&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new ini file does nothing until PHP-FPM reloads. A master that has been running for weeks still uses the old settings in every worker. Test the config, then reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/local/sbin/php-fpm &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; systemctl reload php-fpm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use your own binary path and service name. Then confirm it through a real request. A pool can load a different &lt;code&gt;php.ini&lt;/code&gt; than the CLI, so &lt;code&gt;php -i&lt;/code&gt; on the command line proves nothing about FPM. Put a temporary file in a site's web root that prints &lt;code&gt;ini_get('session.upload_progress.enabled')&lt;/code&gt;, request it, and delete it right away. It should print an empty string (or 0).&lt;/p&gt;

&lt;p&gt;That is a mitigation, not a fix. The rest of this guide is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix is and where it comes from
&lt;/h2&gt;

&lt;p&gt;PHP2Shell is two bugs, CVE-2026-109618 and CVE-2026-109619. They allow remote code execution without authentication against PHP-FPM. &lt;a href="https://sansec.io/research/php2shell" rel="noopener noreferrer"&gt;Sansec&lt;/a&gt; has a good technical write-up, and the &lt;a href="https://www.php.net/" rel="noopener noreferrer"&gt;php.net homepage&lt;/a&gt; lists the fixed releases.&lt;/p&gt;

&lt;p&gt;The PHP project only released fixes for 8.2 and newer. But the patches for older branches exist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PHP branch&lt;/th&gt;
&lt;th&gt;Built as&lt;/th&gt;
&lt;th&gt;Patch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;td&gt;5.6.40&lt;/td&gt;
&lt;td&gt;Ondřej Surý's port from his Debian source packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.0 to 7.3&lt;/td&gt;
&lt;td&gt;7.0.33, 7.1.33, 7.2.34, 7.3.33&lt;/td&gt;
&lt;td&gt;Surý's port, plus a &lt;code&gt;ZSTR_MAX_LEN&lt;/code&gt; backport by Remi Collet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.4, 8.0, 8.1&lt;/td&gt;
&lt;td&gt;7.4.33, 8.0.30, 8.1.34&lt;/td&gt;
&lt;td&gt;the PHP project's own backport branches in php-src&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.2 to 8.5&lt;/td&gt;
&lt;td&gt;8.2.35, 8.3.36, 8.4.27, 8.5.12&lt;/td&gt;
&lt;td&gt;none: the official fixed release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repository has each patch as a file in &lt;code&gt;patches/&lt;/code&gt;, with its origin and authors.&lt;/p&gt;

&lt;p&gt;What the patch changes: on an out-of-memory error, PHP now ends the worker with &lt;code&gt;_exit(1)&lt;/code&gt; instead of unwinding and running more code. The upload-progress callback is switched off in code, and request state is reset harder between requests. PHP-FPM starts a fresh worker, so sites keep working.&lt;/p&gt;

&lt;p&gt;One thing the patch leaves alone matters a lot here: the extension API number. A 7.4 extension built years ago for 7.4.21 still loads in the patched 7.4.33. So you only replace the PHP core. The server's own &lt;code&gt;redis.so&lt;/code&gt;, &lt;code&gt;imagick.so&lt;/code&gt; or &lt;code&gt;ioncube&lt;/code&gt; loader stays where it is.&lt;/p&gt;

&lt;p&gt;That is also why the build stays inside your branch. A server on 7.4.21 gets 7.4.33 with the patch, not 8.1. Moving to a newer branch is a migration project; this is a security fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: record how your PHP was built
&lt;/h2&gt;

&lt;p&gt;On the server, as root. The script reads and changes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /root/php2shell &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /root/php2shell
&lt;span class="nv"&gt;RAW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://raw.githubusercontent.com/seppegadeyne/php2shell-runcloud-builds/main/generic
curl &lt;span class="nt"&gt;-fLO&lt;/span&gt; &lt;span class="nv"&gt;$RAW&lt;/span&gt;/collect.sh
curl &lt;span class="nt"&gt;-fLO&lt;/span&gt; &lt;span class="nv"&gt;$RAW&lt;/span&gt;/deploy.sh      &lt;span class="c"&gt;# needed in step 3&lt;/span&gt;
bash collect.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read both scripts before you run them as root. They are plain Bash, a few hundred lines in total.&lt;/p&gt;

&lt;p&gt;Without arguments, &lt;code&gt;collect.sh&lt;/code&gt; checks the usual paths: &lt;code&gt;/usr/local/bin&lt;/code&gt;, &lt;code&gt;/usr/local/sbin&lt;/code&gt; and &lt;code&gt;/usr/local/php*&lt;/code&gt;, &lt;code&gt;/usr/bin&lt;/code&gt; and &lt;code&gt;/usr/sbin&lt;/code&gt;, one or two levels under &lt;code&gt;/opt&lt;/code&gt;, Remi's collections, cPanel's &lt;code&gt;ea-php&lt;/code&gt;, Plesk and LiteSpeed. If your PHP lives elsewhere, name the binaries yourself, and name all of them, because the search is then skipped: &lt;code&gt;bash collect.sh /srv/php74/bin/php /srv/php74/bin/php-cgi /srv/php74/sbin/php-fpm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Output from our test server, a PHP 7.4.33 compiled into &lt;code&gt;/usr/local&lt;/code&gt; on Ubuntu 20.04 (host name changed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;found: /usr/local/bin/php  PHP 7.4.33 (Command Line Interface)
found: /usr/local/bin/php-cgi  PHP 7.4.33 (CGI/FastCGI)
found: /usr/local/sbin/php-fpm  PHP 7.4.33 (FPM/FastCGI)
profile: /root/php2shell/php2shell-profile-web1-20261025-072514.tar.gz (3 binaries)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The profile is a small tarball. For each binary, it holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the version, the SAPI and the configure command&lt;/li&gt;
&lt;li&gt;the extension API number and the built-in modules&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;php.ini&lt;/code&gt; paths and the extension directory&lt;/li&gt;
&lt;li&gt;the linked libraries and the SHA-256&lt;/li&gt;
&lt;li&gt;the owning package, if any&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also stores &lt;code&gt;/etc/os-release&lt;/code&gt;, so the build machine knows which Ubuntu to build for.&lt;/p&gt;

&lt;p&gt;Watch the output for two kinds of notes. &lt;code&gt;links libraries outside /lib and /usr/lib&lt;/code&gt; means your PHP uses, for example, its own OpenSSL in &lt;code&gt;/opt&lt;/code&gt;. You will need to hand that library to the build in step 2. &lt;code&gt;NOTE: ... mod_php&lt;/code&gt; or &lt;code&gt;SAPI 'LiteSpeed'&lt;/code&gt; means a binary this method cannot replace. On LiteSpeed, the &lt;code&gt;php&lt;/code&gt; CLI next to &lt;code&gt;lsphp&lt;/code&gt; can be rebuilt, but the &lt;code&gt;lsphp&lt;/code&gt; handler that serves your sites stays as it is.&lt;/p&gt;

&lt;p&gt;Copy the profile to your build machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: build the patched PHP on another machine
&lt;/h2&gt;

&lt;p&gt;The build machine is any Linux x86_64 box: a workstation, a spare VM, a CI runner. It needs &lt;code&gt;unshare&lt;/code&gt; from util-linux 2.38 or newer with user namespaces enabled, &lt;code&gt;newuidmap&lt;/code&gt; from the &lt;code&gt;uidmap&lt;/code&gt; package, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;tar&lt;/code&gt; and &lt;code&gt;xz&lt;/code&gt;. Ubuntu 24.04, Debian 12, Fedora and Arch qualify; Ubuntu 22.04 ships an older util-linux. It also needs about 5 GB of disk per Ubuntu release and a subordinate ID range for your user. No root, no sudo, no Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; /etc/subuid /etc/subgid   &lt;span class="c"&gt;# must print a range in both files&lt;/span&gt;
&lt;span class="c"&gt;# if not: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER&lt;/span&gt;

git clone https://github.com/seppegadeyne/php2shell-runcloud-builds.git
&lt;span class="nb"&gt;cd &lt;/span&gt;php2shell-runcloud-builds
generic/build.sh ~/php2shell-profile-web1-20261025-072514.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script unpacks the official &lt;code&gt;ubuntu-base&lt;/code&gt; root filesystem for your server's release and enters it as a rootless chroot. So the result links against the same glibc, OpenSSL and ICU as your server. Inside, it installs the build dependencies of that release's own PHP source package, plus a list of libraries that configure flags often ask for. It checks the &lt;code&gt;ubuntu-base&lt;/code&gt; image, the php.net tarball and the patch against SHA-256 sums pinned in the repository, applies the patch, and runs your server's configure command word for word.&lt;/p&gt;

&lt;p&gt;The first lines show the plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan b1 /usr/local/bin/php: PHP 7.4.33 -&amp;gt; 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
plan b2 /usr/local/bin/php-cgi: PHP 7.4.33 -&amp;gt; 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
plan b3 /usr/local/sbin/php-fpm: PHP 7.4.33 -&amp;gt; 7.4.33 (php-7.4-php2shell.patch) job j92646cb03e
DEPS_OK (cached)
=== j92646cb03e: PHP 7.4.33 for b1 b2 b3 (patch: php-7.4-php2shell.patch)
patch applied: php-7.4-php2shell.patch
BUILD_OK j92646cb03e PHP 7.4.33
OUT .../out/generic/web1/php7.4.33-php2shell-j92646cb03e.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three binaries with the same version and configure command make one build job. Two PHP versions on one server make two jobs and two tarballs. With the root filesystem already set up, this run took 1 minute 34 seconds on a 16-core desktop. A first run on a new Ubuntu release takes longer, because it downloads and installs the build dependencies once.&lt;/p&gt;

&lt;p&gt;The build stops instead of guessing in these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The patch does not apply cleanly&lt;/strong&gt; or would need fuzz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The extension API of the build differs&lt;/strong&gt; from the server's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A built-in module is missing&lt;/strong&gt; in the build. A site would lose a function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server loads a shared &lt;code&gt;opcache.so&lt;/code&gt; but the configure command builds none.&lt;/strong&gt; The 8.1 fix also changes opcache, so the old file would leave unpatched code running. The fix is to add &lt;code&gt;'--enable-opcache=shared'&lt;/code&gt; to &lt;code&gt;bin/&amp;lt;id&amp;gt;/configure.txt&lt;/code&gt; in the profile, pack it again and rerun the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If collect reported libraries outside &lt;code&gt;/lib&lt;/code&gt; and &lt;code&gt;/usr/lib&lt;/code&gt;, pack them from the server (for example &lt;code&gt;tar -czf extra.tar.gz /opt/openssl-1.1&lt;/code&gt;) and pass them with &lt;code&gt;--extra extra.tar.gz&lt;/code&gt;. The build unpacks them at the same path inside the chroot. That build root is reused for later builds on the same Ubuntu release, so keep it for one kind of server, or delete &lt;code&gt;work/rootfs-generic-&amp;lt;codename&amp;gt;&lt;/code&gt; before you build for another.&lt;/p&gt;

&lt;p&gt;Copy the tarball and &lt;code&gt;SHA256SUMS&lt;/code&gt; from &lt;code&gt;out/generic/&amp;lt;host&amp;gt;/&lt;/code&gt; to &lt;code&gt;/root/php2shell&lt;/code&gt; on the server. Use the same commit of the repository for &lt;code&gt;deploy.sh&lt;/code&gt; and the build; both come from &lt;code&gt;main&lt;/code&gt; in this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: check, swap and roll back
&lt;/h2&gt;

&lt;p&gt;Back on the server, as root, in &lt;code&gt;/root/php2shell&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;--ignore-missing&lt;/span&gt; SHA256SUMS
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz check      &lt;span class="c"&gt;# test only&lt;/span&gt;
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz            &lt;span class="c"&gt;# swap&lt;/span&gt;
bash deploy.sh php7.4.33-php2shell-j92646cb03e.tar.gz rollback   &lt;span class="c"&gt;# undo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the &lt;code&gt;OK&lt;/code&gt; line after &lt;code&gt;sha256sum -c&lt;/code&gt;; the old coreutils on Ubuntu 16.04 can exit 0 without checking anything. Then always run &lt;code&gt;check&lt;/code&gt; first. It changes nothing and runs the tests from points 1 to 3 below; only the PHP-FPM config test in point 7 needs a real deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check: new CLI loads 37 modules, no warnings
CHECK OK (j92646cb03e)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what &lt;code&gt;deploy.sh&lt;/code&gt; does, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It refuses a server that changed.&lt;/strong&gt; Every binary it will replace must still have the SHA-256 from the profile. If someone updated PHP after you ran collect, the deploy stops: collect and build again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It starts the new CLI with your real configuration.&lt;/strong&gt; That is your own &lt;code&gt;php.ini&lt;/code&gt;, &lt;code&gt;conf.d&lt;/code&gt; files and shared extensions. A CLI that does not run, or that prints a warning or error while it loads them, aborts the deploy. This catches an extension that does not load. Compare the module count it prints with &lt;code&gt;php -m | grep -c .&lt;/code&gt; on the old binary: they should match. The test uses the CLI's configuration; an FPM pool with its own &lt;code&gt;php.ini&lt;/code&gt; is only tested when PHP-FPM restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It tests the fix itself&lt;/strong&gt; (5.6 to 8.1, the patched branches). It runs a script with an 8 MB memory limit that allocates 64 MB and registers a shutdown function. The patched binary must exit without running that function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It backs up the originals once&lt;/strong&gt; to &lt;code&gt;/root/php2shell-generic-backup/&amp;lt;job&amp;gt;/&lt;/code&gt;. A second deploy never overwrites that backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It swaps by rename.&lt;/strong&gt; Running processes keep the old file until they restart, and no process ever sees a half-written binary. The shared extensions from the PHP core, such as &lt;code&gt;opcache.so&lt;/code&gt;, are replaced too. That happens in the extension directory your server uses, and only for files it already has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It holds the owning package&lt;/strong&gt;, if a package owns the binary. Then &lt;code&gt;apt-get upgrade&lt;/code&gt; cannot quietly put the unpatched version back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It restarts PHP-FPM.&lt;/strong&gt; It finds every running systemd service whose main process runs a replaced binary, tests its config with the service's own &lt;code&gt;--fpm-config&lt;/code&gt;, and restarts it. If a config test or a start fails, it puts the original files back and restarts the same services. If one of them still does not come up, the rollback says so and exits with an error: check &lt;code&gt;systemctl status&lt;/code&gt; for that unit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On our test server, the deploy and the rollback looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check: new CLI loads 37 modules, no warnings
swapped: 3 binaries, 1 shared extensions from the core
no running PHP-FPM service uses these binaries (nothing restarted; restart it yourself if it runs outside systemd)
DEPLOY OK (j92646cb03e): 7.4.33, patch php-7.4-php2shell.patch
ROLLBACK OK (j92646cb03e)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test chroot has no systemd, hence the "nothing restarted" line. On a normal server you see &lt;code&gt;restarted: &amp;lt;your-fpm-service&amp;gt;&lt;/code&gt; instead. If your PHP-FPM runs under supervisord, runit or a script in &lt;code&gt;/etc/rc.local&lt;/code&gt;, restart it yourself after the swap. Until you do, the old master keeps running the old binary.&lt;/p&gt;

&lt;p&gt;After the rollback, all files had the same SHA-256 as before the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify that the server runs the new binary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;php -v&lt;/code&gt; cannot tell a patched 7.4.33 from an unpatched one, so it proves nothing. Compare file hashes instead. Use your own unit name and paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the file on disk and the binary of the running FPM master&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; /usr/local/sbin/php-fpm /proc/&lt;span class="si"&gt;$(&lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; MainPID php-fpm | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/exe
&lt;span class="c"&gt;# the file in the tarball&lt;/span&gt;
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzOf&lt;/span&gt; php7.4.33-php2shell-j92646cb03e.tar.gz ./usr/local/sbin/php-fpm | &lt;span class="nb"&gt;sha256sum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three hashes must match. If the &lt;code&gt;/proc&lt;/code&gt; hash differs, the master still runs the old binary: restart PHP-FPM.&lt;/p&gt;

&lt;p&gt;Then the behavior test, the same one the deploy ran for 5.6 to 8.1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/local/bin/php &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;memory_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8M &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'register_shutdown_function(function(){echo "shutdown-ran\n";}); $a=str_repeat("x", 64*1024*1024);'&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;   &lt;span class="c"&gt;# 1, and "shutdown-ran" is not printed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, the sites. Before the deploy, request every site through the local web server and save the status codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in &lt;/span&gt;example.com shop.example.com&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;:443:127.0.0.1"&lt;/span&gt; &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; before.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same loop into &lt;code&gt;after.txt&lt;/code&gt; after the deploy and &lt;code&gt;diff before.txt after.txt&lt;/code&gt;. On our RunCloud servers, this comparison is what told us nothing had changed for the sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scripts refuse to do
&lt;/h2&gt;

&lt;p&gt;The scripts are careful on purpose, so it helps to know where they stop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only Ubuntu 16.04 to 24.04 is automated.&lt;/strong&gt; For Debian, Rocky or AlmaLinux, you can build by hand in a matching root filesystem with &lt;code&gt;generic/chroot-build.sh&lt;/code&gt;. &lt;code&gt;generic/chroot-deps.sh&lt;/code&gt; lists the libraries to install, with Ubuntu package names. The swap is then manual too: &lt;code&gt;deploy.sh&lt;/code&gt; only takes tarballs from &lt;code&gt;build.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No distribution packages.&lt;/strong&gt; If the binary has no configure command in &lt;code&gt;php -i&lt;/code&gt;, the build skips it and writes the vendor's fix status to &lt;code&gt;SKIPPED.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code&gt;mod_php&lt;/code&gt; and no &lt;code&gt;lsphp&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No other branch.&lt;/strong&gt; A server on 7.2 gets a patched 7.2.34, not 7.4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No restart outside systemd.&lt;/strong&gt; Under supervisord, runit or an init script, restart PHP-FPM yourself and check the &lt;code&gt;/proc&lt;/code&gt; hash above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one honest limit: the RunCloud builds from my earlier article run on our production servers, but this generic mode does not yet. We tested it in chroots on our build host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a PHP 7.4 compiled into &lt;code&gt;/usr/local&lt;/code&gt; on Ubuntu 20.04, with a shared &lt;code&gt;opcache.so&lt;/code&gt; and &lt;code&gt;php-cgi&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a PHP 8.1.30 on Ubuntu 22.04, built as 8.1.34 with the patch, including a rebuilt &lt;code&gt;opcache.so&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ubuntu 22.04's own &lt;code&gt;php8.1&lt;/code&gt; package, which it skipped correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both builds went through the full cycle: collect, build, check and deploy. PHP-FPM answered a FastCGI request with the new binary, the out-of-memory test exited with 1, and the rollback restored the original hashes. Run &lt;code&gt;check&lt;/code&gt; first, and try one server before the rest. If something breaks on your setup, &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; with the &lt;code&gt;collect.sh&lt;/code&gt; output.&lt;/p&gt;

&lt;p&gt;A patched PHP 7.4 is a way to survive this bug, not a plan. These builds get no further updates. The next PHP bug is your job again, and Ubuntu 16.04 to 20.04 are past standard support. Keep &lt;code&gt;session.upload_progress.enabled = Off&lt;/code&gt; in place: it also covers &lt;a href="https://thehackernews.com/2026/10/urgent-php-warns-of-another-unpatched.html" rel="noopener noreferrer"&gt;CVE-2026-115560&lt;/a&gt;, a separate session bug announced on October 23 that these builds do not fix. And plan the move to a supported PHP on a supported OS.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;php -v&lt;/code&gt; show that the patch is installed?&lt;/strong&gt;&lt;br&gt;
No. A patched 7.4.33 and an unpatched 7.4.33 report the same version. Compare the SHA-256 of the running binary with the file in the tarball, and run the out-of-memory test above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will my existing PHP extensions keep working?&lt;/strong&gt;&lt;br&gt;
Yes, within the same branch. The patch does not change the extension API number, and the deploy aborts if the new CLI prints a warning while it loads your extensions. Only &lt;code&gt;opcache.so&lt;/code&gt; and the other extensions built from the PHP core are replaced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I build on the server itself?&lt;/strong&gt;&lt;br&gt;
You can, if it has the disk space and user namespaces. But a separate machine keeps compilers and build dependencies off the server, and the chroot matches the server's Ubuntu release anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;session.upload_progress.enabled = Off&lt;/code&gt; enough on its own?&lt;/strong&gt;&lt;br&gt;
It is the stopgap everyone recommends, but it is a setting. A config that sets it back, or a pool that loads another &lt;code&gt;php.ini&lt;/code&gt;, removes the protection without a warning. The patch fixes the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about PHP from Ondřej Surý's PPA or Ubuntu's own packages?&lt;/strong&gt;&lt;br&gt;
Update the package. Those vendors ship their own fixes, and &lt;code&gt;collect.sh&lt;/code&gt; reports whether the installed package's changelog already mentions the two CVE numbers.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>Claude refused to finish patching my servers. My local LLM took over</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:33:35 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/claude-refused-to-finish-patching-my-servers-my-local-llm-took-over-3l8j</link>
      <guid>https://dev.to/seppegadeyne/claude-refused-to-finish-patching-my-servers-my-local-llm-took-over-3l8j</guid>
      <description>&lt;p&gt;On October 21 and 22, I spent two long days patching our own servers. PHP had announced an emergency release for two critical remote code execution bugs, now known as "PHP2Shell". We run seven RunCloud servers with 32 WordPress sites, and several of them are on PHP versions that no longer get official fixes.&lt;/p&gt;

&lt;p&gt;I did most of the work together with &lt;a href="https://hermes-agent.nousresearch.com/" rel="noopener noreferrer"&gt;Hermes&lt;/a&gt;, my AI agent, running Claude Opus 5.5. It went well until it didn't. In the middle of the job, the cloud model stopped answering. My local model, Qwen3.8-Flash-Next on my own workstation, picked up the same session and finished the work.&lt;/p&gt;

&lt;p&gt;This post covers what happened, why the cloud model stopped, and why I now see a local model as a real backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job
&lt;/h2&gt;

&lt;p&gt;The work came in phases over two days:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Patch PHP on all servers. Where RunCloud no longer ships builds (PHP 5.6 to 8.1 on old Ubuntu releases), we rebuilt the PHP binaries ourselves with the upstream fixes. I wrote up &lt;a href="https://dev.to/seppegadeyne/patching-php-56-to-81-for-php2shell-on-old-ubuntu-servers-by-rebuilding-runclouds-php-1bl7"&gt;how we patched PHP 5.6 to 8.1 for PHP2Shell&lt;/a&gt;. The builds and scripts are now public in &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds" rel="noopener noreferrer"&gt;php2shell-runcloud-builds&lt;/a&gt;, including a generic mode for servers that don't run RunCloud.&lt;/li&gt;
&lt;li&gt;Clean up and restore a handful of WordPress sites that had been compromised. The cause turned out to be an old copy of The Grid, a plugin bundled with a Themeco theme that we can't update without breaking the site layouts.&lt;/li&gt;
&lt;li&gt;Make a full inventory of every plugin on every site, look up known vulnerabilities for the exact installed versions, and backport the fixes into the old plugin code by hand. For these sites, a plugin update is not an option for the same reason.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Phase 3 is where it got stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Your request was not processed"
&lt;/h2&gt;

&lt;p&gt;The agent had built the inventory: 32 sites, 206 plugins, 300 plugin/version pairs. It had matched them against the free &lt;a href="https://www.wpvulnerability.net/" rel="noopener noreferrer"&gt;WPVulnerability&lt;/a&gt; database and was checking access logs for suspicious requests to &lt;code&gt;wp-content/uploads&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then every message I sent got the same reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your request was not processed. Send it again if you still want me to carry it out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried "continue", "try it another way", and "try again, differently". The reply was the same each time. When I asked where it got stuck, it answered normally. It said it had not executed anything in those turns and could not see why. Its guess was a context length issue or an outage.&lt;/p&gt;

&lt;p&gt;It wasn't either of those. The Hermes log had the real reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-22 14:49:28 WARNING [20261021_095417_816396] agent.conversation_loop:
Model declined to respond (finish_reason=content_filter).
model=claude-opus-5-5[1m] ...
refusal=This request triggered restrictions on violative cyber content and was
blocked under Anthropic's Usage Policy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were five of these in that session between 14:49 and 16:42, and one more in a separate session where I asked for a client report about the same breach. That report session was actually the first one I moved to the local model, and it finished the report there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Claude Opus 5.5 runs behind safety classifiers. When one fires, the raw API returns a normal response with &lt;code&gt;stop_reason: "refusal"&lt;/code&gt; and a category. Hermes logged it as &lt;code&gt;finish_reason=content_filter&lt;/code&gt;, without the category field, but the refusal text points to the &lt;code&gt;cyber&lt;/code&gt; category. Anthropic's &lt;a href="https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback" rel="noopener noreferrer"&gt;refusals and fallback docs&lt;/a&gt; describe it like this: the category covers requests that "could enable cyber harm, such as malware or exploit development", and "benign cybersecurity work can also trigger this category."&lt;/p&gt;

&lt;p&gt;That second sentence was us. Look at what was in the context window at that point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Access log lines with requests for &lt;code&gt;code-execution.php&lt;/code&gt; in an uploads folder and &lt;code&gt;../../wp-config.php&lt;/code&gt; path traversal attempts&lt;/li&gt;
&lt;li&gt;A list of 47 serious CVEs: remote code execution, file upload, SQL injection, privilege escalation&lt;/li&gt;
&lt;li&gt;Diffs between vulnerable and fixed plugin versions, such as &lt;code&gt;maybe_unserialize()&lt;/code&gt; being replaced with &lt;code&gt;unserialize( ..., array( 'allowed_classes' =&amp;gt; false ) )&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Earlier in the session: forensics on real web shells and backdoor admin accounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To a classifier, "read the vulnerable code path and figure out exactly how the bug works" looks the same whether you want to patch it or exploit it. My reading is that the classifier looks at the request as a whole, including the tool output the agent had just read. My message was "continue". The context was the problem.&lt;/p&gt;

&lt;p&gt;A few details made it harder to see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The refusal was invisible to the model.&lt;/strong&gt; The agent got a short placeholder in place of its own turn. It didn't know it had been refused, so it couldn't tell me, and it guessed wrong about the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrying doesn't help.&lt;/strong&gt; The docs say so too: "Re-sending a refused request to the same model usually earns another refusal." Each retry sent the same context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It wasn't consistent.&lt;/strong&gt; After a non-technical question, the session worked again. The next phase (an nginx rule that blocks PHP execution in uploads on all 32 sites) went fine. Then, once it was reading Gravity Forms diffs, the next refusal came at 16:42.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be fair: this isn't a bug, and I understand why the classifier exists. The docs also describe a server-side &lt;code&gt;fallbacks&lt;/code&gt; option that retries a refused request on a different Claude model. My setup goes through a subscription-based provider in Hermes that doesn't use it, so for me the session just stopped. If you build on the API directly, check whether your refusal category has a recommended fallback model, and log refusals as their own event. A refusal is an HTTP 200, so error-rate monitoring won't see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to the local model
&lt;/h2&gt;

&lt;p&gt;My workstation is a Ryzen 9 9950X3D with an RTX 5090 and 128 GB of RAM. I already run several large open models on it. I wrote about &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81"&gt;Qwen3.8-Flash-Next with Strata and a 5-bit quant&lt;/a&gt; and &lt;a href="https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j"&gt;compared four local models side by side&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Hermes treats the local server as just another OpenAI-compatible provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strata-qwen&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Strata Qwen3.8-Flash-Next (UD-Q5_K_XL)&lt;/span&gt;
    &lt;span class="na"&gt;base_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8081/v1&lt;/span&gt;
    &lt;span class="na"&gt;default_model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;strata&lt;/span&gt;
    &lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;strata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;context_length&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;524288&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The switch took two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Free the GPU (a background job was using it) and start the Strata server with the Unsloth UD-Q5_K_XL quant. That was at 17:22.&lt;/li&gt;
&lt;li&gt;In the same Hermes session, switch the model with &lt;code&gt;/model&lt;/code&gt; and type (in Dutch): "Continue (now with a different model, hopefully no restrictions this time)."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Switching models in the middle of a session isn't the usual way to work in Hermes. A new session or a configured fallback provider is cleaner. Here it was the fastest way to keep the full context.&lt;/p&gt;

&lt;p&gt;Hermes keeps the session history, so Qwen picked up the full context: the inventory, the CVE list, the research that Opus and its subagents had already done, the plugin sources already downloaded, the earlier decisions, and my standing rules (no plugin updates, back up first, roll back on any error).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Qwen finished
&lt;/h2&gt;

&lt;p&gt;From 17:45 to 18:38, Qwen made 66 model calls. The prompt grew from about 121k to 212k tokens, well within the 512k context. Once the prompt cache was warm, turns took 3 to 20 seconds. About 20 turns missed the cache, mostly while it was writing the patcher, and took 70 to 115 seconds each. That's slower than the cloud, but it kept going.&lt;/p&gt;

&lt;p&gt;In that time it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finished filtering the 47 serious CVEs (started under Opus) down to the ones that work without a login, and wrote down why each of the others was skipped&lt;/li&gt;
&lt;li&gt;Wrote a patcher (&lt;code&gt;patch_plugins.py&lt;/code&gt;) with exact code anchors, a &lt;code&gt;HERMES-PATCH&lt;/code&gt; marker, and CRLF handling, and tested it locally on copies of each affected version&lt;/li&gt;
&lt;li&gt;Where an upstream fix was available, took it from the official release by diffing the vulnerable and fixed versions, instead of writing its own&lt;/li&gt;
&lt;li&gt;Wrote a deploy script with a backup per server, &lt;code&gt;php -l&lt;/code&gt; with each site's own PHP version, and automatic rollback&lt;/li&gt;
&lt;li&gt;Patched 56 plugin copies across our seven servers: Gravity Forms, Broken Link Checker, MonsterInsights, Media Library Assistant, WooCommerce, WPForms, Relevanssi, StoryChief, and a product tabs plugin. No rollbacks were needed, and all 32 sites still returned 200.&lt;/li&gt;
&lt;li&gt;After I asked for it, moved MySQL to listen on &lt;code&gt;127.0.0.1&lt;/code&gt; only on the five servers that still listened on all interfaces. On two servers, the real setting turned out to be in a RunCloud file (&lt;code&gt;mariadb.conf.d/99-server.cnf&lt;/code&gt;) that overrode the drop-in it had added first. It noticed, found the right file, and fixed it there.&lt;/li&gt;
&lt;li&gt;Did a final read-only check across all servers when I asked it to verify everything, and explained a monitoring alert as the short MySQL restart it was&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It wasn't flawless. The patcher had two bugs: &lt;code&gt;\$&lt;/code&gt; in Python replacement strings, and a slug comparison that missed folder names with version numbers. It found both in its own local test run and fixed them. Its final summary said 56 patched plugin copies, while an earlier report said 48. When I checked afterwards, 56 was right: the first deploy run failed on one server and didn't include another, and the earlier report was written before those were patched. Those are the kinds of things you check anyway, whatever model you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A local model is insurance.&lt;/strong&gt; I don't run Qwen locally because it's better than Opus. For most work, I still use the cloud model. But the cloud model can stop on you for reasons you don't control: a classifier, an outage, a rate limit, a policy change. When that happens halfway through patching live servers, "come back later" isn't a good option. A local model with the same tools, the same agent, and the same session history means you can keep going.&lt;/p&gt;

&lt;p&gt;A few practical points if you want this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use one agent framework for both.&lt;/strong&gt; The handover only worked because Hermes kept the session and tools the same. The model changed and nothing else did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the local model ready.&lt;/strong&gt; Mine loads its 158 GB quant (92 GiB of experts) in under a minute, once I've stopped the other job on the GPU. Know your start script and test it now and then.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a model that handles long tool-calling sessions.&lt;/strong&gt; A 200k-token agent context with dozens of shell calls is very different from a chat benchmark. In my own tests, Qwen3.8-Flash-Next with Strata was the fastest of the four models I compared on October 22 and the most reliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the logs when something stops.&lt;/strong&gt; "Your request was not processed" told me nothing. &lt;code&gt;finish_reason=content_filter&lt;/code&gt; told me what had happened. Look for refusals separately from errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the work the same way.&lt;/strong&gt; Backups, linting, before-and-after HTTP checks, and rollback scripts did their job no matter which model wrote the patch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I expect cloud models to stay strict in some areas, and that's their call to make. Security work on your own servers will always look a bit like attack work. Having a capable model on your own hardware means that decision doesn't stop your work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localllm</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Patching PHP 5.6 to 8.1 for PHP2Shell on old Ubuntu servers by rebuilding RunCloud's PHP</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:10:16 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/patching-php-56-to-81-for-php2shell-on-old-ubuntu-servers-by-rebuilding-runclouds-php-1bl7</link>
      <guid>https://dev.to/seppegadeyne/patching-php-56-to-81-for-php2shell-on-old-ubuntu-servers-by-rebuilding-runclouds-php-1bl7</guid>
      <description>&lt;p&gt;&lt;strong&gt;On October 21, 2026, PHP shipped fixes for PHP2Shell, but only for PHP 8.2 and newer. Our hosting panel, RunCloud, only patches those versions too.&lt;/strong&gt; Most of our older WordPress sites run on PHP 5.6 to 8.1, on Ubuntu releases as old as 16.04. So that evening we rebuilt PHP ourselves: every old version, for every Ubuntu release on our servers, with RunCloud's own build flags, and swapped in only the patched binaries. This article shows how, what broke on the way, and how we checked that nothing changed for the sites.&lt;/p&gt;

&lt;p&gt;Everything below was done on October 21, 2026: builds and tests on our own workstation, deploys and checks on our servers. Where a fact comes from someone else, I link the source. Server and client names are left out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, October 24, 2026:&lt;/strong&gt; all 25 builds, the patches, RunCloud's configure commands and the build scripts are now public: &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds" rel="noopener noreferrer"&gt;seppegadeyne/php2shell-runcloud-builds&lt;/a&gt;. You can download a tarball for your Ubuntu release or rebuild it yourself with one command. Details in Use our builds, or build them yourself below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Later that day:&lt;/strong&gt; not on RunCloud? The repository now also has a generic mode for a PHP that you or your host compiled from source. It reads the configure command from your server, builds the newest release of the same PHP branch with the fix, inside your server's Ubuntu release, and swaps it in with the same checks. See Not on RunCloud: the generic mode. The full step-by-step guide is in a separate article: &lt;a href="https://dev.to/seppegadeyne/patch-php2shell-on-php-74-and-81-compiled-from-source-without-a-panel-j60"&gt;Patch PHP2Shell on PHP 7.4 and 8.1 compiled from source, without a panel&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem: two critical bugs and a panel that stops at 8.2
&lt;/h2&gt;

&lt;p&gt;PHP2Shell is two bugs, CVE-2026-109618 and CVE-2026-109619. They allow remote code execution without authentication, PHP-FPM is affected, and every known trigger is a &lt;code&gt;multipart/form-data&lt;/code&gt; request. The announcement on the &lt;a href="https://www.php.net/" rel="noopener noreferrer"&gt;php.net homepage&lt;/a&gt; lists the fixed releases: 8.2.35, 8.3.36, 8.4.27 and 8.5.12. &lt;a href="https://sansec.io/research/php2shell" rel="noopener noreferrer"&gt;Sansec&lt;/a&gt; has a good technical write-up and timeline.&lt;/p&gt;

&lt;p&gt;Our setup at Straffe Sites: seven servers managed with RunCloud, running Ubuntu 16.04, 18.04 (three servers), 20.04 (two) and 22.04. RunCloud installs its own PHP builds under &lt;code&gt;/RunCloud/Packages/phpXXrc&lt;/code&gt; from a per-release apt repository, &lt;code&gt;release.runcloud.io&lt;/code&gt;. That repository told us most of what we needed to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RunCloud only patches 8.2 and newer, as their &lt;a href="https://community.runcloud.io/t/critical-php-vulnerabilities-cve-2026-109618-cve-2026-109619-what-runcloud-is-doing/4814" rel="noopener noreferrer"&gt;community statement&lt;/a&gt; says.&lt;/li&gt;
&lt;li&gt;The 18.04 repository stopped getting builds at 8.3.17 and 8.2.27, so even the supported branches would not get a fix there.&lt;/li&gt;
&lt;li&gt;The 16.04 repository tops out at PHP 8.0. No branch there would ever be patched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many of our older WordPress sites are pinned to PHP 7.3 or 7.4 on purpose: old page-builder themes and plugins that break on PHP 8. Moving them is a project, not an evening. We counted 71 FPM pools, and 62 of them had no official patch coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: make sure the mitigation is actually active
&lt;/h2&gt;

&lt;p&gt;Before building anything, check the stopgap. RunCloud dropped &lt;code&gt;/etc/phpXXrc/conf.d/99-cve-2026-109618.ini&lt;/code&gt; on every server in the morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PHP-FPM masters had been running since days before that, so the setting was not active in a single worker. A new &lt;code&gt;conf.d&lt;/code&gt; file only applies after FPM reloads. We tested the config and reloaded every running FPM service, along these lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running &lt;span class="nt"&gt;--no-legend&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/php[0-9]+rc-fpm/{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="p"&gt;%-fpm.service&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  /RunCloud/Packages/&lt;span class="nv"&gt;$v&lt;/span&gt;/sbin/php-fpm &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; /etc/&lt;span class="nv"&gt;$v&lt;/span&gt;/php-fpm.conf &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; systemctl reload &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we confirmed it through a real pool, for every PHP version that had a site that would serve a stray PHP file (all but two; for those we checked the ini file and the worker restart): a temporary PHP file that prints &lt;code&gt;PHP_VERSION&lt;/code&gt; and &lt;code&gt;ini_get('session.upload_progress.enabled')&lt;/code&gt;, fetched through the local nginx with &lt;code&gt;curl --resolve&lt;/code&gt;, and deleted right after. If your host says "we applied the mitigation", ask whether they also reloaded PHP-FPM.&lt;/p&gt;

&lt;p&gt;That is a mitigation, not a fix. So, the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the patches come from
&lt;/h2&gt;

&lt;p&gt;Nobody ships PHP 5.6 to 8.1 builds for Ubuntu 16.04 to 20.04 anymore. But the source patches exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP 7.4, 8.0 and 8.1:&lt;/strong&gt; the PHP project published minimal backports in php-src branches named &lt;code&gt;PHP-7.4-CVE-2026-109618-109619&lt;/code&gt; (and the same for 8.0 and 8.1). For 7.4 it is a single commit, &lt;code&gt;712e60f9ed&lt;/code&gt; "Avoid state corruption via bailout" by Arnaud Le Blanc, co-authored by Ilija Tovilo; for 8.0 &lt;code&gt;82f8f31b16&lt;/code&gt;, for 8.1 &lt;code&gt;1d7dec178a&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP 5.6 to 7.3:&lt;/strong&gt; Ondřej Surý ported the same fix for his &lt;a href="https://packages.sury.org/php/" rel="noopener noreferrer"&gt;packages.sury.org&lt;/a&gt; packages. His builds only target Ubuntu 22.04, 24.04 and Debian, but the Debian source packages contain the patches as plain files in &lt;code&gt;debian/patches/&lt;/code&gt;. For 7.0 to 7.3 the fix needs one earlier backport first, &lt;code&gt;backport ZSTR_MAX_LEN&lt;/code&gt; by Remi Collet, a macro in &lt;code&gt;zend_string.h&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PHP&lt;/th&gt;
&lt;th&gt;Patches applied on top of the php.net tarball&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5.6.40&lt;/td&gt;
&lt;td&gt;Surý &lt;code&gt;0261-Avoid-state-corruption-via-bailout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.0.33&lt;/td&gt;
&lt;td&gt;Surý &lt;code&gt;0211-backport-ZSTR_MAX_LEN&lt;/code&gt; + &lt;code&gt;0230-Avoid-state-corruption-via-bailout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.1.33&lt;/td&gt;
&lt;td&gt;Surý &lt;code&gt;0167&lt;/code&gt; (ZSTR_MAX_LEN) + &lt;code&gt;0186&lt;/code&gt; (bailout)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.2.34&lt;/td&gt;
&lt;td&gt;Surý &lt;code&gt;0135&lt;/code&gt; (ZSTR_MAX_LEN) + &lt;code&gt;0159&lt;/code&gt; (bailout)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.3.33&lt;/td&gt;
&lt;td&gt;Surý &lt;code&gt;0107&lt;/code&gt; (ZSTR_MAX_LEN) + &lt;code&gt;0139&lt;/code&gt; (bailout)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.4.33&lt;/td&gt;
&lt;td&gt;php-src &lt;code&gt;712e60f9ed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.0.30&lt;/td&gt;
&lt;td&gt;php-src &lt;code&gt;82f8f31b16&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.1.34&lt;/td&gt;
&lt;td&gt;php-src &lt;code&gt;1d7dec178a&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the patch does, in short: on an out-of-memory error the worker now calls &lt;code&gt;_exit(1)&lt;/code&gt; instead of unwinding with &lt;code&gt;longjmp&lt;/code&gt; (FPM simply starts a fresh worker), the session upload-progress callback is switched off in code (&lt;code&gt;if (!PS(rfc1867_enabled) || true)&lt;/code&gt;), and request state is reset harder in &lt;code&gt;main.c&lt;/code&gt;, &lt;code&gt;SAPI.c&lt;/code&gt;, &lt;code&gt;rfc1867.c&lt;/code&gt; and &lt;code&gt;php_variables.c&lt;/code&gt;. It does not change &lt;code&gt;ZEND_MODULE_API_NO&lt;/code&gt;. That matters: the existing compiled extensions on the server can keep loading without a rebuild. In our tests every build loaded the server's full set of extensions, with the same module count and no startup warnings.&lt;/p&gt;

&lt;p&gt;Every patch applied with &lt;code&gt;patch -p1&lt;/code&gt; against the official tarballs without a single failed hunk; some hunks needed a line offset, none needed fuzz. PHP 5.5 was the exception: hunk 10 of the 5.6 patch fails in &lt;code&gt;Zend/zend_alloc.c&lt;/code&gt;. No server used 5.5, so we disabled it instead of patching (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Build on the same Ubuntu, without root or Docker
&lt;/h2&gt;

&lt;p&gt;A PHP binary built on a modern system will not run on Ubuntu 16.04: newer glibc, newer OpenSSL, newer ICU. The binary has to be built against the same libraries the server has. The cleanest way is to build inside the same Ubuntu release.&lt;/p&gt;

&lt;p&gt;We used the official &lt;code&gt;ubuntu-base&lt;/code&gt; root filesystems (16.04.6, 18.04.5, 20.04.5, 22.04.5) from &lt;code&gt;cdimage.ubuntu.com&lt;/code&gt;, checked against their SHA-256 sums, and entered them as a rootless chroot with a user namespace. No sudo, no Docker daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# enter.sh &amp;lt;rootfs-dir&amp;gt; &amp;lt;command...&amp;gt;&lt;/span&gt;
&lt;span class="nv"&gt;HERE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;R&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift
exec &lt;/span&gt;unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; &lt;span class="nt"&gt;--map-users&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto &lt;span class="nt"&gt;--map-groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nt"&gt;--pid&lt;/span&gt; &lt;span class="nt"&gt;--fork&lt;/span&gt; &lt;span class="nt"&gt;--kill-child&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERE&lt;/span&gt;&lt;span class="s2"&gt;/_inner.sh"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# _inner.sh: runs as "root" inside the new namespaces&lt;/span&gt;
&lt;span class="nv"&gt;R&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift
&lt;/span&gt;mount &lt;span class="nt"&gt;--rbind&lt;/span&gt; /dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/dev"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;mount &lt;span class="nt"&gt;-t&lt;/span&gt; proc proc &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/proc"&lt;/span&gt;
mount &lt;span class="nt"&gt;-t&lt;/span&gt; tmpfs tmpfs &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/tmp"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; /etc/resolv.conf &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/etc/resolv.conf"&lt;/span&gt;
&lt;span class="nb"&gt;exec chroot&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /usr/bin/env &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/root &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;LANG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C.UTF-8 &lt;span class="nv"&gt;DEBIAN_FRONTEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;noninteractive &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--map-users=auto&lt;/code&gt; needs a range for your user in &lt;code&gt;/etc/subuid&lt;/code&gt; and &lt;code&gt;/etc/subgid&lt;/code&gt;; without it, apt cannot switch to its &lt;code&gt;_apt&lt;/code&gt; user. It also needs &lt;code&gt;unshare&lt;/code&gt; from util-linux 2.38 or newer and &lt;code&gt;newuidmap&lt;/code&gt; (package &lt;code&gt;uidmap&lt;/code&gt;). Inside the chroot, &lt;code&gt;apt-get install&lt;/code&gt; works as usual. The 16.04 build deps were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; build-essential autoconf bison re2c pkg-config &lt;span class="se"&gt;\&lt;/span&gt;
  ca-certificates curl xz-utils patch libxml2-dev libssl-dev libsqlite3-dev zlib1g-dev &lt;span class="se"&gt;\&lt;/span&gt;
  libcurl4-openssl-dev libgmp-dev libffi-dev libenchant-dev libsystemd-dev libedit-dev &lt;span class="se"&gt;\&lt;/span&gt;
  libicu-dev libpcre3-dev libselinux1-dev libfcgi0ldbl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives each release its original toolchain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ubuntu&lt;/th&gt;
&lt;th&gt;GCC&lt;/th&gt;
&lt;th&gt;OpenSSL&lt;/th&gt;
&lt;th&gt;ICU&lt;/th&gt;
&lt;th&gt;PHP versions built&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16.04&lt;/td&gt;
&lt;td&gt;5.4.0&lt;/td&gt;
&lt;td&gt;1.0.2g&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;5.6, 7.0, 7.1, 7.2, 7.3, 7.4, 8.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18.04&lt;/td&gt;
&lt;td&gt;7.5.0&lt;/td&gt;
&lt;td&gt;1.1.1&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;7.0 to 7.4, 8.0, 8.1, plus 8.2.35 and 8.3.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20.04&lt;/td&gt;
&lt;td&gt;9.4.0&lt;/td&gt;
&lt;td&gt;1.1.1&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;7.2, 7.3, 7.4, 8.0, 8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22.04&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;3.0 (and RunCloud's 1.1.1b)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;7.4, 8.0, 8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 18.04 we also built the official 8.2.35 and 8.3.36 releases, because RunCloud will not ship them for that release anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use RunCloud's own configure flags
&lt;/h2&gt;

&lt;p&gt;The goal is a drop-in replacement, so the new binary must be built exactly like RunCloud's. You do not have to guess: &lt;code&gt;php -i&lt;/code&gt; prints the full configure command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/RunCloud/Packages/php74rc/bin/php &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^Configure Command'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^Configure Command =&amp;gt;  //'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PHP 7.4 that starts like this (shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'./configure' '--build=x86_64-linux-gnu' '--prefix=/RunCloud/Packages/php74rc'
'--sysconfdir=/etc/php74rc' '--with-config-file-path=/etc/php74rc'
'--with-config-file-scan-dir=/etc/php74rc/conf.d' '--disable-cgi' '--disable-all'
'--with-openssl=yes' '--with-password-argon2' '--enable-fpm' '--enable-cli'
'--enable-pdo=shared' '--enable-mysqlnd=shared' '--enable-xml=shared' ...
'--with-fpm-systemd' '--with-gmp' '--with-libedit=shared,/usr'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out. RunCloud builds a small core with &lt;code&gt;--disable-all&lt;/code&gt; and ships the rest (mysqli, gd, intl, curl, and so on) as separate shared extensions. And &lt;code&gt;--with-password-argon2&lt;/code&gt; links against RunCloud's own &lt;code&gt;libargon2&lt;/code&gt; package on most releases (not all, see below), which you can download from the same repository for each Ubuntu release (&lt;code&gt;release.runcloud.io/dists/&amp;lt;codename&amp;gt;/main/binary-amd64/Packages.gz&lt;/code&gt;, checked against the SHA-256 in that index) and install in the chroot with &lt;code&gt;dpkg -i&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We saved the configure command per version and Ubuntu release, and one generic script built everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# build-generic.sh &amp;lt;full-version&amp;gt; &amp;lt;rc-tag e.g. 74&amp;gt; &amp;lt;patch|none&amp;gt; [mode] [ubuntu codename]   (shortened)&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;FV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;PATCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;DIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;5&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;xenial&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /root &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; php-&lt;span class="nv"&gt;$FV&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xJf&lt;/span&gt; /src/php-&lt;span class="nv"&gt;$FV&lt;/span&gt;.tar.xz &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;php-&lt;span class="nv"&gt;$FV&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PATCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; none &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;patch &lt;span class="nt"&gt;-p1&lt;/span&gt; &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &amp;lt; /src/&lt;span class="nv"&gt;$PATCH&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/patch&lt;span class="nv"&gt;$V&lt;/span&gt;.log 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"FAILED|Hunk"&lt;/span&gt; /root/patch&lt;span class="nv"&gt;$V&lt;/span&gt;.log&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; fuzz /root/patch&lt;span class="nv"&gt;$V&lt;/span&gt;.log &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PATCH NEEDS FUZZ, refusing"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; dryrun &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;DRYRUN_OK&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  patch &lt;span class="nt"&gt;-p1&lt;/span&gt; &amp;lt; /src/&lt;span class="nv"&gt;$PATCH&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# only needed if a patch touches configure.ac (none of ours did)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"configure.ac"&lt;/span&gt; /src/&lt;span class="nv"&gt;$PATCH&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./buildconf &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /src/configure-php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-&lt;span class="nv"&gt;$DIST&lt;/span&gt;.txt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/configure&lt;span class="nv"&gt;$V&lt;/span&gt;.log 2&amp;gt;&amp;amp;1
make &lt;span class="nt"&gt;-j&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;nproc&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/make&lt;span class="nv"&gt;$V&lt;/span&gt;.log 2&amp;gt;&amp;amp;1
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /stage&lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nb"&gt;install &lt;/span&gt;&lt;span class="nv"&gt;INSTALL_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/stage&lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/install&lt;span class="nv"&gt;$V&lt;/span&gt;.log 2&amp;gt;&amp;amp;1
&lt;span class="nv"&gt;P&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/stage&lt;span class="nv"&gt;$V&lt;/span&gt;/RunCloud/Packages/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc
strip &lt;span class="nt"&gt;--strip-unneeded&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/bin/php &lt;span class="nv"&gt;$P&lt;/span&gt;/sbin/php-fpm
&lt;span class="nb"&gt;cd&lt;/span&gt; /stage&lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; /src/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-&lt;span class="nv"&gt;$FV&lt;/span&gt;&lt;span class="nt"&gt;-php2shell-&lt;/span&gt;&lt;span class="nv"&gt;$DIST&lt;/span&gt;.tar.gz RunCloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make install INSTALL_ROOT=/stage74&lt;/code&gt; installs into a staging directory with the full &lt;code&gt;/RunCloud/Packages/php74rc&lt;/code&gt; path underneath, so the tarball unpacks to the right place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pitfalls we hit
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gmp.h&lt;/code&gt; not found on PHP 5.6.&lt;/strong&gt; On multiarch Ubuntu the header lives in &lt;code&gt;/usr/include/x86_64-linux-gnu/&lt;/code&gt;. A symlink fixes it: &lt;code&gt;ln -sf /usr/include/x86_64-linux-gnu/gmp.h /usr/include/gmp.h&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surý's 7.0 to 7.3 patches need &lt;code&gt;ZSTR_MAX_LEN&lt;/code&gt;.&lt;/strong&gt; The bailout patch uses that macro, which 7.0 to 7.3 do not have. Apply the earlier backport first, then the bailout patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On 22.04, RunCloud's 7.4 and 8.0 link against OpenSSL 1.1.1b,&lt;/strong&gt; which RunCloud ships as its own &lt;code&gt;libssl1-rc&lt;/code&gt; package under &lt;code&gt;/RunCloud/Packages/libssl1-rc&lt;/code&gt;. Ubuntu 22.04 itself only has OpenSSL 3. Install that package in the chroot and build against it, like the original.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The version number does not change.&lt;/strong&gt; A patched 7.4 still says &lt;code&gt;PHP 7.4.33&lt;/code&gt;. Do not use &lt;code&gt;php -v&lt;/code&gt; to prove anything; check the behavior and the file hash (below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test in the chroot before anything touches a server
&lt;/h2&gt;

&lt;p&gt;For each build we copied the server's real extension directory and &lt;code&gt;conf.d&lt;/code&gt; into the chroot, put the new core next to them and checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ldd&lt;/code&gt; on every &lt;code&gt;.so&lt;/code&gt;: no "not found" libraries.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;php -m&lt;/code&gt;: the same number of modules as on the server (for example 67 for 7.4 on 16.04), and no startup warnings.&lt;/li&gt;
&lt;li&gt;A smoke test: mysqli, gd, curl, mbstring and &lt;code&gt;password_hash&lt;/code&gt; with the default algorithm.&lt;/li&gt;
&lt;li&gt;A real PHP-FPM pool with &lt;code&gt;memory_limit = 16M&lt;/code&gt;, driven with &lt;code&gt;cgi-fcgi&lt;/code&gt;: one normal request, one request that allocates 64 MB, then another normal request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The out-of-memory case shows the patch at work. Here is the patched 7.4.33 on 16.04, with a shutdown function registered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php -d memory_limit=8M -r 'register_shutdown_function(function(){echo "shutdown-ran\n";}); $a=str_repeat("x", 64*1024*1024);'
Fatal error: Allowed memory size of 8388608 bytes exhausted (tried to allocate 67108896 bytes) in Command line code on line 1
$ echo $?
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shutdown function never runs and the process exits with 1. In FPM, that worker dies and the master starts a new one; the third request in the pool test was served normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy: swap only the core, keep a way back
&lt;/h2&gt;

&lt;p&gt;We did not replace the whole RunCloud package. Only the files the patch touches are swapped: &lt;code&gt;bin/php&lt;/code&gt;, &lt;code&gt;sbin/php-fpm&lt;/code&gt; and the six extensions that are built from the core source as shared modules (&lt;code&gt;mysqlnd&lt;/code&gt;, &lt;code&gt;pdo&lt;/code&gt;, &lt;code&gt;xml&lt;/code&gt;, &lt;code&gt;xmlreader&lt;/code&gt;, &lt;code&gt;xmlwriter&lt;/code&gt;, &lt;code&gt;readline&lt;/code&gt;). RunCloud's other extensions and all &lt;code&gt;conf.d&lt;/code&gt; files stay as they are. The deploy script, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# deploy-core.sh &amp;lt;74|83|...&amp;gt; &amp;lt;tarball&amp;gt; [rollback]   (run as root on the server)&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;TB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;deploy&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;P&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/RunCloud/Packages/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc
&lt;span class="nv"&gt;EXTDIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/lib/php/extensions/no-debug-non-zts-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/root/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-orig-before-php2shell
&lt;span class="nv"&gt;FILES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bin/php sbin/php-fpm"&lt;/span&gt;
&lt;span class="nv"&gt;CORE_EXT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mysqlnd.so pdo.so xml.so xmlreader.so xmlwriter.so readline.so"&lt;/span&gt;

&lt;span class="c"&gt;# (rollback branch: put $BK back by rename, apt-mark unhold, restart FPM)&lt;/span&gt;

&lt;span class="nv"&gt;T&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nv"&gt;$T&lt;/span&gt;
&lt;span class="nv"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$T&lt;/span&gt;/RunCloud/Packages/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc
&lt;span class="c"&gt;# 1) back up the originals once, never overwrite them&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;$BK&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$BK&lt;/span&gt;/bin &lt;span class="nv"&gt;$BK&lt;/span&gt;/sbin &lt;span class="nv"&gt;$BK&lt;/span&gt;/ext
  &lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$FILES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="nv"&gt;$BK&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
  for &lt;/span&gt;e &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$CORE_EXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$EXTDIR&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nv"&gt;$EXTDIR&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="nv"&gt;$BK&lt;/span&gt;/ext/&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
fi&lt;/span&gt;
&lt;span class="c"&gt;# 2) the new binary must start cleanly with the real php.ini&lt;/span&gt;
&lt;span class="nv"&gt;$N&lt;/span&gt;/bin/php &lt;span class="nt"&gt;-c&lt;/span&gt; /etc/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc/php.ini &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&lt;span class="nv"&gt;$T&lt;/span&gt;/err &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; warning &lt;span class="nv"&gt;$T&lt;/span&gt;/err &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ABORT: startup warnings"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# 3) swap by rename, so running processes keep their old inode&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$FILES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 755 &lt;span class="nv"&gt;$N&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt;.new &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt;.new &lt;span class="nv"&gt;$P&lt;/span&gt;/&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
for &lt;/span&gt;e &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$CORE_EXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$N&lt;/span&gt;/lib/php/extensions/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 755 &lt;span class="nv"&gt;$N&lt;/span&gt;/lib/php/extensions/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="nv"&gt;$EXTDIR&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt;.new &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$EXTDIR&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt;.new &lt;span class="nv"&gt;$EXTDIR&lt;/span&gt;/&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="c"&gt;# 4) stop RunCloud from putting the unpatched package back&lt;/span&gt;
apt-mark hold php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="c"&gt;# 5) service inactive (no pools)? sanity-check the binaries and exit without starting it&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; systemctl is-active &lt;span class="nt"&gt;--quiet&lt;/span&gt; php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-fpm&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="nv"&gt;$P&lt;/span&gt;/sbin/php-fpm &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$P&lt;/span&gt;/bin/php &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;bash &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="nv"&gt;$TB&lt;/span&gt; rollback
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# 6) config test, restart (a new binary needs a restart, not a reload), roll back on failure&lt;/span&gt;
&lt;span class="nv"&gt;$P&lt;/span&gt;/sbin/php-fpm &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; /etc/php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc/php-fpm.conf &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;bash &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="nv"&gt;$TB&lt;/span&gt; rollback
systemctl restart php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-fpm&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2
systemctl is-active php&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;rc-fpm &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;bash &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="nv"&gt;$V&lt;/span&gt; &lt;span class="nv"&gt;$TB&lt;/span&gt; rollback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Installed PHP versions without any pool got the same new binaries, but their FPM service was not started. Someone switching a site to that version in the panel later would otherwise land on an unpatched PHP.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;apt-mark hold&lt;/code&gt; is the important trade-off. It keeps RunCloud's agent from reinstalling the old package, but it also means these versions get no RunCloud updates at all from now on. For 8.2 and newer on 20.04 and 22.04 we did not hold anything: RunCloud released 8.2.35, 8.3.36, 8.4.27 and 8.5.12 for those Ubuntu releases later that evening, and we installed them with &lt;code&gt;apt-get install --only-upgrade&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify: same pages, new binary
&lt;/h2&gt;

&lt;p&gt;Three checks per server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTP before and after.&lt;/strong&gt; Before each deploy we requested every domain on the server through the local nginx (&lt;code&gt;curl --resolve domain:443:127.0.0.1&lt;/code&gt;) and saved the status code per domain. After the deploy we ran it again and diffed the two lists. No differences on any server. For a few sites per PHP version we also checked &lt;code&gt;/wp-login.php&lt;/code&gt; and &lt;code&gt;/wp-admin/&lt;/code&gt; (200 and 302).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The running process uses the new file.&lt;/strong&gt; Compare the hash of the binary on disk with the one the FPM master actually runs: &lt;code&gt;sha256sum /RunCloud/Packages/php74rc/sbin/php-fpm /proc/$(systemctl show -p MainPID php74rc-fpm | cut -d= -f2)/exe&lt;/code&gt;. (We avoid &lt;code&gt;systemctl show --value&lt;/code&gt;: that option does not exist in systemd 229, which is what Ubuntu 16.04 has.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pool reports what we expect.&lt;/strong&gt; The same temporary probe file as for the mitigation: PHP version, &lt;code&gt;upload_progress&lt;/code&gt; off, SAPI &lt;code&gt;fpm-fcgi&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To be sure everything survives a restart, we rebooted the six servers we had patched that night one by one just before midnight and repeated the HTTP comparison: 184 domains, no differences. The 22.04 server's 7.4 to 8.1 builds went out right after that. By the end of the night every installed PHP version from 5.6 to 8.5 on those six servers was patched; only the unused 5.5 was disabled instead. The seventh server, also on 18.04, followed the next day with the same 18.04 builds and the same checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one we did not patch: PHP 5.5
&lt;/h2&gt;

&lt;p&gt;One server still had PHP 5.5.38 installed, without any site using it. No patch exists, and the 5.6 patch does not apply. Removing the package with apt would also remove RunCloud's agent, because it depends on it. So we moved the &lt;code&gt;php&lt;/code&gt; and &lt;code&gt;php-fpm&lt;/code&gt; binaries out of the way, put the package on hold, and added a systemd drop-in with &lt;code&gt;ConditionPathExists&lt;/code&gt; so the service cannot start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use our builds, or build them yourself
&lt;/h2&gt;

&lt;p&gt;This article got more readers than we expected, and several of them run the same setup. So we published everything in one repository: &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds" rel="noopener noreferrer"&gt;github.com/seppegadeyne/php2shell-runcloud-builds&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The release&lt;/strong&gt; &lt;a href="https://github.com/seppegadeyne/php2shell-runcloud-builds/releases/tag/v2026.10.21" rel="noopener noreferrer"&gt;&lt;code&gt;v2026.10.21&lt;/code&gt;&lt;/a&gt; has the 25 tarballs we deployed, with a &lt;code&gt;SHA256SUMS&lt;/code&gt; file: 16.04 (5.6 to 8.0), 18.04 (7.0 to 8.1, plus the official 8.2.35 and 8.3.36), 20.04 (7.2 to 8.1), 22.04 (7.4 to 8.1) and 24.04 (8.1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deploy-core.sh&lt;/code&gt;&lt;/strong&gt; is the full version of the script above, in English, with the rollback branch and an extension API check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;patches/&lt;/code&gt;&lt;/strong&gt; has the eight patches from the table, unchanged, with their origin and authors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;configure/&lt;/code&gt;&lt;/strong&gt; has RunCloud's configure command per PHP version and Ubuntu release, copied from &lt;code&gt;php -i&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;generic/&lt;/code&gt;&lt;/strong&gt; builds a patched PHP for servers without RunCloud, from the server's own configure command (see below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;build.sh&lt;/code&gt;&lt;/strong&gt; rebuilds any of them from scratch:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/seppegadeyne/php2shell-runcloud-builds.git
&lt;span class="nb"&gt;cd &lt;/span&gt;php2shell-runcloud-builds
./build.sh focal          &lt;span class="c"&gt;# every version for 20.04&lt;/span&gt;
./build.sh xenial 56 74   &lt;span class="c"&gt;# only php56rc and php74rc for 16.04&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It downloads the &lt;code&gt;ubuntu-base&lt;/code&gt; root filesystem and the php.net tarballs, checks them against pinned SHA-256 sums, fetches RunCloud's &lt;code&gt;libargon2&lt;/code&gt; or &lt;code&gt;libssl1-rc&lt;/code&gt; package (checked against RunCloud's own index), and builds in the rootless chroot. A patch that does not apply cleanly, or would need fuzz, stops the build. Before publishing, we rebuilt versions for every Ubuntu release from an empty directory with this script and compared them with the deployed tarballs: same files, same linked libraries.&lt;/p&gt;

&lt;p&gt;One detail we only wrote down while cleaning up: RunCloud does not use the same &lt;code&gt;libargon2&lt;/code&gt; everywhere. On 18.04, PHP 7.2 to 8.0 link RunCloud's own &lt;code&gt;libargon2.so.1&lt;/code&gt;, while 8.1 and newer link Ubuntu's &lt;code&gt;libargon2.so.0&lt;/code&gt;. The build script handles that per version; if you build by hand, check the original with &lt;code&gt;readelf -d /RunCloud/Packages/php81rc/sbin/php-fpm | grep argon2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The usual warning applies: these are unofficial builds, not affiliated with RunCloud or the PHP project, provided as is. Match both the PHP version and the Ubuntu release, read the script before you run it as root, and try one server first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not on RunCloud: the generic mode
&lt;/h3&gt;

&lt;p&gt;The release tarballs only fit RunCloud. They unpack to &lt;code&gt;/RunCloud/Packages/phpXXrc&lt;/code&gt; and link RunCloud's own libraries. But the problem is not specific to RunCloud. Plenty of servers run a PHP that someone compiled into &lt;code&gt;/usr/local&lt;/code&gt; or &lt;code&gt;/opt&lt;/code&gt; years ago, or a panel's own build, on an Ubuntu release that no longer gets PHP fixes. For those, the repository now has a &lt;code&gt;generic/&lt;/code&gt; directory. It follows the same steps as above, but it starts from your server instead of from RunCloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) on the server (read-only): record every PHP CLI, FPM and CGI binary&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /root/php2shell &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /root/php2shell
&lt;span class="nv"&gt;RAW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://raw.githubusercontent.com/seppegadeyne/php2shell-runcloud-builds/main/generic
curl &lt;span class="nt"&gt;-fLO&lt;/span&gt; &lt;span class="nv"&gt;$RAW&lt;/span&gt;/collect.sh
curl &lt;span class="nt"&gt;-fLO&lt;/span&gt; &lt;span class="nv"&gt;$RAW&lt;/span&gt;/deploy.sh             &lt;span class="c"&gt;# needed in step 3&lt;/span&gt;
bash collect.sh                      &lt;span class="c"&gt;# or: bash collect.sh /usr/local/sbin/php-fpm&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; php2shell-profile-&amp;lt;host&amp;gt;-&amp;lt;date&amp;gt;.tar.gz&lt;/span&gt;

&lt;span class="c"&gt;# 2) on the build machine (same requirements as build.sh)&lt;/span&gt;
generic/build.sh php2shell-profile-web1-20261024-120000.tar.gz
&lt;span class="c"&gt;# -&amp;gt; out/generic/web1/php7.4.33-php2shell-&amp;lt;job&amp;gt;.tar.gz + SHA256SUMS + SKIPPED.txt&lt;/span&gt;
&lt;span class="c"&gt;# copy the tarball and SHA256SUMS to /root/php2shell on the server&lt;/span&gt;

&lt;span class="c"&gt;# 3) back on the server, as root, in /root/php2shell&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;--ignore-missing&lt;/span&gt; SHA256SUMS
bash deploy.sh php7.4.33-php2shell-&amp;lt;job&amp;gt;.tar.gz check      &lt;span class="c"&gt;# test only&lt;/span&gt;
bash deploy.sh php7.4.33-php2shell-&amp;lt;job&amp;gt;.tar.gz            &lt;span class="c"&gt;# swap&lt;/span&gt;
bash deploy.sh php7.4.33-php2shell-&amp;lt;job&amp;gt;.tar.gz rollback   &lt;span class="c"&gt;# undo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;collect.sh&lt;/code&gt; changes nothing.&lt;/strong&gt; It looks for PHP binaries in the usual places (&lt;code&gt;/usr/local&lt;/code&gt;, &lt;code&gt;/opt&lt;/code&gt;, cPanel's &lt;code&gt;ea-php&lt;/code&gt;, Plesk, LiteSpeed, RunCloud), or takes the paths you give it. For each binary, it saves the configure command from &lt;code&gt;php -i&lt;/code&gt;, the extension API number, the built-in modules, the linked libraries, the SHA-256 and the package that owns the file. It also saves the server's &lt;code&gt;/etc/os-release&lt;/code&gt;. It warns about libraries linked from outside &lt;code&gt;/lib&lt;/code&gt; and &lt;code&gt;/usr/lib&lt;/code&gt;. What the generic mode cannot rebuild, it reports and skips: an Apache &lt;code&gt;mod_php&lt;/code&gt; and LiteSpeed's &lt;code&gt;lsphp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;build.sh&lt;/code&gt; builds the newest release of the same branch&lt;/strong&gt; with that configure command, verbatim, inside the server's own Ubuntu release. A 7.4.x becomes 7.4.33 with the patch; 8.2 to 8.5 get the fixed php.net release. Staying in the same branch keeps the extension API the same, so the server's other shared extensions keep loading. The build stops if the extension API differs or a built-in module is missing. Build dependencies come from the release's own PHP source package (&lt;code&gt;apt-get build-dep&lt;/code&gt;), plus a list of libraries that configure flags often ask for. Libraries the server keeps in &lt;code&gt;/opt&lt;/code&gt; go in with &lt;code&gt;--extra &amp;lt;tarball&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deploy.sh&lt;/code&gt; refuses a server that changed.&lt;/strong&gt; Every target binary must still have the SHA-256 from the profile. The new CLI must start with the server's own &lt;code&gt;php.ini&lt;/code&gt; without a warning, and for patched builds, an out-of-memory error must no longer run shutdown functions (the same test as above). Then it backs up the originals once to &lt;code&gt;/root/php2shell-generic-backup/&amp;lt;job&amp;gt;/&lt;/code&gt;, swaps by rename, puts an owning package on hold, and config-tests and restarts every systemd PHP-FPM service whose main process runs a replaced binary. A failed test or start rolls everything back. An FPM outside systemd is not restarted; restart it yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It leaves two kinds of builds alone on purpose. Distribution packages (Ubuntu's own &lt;code&gt;php8.1&lt;/code&gt;, Debian, Surý) do not show a configure command in &lt;code&gt;php -i&lt;/code&gt;. For those, &lt;code&gt;collect.sh&lt;/code&gt; searches the package changelog for the two CVE numbers and reports whether the vendor already shipped the fix. The answer there is &lt;code&gt;apt-get install --only-upgrade&lt;/code&gt; or a question to the vendor, not a rebuild. And the SAPIs it cannot rebuild: move &lt;code&gt;mod_php&lt;/code&gt; sites to PHP-FPM first, and patch LiteSpeed's &lt;code&gt;lsphp&lt;/code&gt; through LiteSpeed's own packages.&lt;/p&gt;

&lt;p&gt;One detail came up while testing. The 8.1 fix also changes &lt;code&gt;ext/opcache&lt;/code&gt;. If the server loads a shared &lt;code&gt;opcache.so&lt;/code&gt;, keeping the old file would leave unpatched code in use, so the build has to produce a new &lt;code&gt;opcache.so&lt;/code&gt; from the patched source. A configure command that builds no opcache is refused, with a hint to add &lt;code&gt;--enable-opcache=shared&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Only Ubuntu 16.04 to 24.04 is automated. For another distribution, you can build by hand in a matching root filesystem with &lt;code&gt;generic/chroot-build.sh&lt;/code&gt;; &lt;code&gt;generic/chroot-deps.sh&lt;/code&gt; shows which libraries to install, but it uses Ubuntu package names. And unlike the RunCloud builds, the generic mode has not run on a production server yet. We tested it in chroots on our build host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a PHP 7.4 compiled into &lt;code&gt;/usr/local&lt;/code&gt; with a shared &lt;code&gt;opcache.so&lt;/code&gt; and &lt;code&gt;php-cgi&lt;/code&gt; on Ubuntu 20.04: collect, build 7.4.33 with the patch, check, deploy, PHP-FPM answering a FastCGI request with the new binary, out-of-memory exit 1, and a rollback to the original SHA-256&lt;/li&gt;
&lt;li&gt;the same for a PHP 8.1.30 on Ubuntu 22.04, built as 8.1.34 with the patch, including the rebuilt &lt;code&gt;opcache.so&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ubuntu 22.04's own &lt;code&gt;php8.1&lt;/code&gt; package, which it skipped because the changelog shows the vendor fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So run &lt;code&gt;check&lt;/code&gt; first, and try one server before the rest.&lt;/p&gt;

&lt;p&gt;The whole procedure, with real output, the checks and how to verify the running binary, is in its own article: &lt;a href="https://dev.to/seppegadeyne/patch-php2shell-on-php-74-and-81-compiled-from-source-without-a-panel-j60"&gt;Patch PHP2Shell on PHP 7.4 and 8.1 compiled from source, without a panel&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not fix
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The next PHP bug.&lt;/strong&gt; Held packages get nothing from RunCloud anymore. The scripts are in the repository now, so the next round is a rebuild, not a research project. But it stays our job. The next one is already announced: on October 23, PHP published &lt;a href="https://thehackernews.com/2026/10/urgent-php-warns-of-another-unpatched.html" rel="noopener noreferrer"&gt;CVE-2026-115560&lt;/a&gt;, a separate session bug that the October 21 releases do not fix. It only affects applications that switch &lt;code&gt;session.serialize_handler&lt;/code&gt; to &lt;code&gt;php&lt;/code&gt; with &lt;code&gt;ini_set()&lt;/code&gt; before &lt;code&gt;session_start()&lt;/code&gt;, and the mitigation is the same &lt;code&gt;session.upload_progress.enabled = Off&lt;/code&gt;. We checked it on every PHP version on our servers. The fix comes in the next regular release; when it lands, we will rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old operating systems.&lt;/strong&gt; Ubuntu 16.04 to 20.04 are past standard support. A patched PHP on an unpatched OS is still an old server. Our real fix is moving the old sites to a new Ubuntu 24.04 server, where Surý's packages cover the old versions we need (5.6, 7.0, 7.3, 7.4 and 8.1) with this fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mitigations stay on.&lt;/strong&gt; &lt;code&gt;session.upload_progress.enabled = Off&lt;/code&gt;, Cloudflare's managed rules for proxied domains, and phpMyAdmin behind an IP allowlist. A patch is not a reason to remove them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The approach carries over to any server with its own PHP build on an older Ubuntu: same Ubuntu in a rootless chroot, the server's own configure command from &lt;code&gt;php -i&lt;/code&gt;, upstream or Surý patches, swap only the core, hold the package, and compare the HTTP output before and after. For a self-compiled PHP, the generic mode now does most of those steps for you. The HTTP comparison is still your job.&lt;/p&gt;

&lt;p&gt;For site owners rather than sysadmins, I wrote a plain-language version of what PHP2Shell means for a WordPress site: &lt;a href="https://straffesites.com/en/blog/php-vulnerability-wordpress" rel="noopener noreferrer"&gt;PHP2Shell: what the PHP flaw means for your WordPress site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>devops</category>
      <category>security</category>
      <category>linux</category>
    </item>
    <item>
      <title>Run DeepSeek-V4.1-Flash (748B) locally with Project Maya on one RTX 5090</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:36:02 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/run-deepseek-v41-flash-748b-locally-with-project-maya-on-one-rtx-5090-4i4a</link>
      <guid>https://dev.to/seppegadeyne/run-deepseek-v41-flash-748b-locally-with-project-maya-on-one-rtx-5090-4i4a</guid>
      <description>&lt;p&gt;&lt;strong&gt;DeepSeek-V4.1-Flash-1017 has 748B parameters. Even Project Maya's own 2-3 bit quant is 340 GB with its Engram tables, and my workstation has 128 GB of RAM.&lt;/strong&gt; It still runs here: on one RTX 5090, with a 262K context, at about 21 tokens per second on Maya 1.1.17, and about 25 since 1.1.21. This guide shows how, with &lt;a href="https://github.com/mw00/project-maya" rel="noopener noreferrer"&gt;Project Maya&lt;/a&gt;, and what we measured, including where it is slow.&lt;/p&gt;

&lt;p&gt;Every number below was measured on our own machine between October 19 and 22, 2026. Where a number comes from someone else, I name the source.&lt;/p&gt;

&lt;p&gt;This article belongs to a series about large models on one consumer machine. A &lt;a href="https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j"&gt;side-by-side benchmark of all four models&lt;/a&gt; compares them; the setup guides cover &lt;a href="https://dev.to/seppegadeyne/run-glm-53-flash-321b-locally-with-project-maya-and-exl3-on-one-rtx-5090-24pj"&gt;GLM-5.3-Flash with Project Maya&lt;/a&gt;, &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81"&gt;Qwen3.8-Flash-Next with Strata at 79 tokens per second&lt;/a&gt; and &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d"&gt;Qwen3.8-Flash-Next with EXL3 and TabbyAPI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash-1017" rel="noopener noreferrer"&gt;DeepSeek-V4.1-Flash-1017&lt;/a&gt; is an open-weight model from DeepSeek under the MIT license. Maya's README lists it at 748B parameters. The model card lists a context window of up to 1M tokens and recommends temperature 1.0 and top_p 1.0. Next to its experts it has two Engram tables, which Maya's setup describes as an n-gram memory that the engine reads a few rows from per token.&lt;/p&gt;

&lt;p&gt;The model card does not ship a Jinja chat template, only a Python reference encoder. Maya's engine brings its own prompt handling, so you do not have to deal with that yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Project Maya
&lt;/h2&gt;

&lt;p&gt;Maya started as a runtime for GLM-5.3-Flash, and recent versions add a second engine, &lt;code&gt;maya-ds4&lt;/code&gt;, for this model. The idea is the same: the most-used experts live on the GPU, the next ones in RAM, and the rest are read in place from the model files on the NVMe drive. The full model never has to fit in memory.&lt;/p&gt;

&lt;p&gt;For this model Maya offers several file sets. From its README:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maya-S, Maya's own quant (experts in 2-3 bits), "92.6% the same next token as the original"&lt;/td&gt;
&lt;td&gt;340 GB with the Engram tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unsloth's UD-Q8_K_XL GGUF&lt;/td&gt;
&lt;td&gt;508 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LibertAIDAI's EXL3 2.9 bpw + Engram tables&lt;/td&gt;
&lt;td&gt;414 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LibertAIDAI's EXL3 3.5 bpw + Engram tables&lt;/td&gt;
&lt;td&gt;461 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Maya-S is the smallest by far, so on 128 GB of RAM that is the one to take. We installed it with Maya 1.1.16, which downloaded 178.2 GB of model files, 203 GB of Engram tables from unsloth's GGUF and 8 GB for the DSpark drafter. Since 1.1.17, a fresh setup downloads Maya-S as one set of six files from Maya's own repository, 339.5 GB including the Engram tables, according to the changelog; a 1.1.16 install keeps working as it is. Plan for about 350-400 GB of free NVMe space.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GPU: RTX 5090, 32 GB of VRAM, also driving the desktop.&lt;/li&gt;
&lt;li&gt;CPU: Ryzen 9 9950X3D (16 cores, 32 threads, AVX-512).&lt;/li&gt;
&lt;li&gt;RAM: 128 GB DDR5 as 4x32 GB at 5,600 MT/s.&lt;/li&gt;
&lt;li&gt;Disk: one NVMe drive.&lt;/li&gt;
&lt;li&gt;OS: Arch Linux (Omarchy), NVIDIA driver 615.78, CUDA 13.4.&lt;/li&gt;
&lt;li&gt;Maya 1.1.16 for the first measurements, 1.1.17 (commit &lt;code&gt;8d9a9b5&lt;/code&gt;) for the rest, and 1.1.21 (commit &lt;code&gt;451f9cb&lt;/code&gt;) for the October 22 update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;If you already run Maya for GLM, the same checkout can hold both models. Each gets its own engine, config and port.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mw00/project-maya.git
&lt;span class="nb"&gt;cd &lt;/span&gt;project-maya
./maya.sh &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the setup. It downloads 340 GB or more, so run it in the background and outside any memory-limited scope (see the next section):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./maya.sh &lt;span class="nt"&gt;--setup&lt;/span&gt; &lt;span class="nt"&gt;--plain&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--gpu&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; DeepSeek-V4.1-Flash-1017 &lt;span class="nt"&gt;--ds4-quant&lt;/span&gt; maya-s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--context&lt;/span&gt; 262144 &lt;span class="nt"&gt;--models-dir&lt;/span&gt; ~/Models/maya &lt;span class="nt"&gt;--download-model&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-vision&lt;/span&gt; &lt;span class="nt"&gt;--vram-reserve-mib&lt;/span&gt; 3072 &lt;span class="nt"&gt;--port&lt;/span&gt; 8082 &lt;span class="nt"&gt;--no-start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it did on our machine (Maya 1.1.16):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checked the PC, installed its Python packages and fetched llama.cpp's source at a pinned commit.&lt;/li&gt;
&lt;li&gt;Compiled the &lt;code&gt;maya-ds4&lt;/code&gt; engine for the RTX 5090 (&lt;code&gt;sm_120&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Downloaded the four Maya-S files (178.2 GB), the Engram file (203 GB) and the DSpark drafter (8 GB) with resumable &lt;code&gt;curl&lt;/code&gt; commands it prints first, and checked the files against their SHA-256. The download ran at roughly 40-80 MB/s here, about an hour and a half for everything.&lt;/li&gt;
&lt;li&gt;Indexed every tensor into a &lt;em&gt;pack&lt;/em&gt; next to the model files. The weights stay where they are.&lt;/li&gt;
&lt;li&gt;Wrote &lt;code&gt;maya-ds4.json&lt;/code&gt; and &lt;code&gt;run-maya-ds4.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flags that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--model DeepSeek-V4.1-Flash-1017 --ds4-quant maya-s&lt;/code&gt; picks the model and the smallest quant.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--port 8082&lt;/code&gt; keeps it apart from a GLM install on 8080.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--vram-reserve-mib 3072&lt;/code&gt; leaves 3 GiB of VRAM free. Our desktop runs on the same card, and with full VRAM the screen flickered.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-vision&lt;/code&gt; skips DeepSeek's vision tower (about 1 GB).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start it outside your shell's memory limits
&lt;/h2&gt;

&lt;p&gt;Unlike the GLM engine, &lt;code&gt;maya-ds4&lt;/code&gt; does not pin most of the RAM: it reads the model files in place, so the RAM it uses shows up mostly as page cache (after our benchmark &lt;code&gt;free&lt;/code&gt; reported 20 GiB used and about 100 GiB available). Do not read the available number as free memory: the engine depends on that cache, and anything you start next to it pushes experts out, which then have to come from the SSD again. In practice it uses the whole machine. The load is heavy too. Two Linux defaults get in the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shell inside a systemd scope with a memory limit. Our agent's terminal scopes have &lt;code&gt;memory.max&lt;/code&gt; of 4 GiB; a big process started from there ends up in swap.&lt;/li&gt;
&lt;li&gt;systemd-oomd, which Omarchy enables for the user's &lt;code&gt;app.slice&lt;/code&gt;. It kills a cgroup after 20 seconds above 50% memory pressure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we start it in its own slice, without a memory limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;--collect&lt;/span&gt; &lt;span class="nt"&gt;--slice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;llm.slice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"maya-ds4-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;infinity &lt;span class="se"&gt;\&lt;/span&gt;
  ./run-maya-ds4.sh &lt;span class="nt"&gt;--plain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first start after setup also runs Maya's speed calibration (below) and took about four minutes here; a normal start took 20 seconds. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8082/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": "Say hello in five words."}],
       "max_tokens": 2000, "reasoning_effort": "medium"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard at &lt;code&gt;http://127.0.0.1:8082/#monitor&lt;/code&gt; shows the speeds and where the experts live. Keep the server on &lt;code&gt;127.0.0.1&lt;/code&gt; unless you add &lt;code&gt;--api-key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drafter: measure it on every version
&lt;/h2&gt;

&lt;p&gt;DeepSeek's model comes with a DSpark drafter that proposes a few tokens at a time for the big model to check. Whether that helps depends on your PC, so Maya measures it on the first start with the GPU to itself and keeps the faster option. Upstream, it does not run while more than 1 GiB of VRAM is in use. We raised that threshold locally to 3 GiB because the desktop runs on the same card (see the GLM article), and during setup 4.1 GiB was in use, so the calibration ran at the first start instead.&lt;/p&gt;

&lt;p&gt;The result flipped between two versions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maya version&lt;/th&gt;
&lt;th&gt;Without drafting&lt;/th&gt;
&lt;th&gt;With DSpark&lt;/th&gt;
&lt;th&gt;Accepted drafts&lt;/th&gt;
&lt;th&gt;Kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.1.16&lt;/td&gt;
&lt;td&gt;23.0 tok/s&lt;/td&gt;
&lt;td&gt;20.6 tok/s&lt;/td&gt;
&lt;td&gt;59%&lt;/td&gt;
&lt;td&gt;no drafting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.17&lt;/td&gt;
&lt;td&gt;18.2 tok/s&lt;/td&gt;
&lt;td&gt;21.0 tok/s&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;DSpark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.21&lt;/td&gt;
&lt;td&gt;23.9 tok/s&lt;/td&gt;
&lt;td&gt;23.8 tok/s&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;td&gt;no drafting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running &lt;code&gt;--setup&lt;/code&gt; again after an update drops the stored result, so the next start calibrates again and takes a few minutes longer. Read the new choice from the log instead of assuming the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks
&lt;/h2&gt;

&lt;p&gt;How we measured: a client-side streaming bench against the OpenAI endpoint. Eight runs per setting, alternating a ~400-word Dutch prose prompt and a coding prompt (binary search with tests), greedy decoding, 512 output tokens, median reported. Prefill comes from a needle-in-a-haystack test at the given prompt length, with a different random seed per run so the prefix cache cannot help. No other model runtime or GPU miner ran at the same time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maya-S, 262K context&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;th&gt;Dutch prose / code&lt;/th&gt;
&lt;th&gt;Time to first token&lt;/th&gt;
&lt;th&gt;Prefill&lt;/th&gt;
&lt;th&gt;Needle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.1.16, no drafting (Oct 19)&lt;/td&gt;
&lt;td&gt;24.0&lt;/td&gt;
&lt;td&gt;28.2 / 22.9&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;td&gt;89 tok/s at 247K (46 min)&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.17, DSpark (Oct 20)&lt;/td&gt;
&lt;td&gt;20.7&lt;/td&gt;
&lt;td&gt;20.9 / 19.9&lt;/td&gt;
&lt;td&gt;3.2 s&lt;/td&gt;
&lt;td&gt;634 tok/s at 31K, 685 at 247K (6 min)&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.17, DSpark (Oct 21)&lt;/td&gt;
&lt;td&gt;21.8&lt;/td&gt;
&lt;td&gt;21.2 / 22.4&lt;/td&gt;
&lt;td&gt;3.1 s&lt;/td&gt;
&lt;td&gt;622 tok/s at 31K, 689 at 125K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.21, no drafting (Oct 22)&lt;/td&gt;
&lt;td&gt;24.9&lt;/td&gt;
&lt;td&gt;26.6 / 23.5&lt;/td&gt;
&lt;td&gt;3.2 s&lt;/td&gt;
&lt;td&gt;1,842 tok/s at 31K, 2,570 at 126K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Update, October 22, 2026:&lt;/strong&gt; Maya 1.1.21 made prompt reading about three to four times faster again: 126K tokens now take 49 seconds instead of 182. Its calibration found DSpark no faster than plain decoding on this PC (23.8 against 23.9 tok/s) and kept no drafting. Decode is back at about 25 tokens per second; time to first token stayed at about 3 seconds. The quality bench scored 54 of 54 in both rounds again, at 680 seconds and about 15,900 tokens per round; the VRAM peak was 28.6 GiB. The rest of this article describes 1.1.16 and 1.1.17, except where a row or sentence names 1.1.21.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; On Maya 1.1.27, Maya's short calibration chose DSpark again (22.0 against 19.9 tok/s without a draft). Our own A/B with a restart between runs disagreed. Without a draft it gave 26.3 and 26.7 tok/s and 54 of 54 on the quality bench. With DSpark it gave 23.0 and 23.9 tok/s and 52 of 54. We set &lt;code&gt;--dspark none&lt;/code&gt; and pinned it. The calibration is a few short answers, so measure the choice yourself with a longer bench.&lt;/p&gt;

&lt;p&gt;Three things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version 1.1.17 made long prompts usable.&lt;/strong&gt; Its release notes say it reads prompts in 8K chunks and reads each expert once per layer for long prompts. Here, reading 247K tokens went from 46 minutes to 6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decoding got slower with 1.1.17.&lt;/strong&gt; Our bench measured 24.0 tokens per second on 1.1.16 without drafting and 20.7-21.8 on 1.1.17 with DSpark. Maya's own calibration also measured less without drafting on 1.1.17 (18.2 against 23.0), so this looked like the version, not the drafter. The 1.1.27 A/B in the update above points the other way: there, the drafter cost speed. It is not a clean A/B: the calibration writes three answers of 384 tokens on its own prompts, our bench eight of 512. If it matters for your PC, measure both with your own prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first token went from 1.7 to about 3 seconds&lt;/strong&gt; from 1.1.17 on, with or without DSpark (3.19 s on 1.1.21 without a draft). That is four to eight times that of the other models on this machine. For short chat turns you notice that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VRAM peak for the whole card, desktop included, was 28.7 GiB, which left 3.2 GiB free. Right after the needle tests the engine process had about 600 MB in swap; the decode numbers above were measured before that and looked normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality check
&lt;/h2&gt;

&lt;p&gt;We ran our own nine-task bench twice: two Astro helpers in JavaScript, two WordPress PHP snippets run in a sandbox, SEO meta tags as JSON, LocalBusiness JSON-LD, a Search Console tool call, a Search Console quick-win analysis and a Dutch blog intro, each with an automatic checker. Reasoning effort medium, Maya's default sampling.&lt;/p&gt;

&lt;p&gt;DeepSeek scored 54 out of 54 in both rounds, 108 out of 108 in total, like Qwen3.8-Flash-Next on Strata. It also took the longest. On October 22 (Maya 1.1.21) that was 680 seconds per round on average, against 104 seconds for Qwen on Strata, because it writes more (about 15,900 tokens per round, against 8,800) at about a third of the speed.&lt;/p&gt;

&lt;p&gt;I compared the other three models on this machine with the same benchmarks: &lt;a href="https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j"&gt;Four big local LLMs on one RTX 5090 and 128 GB of RAM, benchmarked side by side&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls we ran into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing else fits next to it.&lt;/strong&gt; The engine holds about 27 GiB of VRAM (29 GiB for the whole card with our desktop), and everything else you run competes with it for the page cache that holds the experts. Do not run a second model, a game or a GPU miner next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk space and SSD reads.&lt;/strong&gt; About 350-400 GB on disk, and the part that does not fit in RAM is read from the SSD during generation. Use a fast NVMe drive, not a SATA SSD or a hard disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calibrate with the GPU to itself.&lt;/strong&gt; Maya skips the speed calibration when too much VRAM is in use (1 GiB upstream) and tries again at the next start. On a machine where the desktop uses the same card, close what you can, or it keeps being skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning effort.&lt;/strong&gt; The setup writes &lt;code&gt;"reasoning_effort": "high"&lt;/code&gt; as the server default. At 21 to 25 tokens per second, long reasoning takes a while: send a lower &lt;code&gt;reasoning_effort&lt;/code&gt; from your client for interactive work. Our quality bench used medium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which setup we use when
&lt;/h2&gt;

&lt;p&gt;On this machine, DeepSeek-V4.1-Flash is the biggest model and the slowest. It scored full marks in both rounds of our bench, but at about a third of the speed of Qwen3.8-Flash-Next on Strata, with a 3-second wait before the first token. We use it for a second opinion on hard problems, not for interactive agent work.&lt;/p&gt;

&lt;p&gt;If you are thinking about running models like this for your business, Voltti has a page about &lt;a href="https://voltti.ai/en/local-ai-for-businesses" rel="noopener noreferrer"&gt;local AI for businesses&lt;/a&gt;: what stays on your own hardware, and when a hybrid setup makes more sense.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localllm</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Four big local LLMs on one RTX 5090 and 128 GB of RAM, benchmarked side by side</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:20:49 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j</link>
      <guid>https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j</guid>
      <description>&lt;p&gt;&lt;strong&gt;One RTX 5090, 128 GB of RAM, and four open models from 125B to 748B parameters.&lt;/strong&gt; Over the past weeks I got each of them running on the same workstation, on three different runtimes. I measured all four in one session, one after the other, with the same benchmarks. This article puts the numbers side by side, so you can see what each setup is good for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, October 22, 2026:&lt;/strong&gt; I measured everything again after updating Project Maya from 1.1.17 to 1.1.21 and retuning Ornith. The first version of this article, measured on October 21, had Ornith at 33.1 tokens per second. It drafted with its built-in MTP head then, not without a draft model as I wrote. Without drafting and with more experts in VRAM, it now does 46.4. Maya 1.1.21 made DeepSeek read long prompts about 3.7 times faster. The tables below replace the old ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; A reader, Mihai, suggested in the comments that Ornith's stray tokens came from sampling, not from the quant. He was right. TabbyAPI without a sampler preset samples at temperature 1.0 with no top-k, top-p or min-p cutoff. With the settings from Ornith's model card, Ornith scored 108 out of 108 on our tasks instead of 83 (107 in a second check with those settings as the server default), with no stray tokens and no loss of speed. See Ornith and sampling below.&lt;/p&gt;

&lt;p&gt;Every number below was measured on our own machine on the night of October 21 to 22, 2026, unless I say otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four setups
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Quant&lt;/th&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/Qwen/Qwen3.8-Flash-Next" rel="noopener noreferrer"&gt;Qwen3.8-Flash-Next&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;125B, ~6B active&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Niko1221/Strata" rel="noopener noreferrer"&gt;Strata&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Unsloth UD-Q5_K_XL GGUF&lt;/td&gt;
&lt;td&gt;512K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/ornith-ai/Ornith-1.5-122B" rel="noopener noreferrer"&gt;Ornith-1.5-122B&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;125B&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/theroyallab/tabbyAPI" rel="noopener noreferrer"&gt;TabbyAPI&lt;/a&gt; + ExLlamaV3&lt;/td&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw" rel="noopener noreferrer"&gt;Mia-AiLab EXL3 EAQ v1, 4 bpw&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;256K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/zai-org/GLM-5.3-Flash" rel="noopener noreferrer"&gt;GLM-5.3-Flash&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;321B, ~18B active&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/mw00/project-maya" rel="noopener noreferrer"&gt;Project Maya&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;turboderp EXL3 3.05 bpw&lt;/td&gt;
&lt;td&gt;512K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/deepseek-ai/DeepSeek-V4.1-Flash-1017" rel="noopener noreferrer"&gt;DeepSeek-V4.1-Flash-1017&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;748B&lt;/td&gt;
&lt;td&gt;Project Maya (&lt;code&gt;maya-ds4&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Maya-S (experts in 2-3 bits)&lt;/td&gt;
&lt;td&gt;262K&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Parameter counts come from the model cards and the runtimes' READMEs. All four are mixture-of-experts models: only part of the model works on each token, which is what makes them possible on one GPU. The runtimes keep the most-used experts in VRAM and the rest in RAM; Maya with DeepSeek also reads part of the model from the SSD while it generates.&lt;/p&gt;

&lt;p&gt;How to set up three of them, step by step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81"&gt;Qwen3.8-Flash-Next at 79 tok/s with Strata and a 5-bit quant&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/seppegadeyne/run-glm-53-flash-321b-locally-with-project-maya-and-exl3-on-one-rtx-5090-24pj"&gt;GLM-5.3-Flash (321B) with Project Maya and EXL3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/seppegadeyne/run-deepseek-v41-flash-748b-locally-with-project-maya-on-one-rtx-5090-4i4a"&gt;DeepSeek-V4.1-Flash (748B) with Project Maya&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a machine with less RAM, the oldest article in the series covers &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d"&gt;Qwen3.8-Flash-Next with EXL3 and TabbyAPI on 40+ GB of RAM&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GPU: RTX 5090, 32 GB of VRAM, also driving the desktop. Every runtime is set to leave 3 GiB free for that.&lt;/li&gt;
&lt;li&gt;CPU: Ryzen 9 9950X3D (16 cores, 32 threads).&lt;/li&gt;
&lt;li&gt;RAM: 128 GB DDR5 as 4x32 GB at 5,600 MT/s.&lt;/li&gt;
&lt;li&gt;Disk: one NVMe drive.&lt;/li&gt;
&lt;li&gt;OS: Arch Linux (Omarchy), NVIDIA driver 615.78, CUDA 13.4.&lt;/li&gt;
&lt;li&gt;Versions: Strata &lt;code&gt;296b59d&lt;/code&gt;, Project Maya 1.1.21 (&lt;code&gt;451f9cb&lt;/code&gt;), TabbyAPI &lt;code&gt;d6e6227&lt;/code&gt; with ExLlamaV3 1.6.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we measured
&lt;/h2&gt;

&lt;p&gt;One model at a time, started with its own script, with no other model runtime or GPU miner running. Each runtime started in its own systemd slice without a memory limit, so a shell's limits or systemd-oomd could not get in the way (the setup articles explain why).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decode speed:&lt;/strong&gt; a client-side streaming bench against the OpenAI endpoint. Eight runs, alternating a ~400-word Dutch prose prompt and a coding prompt (binary search with tests), greedy decoding, 512 output tokens, median reported. Time to first token is the median over the same runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill and long context:&lt;/strong&gt; a needle-in-a-haystack test with prompts of about 31K and about 125K tokens. The needle is a code hidden in Dutch filler text, with a different random seed per run so the prefix cache cannot help. The exact token count depends on each model's tokenizer: the long prompt came to 124.6K tokens for Qwen, 125.6K for DeepSeek, 127.9K for GLM and 131.5K for Ornith. Prefill speed is prompt tokens divided by the time of the whole request, which includes a short answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drafting:&lt;/strong&gt; Strata drafts with Qwen's MTP head and Maya with GLM's MTP block. Maya measures DeepSeek's DSpark drafter at the first start and keeps the faster option; on 1.1.21 it measured 23.9 tokens per second without and 23.8 with it, so it kept no drafting. Ornith on TabbyAPI runs without drafting, with 180 of its 256 experts per layer in RAM. In an A/B at 256K context the evening before, its MTP head gave 37.8 tokens per second, the &lt;a href="https://huggingface.co/incoai/Ornith-1.5-122B-DFlash2" rel="noopener noreferrer"&gt;DFlash2 drafter&lt;/a&gt; 40.6, and no drafting 45.2. Most of Ornith's experts run on the CPU, so every drafted token the model has to check costs extra reads from RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality on our own work:&lt;/strong&gt; nine tasks from our day job, each with an automatic checker: two Astro helpers in JavaScript, two WordPress PHP snippets run in a PHP sandbox, SEO meta tags as JSON, LocalBusiness JSON-LD, a Search Console tool call, a Search Console quick-win analysis and a Dutch blog intro. 54 points per round, two rounds, reasoning effort medium, each server's default sampling (for Ornith, see the note under the quality table). The second round ran the models in reverse order. During this run, Node could not start inside the sandbox (a version-manager shim on a read-only file system), so both JavaScript tasks failed for every model. I fixed the sandbox and scored the saved answers again with the same checkers; that changed only those two tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Speed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;th&gt;Dutch prose / code&lt;/th&gt;
&lt;th&gt;Time to first token&lt;/th&gt;
&lt;th&gt;Prefill, ~31K prompt&lt;/th&gt;
&lt;th&gt;Prefill, ~125K prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Flash-Next, Strata&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;78.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;81.8 / 76.5&lt;/td&gt;
&lt;td&gt;0.77 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,073 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,252 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ornith-1.5-122B, TabbyAPI&lt;/td&gt;
&lt;td&gt;46.4&lt;/td&gt;
&lt;td&gt;49.8 / 42.2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.42 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,808 tok/s&lt;/td&gt;
&lt;td&gt;1,508 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.3-Flash, Maya&lt;/td&gt;
&lt;td&gt;30.1&lt;/td&gt;
&lt;td&gt;34.8 / 26.4&lt;/td&gt;
&lt;td&gt;0.78 s&lt;/td&gt;
&lt;td&gt;2,269 tok/s&lt;/td&gt;
&lt;td&gt;2,445 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-V4.1-Flash, Maya&lt;/td&gt;
&lt;td&gt;24.9&lt;/td&gt;
&lt;td&gt;26.6 / 23.5&lt;/td&gt;
&lt;td&gt;3.19 s&lt;/td&gt;
&lt;td&gt;1,842 tok/s&lt;/td&gt;
&lt;td&gt;2,570 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four found the needle at both lengths. For a sense of what the prefill means: the long prompt took 29 seconds on Strata, 49 on Maya with DeepSeek, 52 on Maya with GLM and 87 on TabbyAPI with Ornith. On October 21, with Maya 1.1.17, DeepSeek needed 182 seconds for the same length.&lt;/p&gt;

&lt;p&gt;Qwen on Strata is in a class of its own: 1.7 times the decode speed of the next one, and the fastest prefill by far. One likely reason is the number of active parameters: about 6B per token for Qwen against about 18B for GLM, according to their model cards, so far fewer weights move through the CPU and over PCIe for every token. Strata's runtime, built for this one model family, plays a part too; we did not measure how much each contributes.&lt;/p&gt;

&lt;p&gt;The bench counts tokens from the usage numbers each API returns, not from stream chunks. That mattered for Ornith in the first version: with MTP drafting, TabbyAPI streamed it in chunks of about two tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality on our own tasks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Round 1&lt;/th&gt;
&lt;th&gt;Round 2&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Seconds per round&lt;/th&gt;
&lt;th&gt;Tokens written per round&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Flash-Next, Strata&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;108 / 108&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;~8,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-V4.1-Flash, Maya&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;108 / 108&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;680&lt;/td&gt;
&lt;td&gt;~15,900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ornith-1.5-122B, TabbyAPI, model card sampling*&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;53 / 54&lt;/td&gt;
&lt;td&gt;107 / 108&lt;/td&gt;
&lt;td&gt;376&lt;/td&gt;
&lt;td&gt;~17,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.3-Flash, Maya&lt;/td&gt;
&lt;td&gt;47 / 54&lt;/td&gt;
&lt;td&gt;51 / 54&lt;/td&gt;
&lt;td&gt;98 / 108&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;~3,900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* Measured on October 29 with the same config, after a reader's comment (see Ornith and sampling). With TabbyAPI's default sampling on October 22, Ornith scored 37 + 46 = 83 / 108, at about 485 seconds and 22,600 tokens per round.&lt;/p&gt;

&lt;p&gt;On October 21 the totals were 108 for Qwen, 108 for DeepSeek, 105 for GLM and 89 for Ornith (default sampling).&lt;/p&gt;

&lt;p&gt;What went wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GLM&lt;/strong&gt; wrote a WordPress breadcrumb snippet with a PHP syntax error in the first round, and missed the Search Console quick-win analysis in the second (0 of 3 checks), the same task it missed once on October 21.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ornith&lt;/strong&gt;, with TabbyAPI's default sampling on October 22, missed the breadcrumb snippet (it used a PHP constant that does not exist), the JSON-LD task and the Search Console analysis in the first round, and a WordPress shortcode snippet with a PHP syntax error and one check of the Dutch blog intro in the second. Several misses came from stray tokens in the middle of otherwise correct output, such as a Chinese word or the word "Logical" inside a JSON object. That happened in one to four of the nine tasks per round, on both days, with and without MTP drafting. With the sampling from its model card those stray tokens are gone; its one miss on October 29 was a blog intro of 214 words, where the task asks for at most 170.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The time per round says as much as the score. Qwen finishes the nine tasks in under two minutes, GLM in about two and a half. DeepSeek needs 11 minutes, because it writes about 15,900 tokens per round at about 24 tokens per second. Ornith writes even more, about 17,300 tokens, mostly reasoning, and takes about 6 minutes. GLM writes the least, about 3,900 tokens, which is why it is quick despite its slower decode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ornith and sampling
&lt;/h3&gt;

&lt;p&gt;Ornith's stray tokens came from sampling. TabbyAPI uses the sampler settings from its config, and our Ornith config had no preset. Without one, TabbyAPI samples at temperature 1.0 with top-k, top-p and min-p switched off, so now and then it picks a token from the far end of the distribution. Ornith's model card asks for temperature 0.6, top-k 20 and top-p 0.95.&lt;/p&gt;

&lt;p&gt;On October 29 I ran the same nine tasks again on Ornith, with the same config as above (256K context, 180 experts per layer in RAM, no drafting). I compared three sampler settings, sent with each request, two rounds each, taking turns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sampling&lt;/th&gt;
&lt;th&gt;Round 1&lt;/th&gt;
&lt;th&gt;Round 2&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Tasks with Chinese tokens&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabbyAPI default: temperature 1.0, no cutoff&lt;/td&gt;
&lt;td&gt;27 / 54&lt;/td&gt;
&lt;td&gt;34 / 54&lt;/td&gt;
&lt;td&gt;61 / 108&lt;/td&gt;
&lt;td&gt;7 of 18&lt;/td&gt;
&lt;td&gt;47.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model card: temperature 0.6, top-k 20, top-p 0.95&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;108 / 108&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 of 18&lt;/td&gt;
&lt;td&gt;47.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temperature 1.0 with min-p 0.05&lt;/td&gt;
&lt;td&gt;46 / 54&lt;/td&gt;
&lt;td&gt;54 / 54&lt;/td&gt;
&lt;td&gt;100 / 108&lt;/td&gt;
&lt;td&gt;0 of 18&lt;/td&gt;
&lt;td&gt;47.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A min-p floor alone already removed the Chinese tokens. The model card settings also removed the PHP syntax errors. The default arm scored even lower than on October 22 (83), which fits: with no cutoff, which tasks break is down to chance.&lt;/p&gt;

&lt;p&gt;I then made the model card settings the server default, through a sampler preset in TabbyAPI, and ran two more rounds without any sampler settings in the requests, the way our agent sends them: 107 out of 108, no stray tokens, 47.2 tokens per second. The one miss was a blog intro of 214 words where the task asks for at most 170.&lt;/p&gt;

&lt;p&gt;So Ornith's earlier 83 was a configuration mistake on our side, not the model or the quant. The quality table above now shows Ornith with the model card settings. With the right sampling, it scores about as high as Qwen and DeepSeek on these tasks, at about 6 to 8 minutes per round.&lt;/p&gt;

&lt;p&gt;These tasks reward correct, careful output, not deep reasoning. They do not tell you which model is smartest. They do tell you which ones we can hand our everyday work to without checking every answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory and VRAM
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;VRAM peak (whole card)&lt;/th&gt;
&lt;th&gt;VRAM free at peak&lt;/th&gt;
&lt;th&gt;RAM in use (whole system)&lt;/th&gt;
&lt;th&gt;Engine in swap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Flash-Next, Strata&lt;/td&gt;
&lt;td&gt;29.0 GiB&lt;/td&gt;
&lt;td&gt;2.9 GiB&lt;/td&gt;
&lt;td&gt;~120 GiB, pinned&lt;/td&gt;
&lt;td&gt;~361 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ornith-1.5-122B, TabbyAPI&lt;/td&gt;
&lt;td&gt;28.5 GiB&lt;/td&gt;
&lt;td&gt;3.3 GiB&lt;/td&gt;
&lt;td&gt;~61 GiB, pinned&lt;/td&gt;
&lt;td&gt;~2.3 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.3-Flash, Maya&lt;/td&gt;
&lt;td&gt;28.9 GiB&lt;/td&gt;
&lt;td&gt;2.9 GiB&lt;/td&gt;
&lt;td&gt;~117 GiB, pinned&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-V4.1-Flash, Maya&lt;/td&gt;
&lt;td&gt;28.6 GiB&lt;/td&gt;
&lt;td&gt;3.2 GiB&lt;/td&gt;
&lt;td&gt;~20 GiB + model files in page cache&lt;/td&gt;
&lt;td&gt;~585 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The VRAM peak is the highest value &lt;code&gt;nvidia-smi&lt;/code&gt; reported for the whole card during the needle tests, so it includes the desktop and two browsers (about 2.2 GiB before any model started). Each runtime keeps its 3 GiB reserve on top of its own use; with the desktop counted, GLM and Qwen left a little less than that free at their peak. Strata and Maya with GLM pin almost all RAM for their expert tiers, so nothing else heavy fits next to them. Ornith's 65.5 GB quant leaves the most room; with more of its experts in VRAM, it now uses about 1 GiB more VRAM than in the first version of this article. DeepSeek's engine works differently: it reads its model files in place, so &lt;code&gt;free&lt;/code&gt; counts most of what it uses as page cache and reports about 100 GiB as available. Do not read that as free memory. The engine depends on that cache, and anything you start next to it pushes experts out, which then have to come from the SSD again. In practice it uses the whole machine, like the other two. RAM and swap were read once, right after the needle tests. Ornith ran right after DeepSeek and had about 2.3 GiB in swap at that point; on the previous evening's tuning runs it had none, and its decode speed here (46.4) matched those runs with the same settings (46.4 to 47.2).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one we use when
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Qwen3.8-Flash-Next on Strata&lt;/strong&gt; is our default for coding and agent work. Fastest at everything, full marks in both rounds of our tasks, 512K context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GLM-5.3-Flash on Maya&lt;/strong&gt; is the second opinion: a much bigger model with 512K context, at about 40% of the speed, and the one that writes the least.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek-V4.1-Flash on Maya&lt;/strong&gt; is the biggest model here, and the slowest to write, with a 3-second wait before the first token. Since Maya 1.1.21 it reads long prompts about as fast as GLM. Good for hard problems where we can wait, not for interactive work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ornith-1.5-122B on TabbyAPI&lt;/strong&gt; is now the second fastest at writing, with the quickest first token, but the slowest at reading long prompts. It leaves the most RAM free. With the sampling from its model card it scores 107 to 108 out of 108 on our tasks; with TabbyAPI's default sampling it scored the lowest, because of stray tokens in its output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running all of this on one desktop is possible because the runtimes move experts between VRAM, RAM and the SSD while you chat. The cost is that only one model fits at a time, and switching takes a stop and a start script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One machine, one session. Other hardware, other drivers or another day can give other numbers, and all four runtimes move fast: Maya 1.1.17 made DeepSeek's long-prompt reading about eight times faster than 1.1.16, and 1.1.21 made it another 3.7 times faster.&lt;/li&gt;
&lt;li&gt;Two rounds of nine tasks are not many. A single miss can be noise.&lt;/li&gt;
&lt;li&gt;GLM scored 98 here and 105 on October 21, Ornith with default sampling 83 and 89. With two rounds of nine tasks, that difference is within what a few misses can do.&lt;/li&gt;
&lt;li&gt;Each model ran with its runtime's default sampling and reasoning effort medium. For Ornith on TabbyAPI that default was the problem (see Ornith and sampling). Strata samples greedily by default, and Maya uses the sampling from GLM's and DeepSeek's model cards. Check what your runtime does when a request sends no sampler settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are thinking about running models like these for your business, Voltti has a page about &lt;a href="https://voltti.ai/en/local-ai-for-businesses" rel="noopener noreferrer"&gt;local AI for businesses&lt;/a&gt;: what stays on your own hardware, and when a hybrid setup makes more sense.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localllm</category>
      <category>llm</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>Run GLM-5.3-Flash (321B) locally with Project Maya and EXL3 on one RTX 5090</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Fri, 16 Oct 2026 14:33:32 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/run-glm-53-flash-321b-locally-with-project-maya-and-exl3-on-one-rtx-5090-24pj</link>
      <guid>https://dev.to/seppegadeyne/run-glm-53-flash-321b-locally-with-project-maya-and-exl3-on-one-rtx-5090-24pj</guid>
      <description>&lt;p&gt;&lt;strong&gt;GLM-5.3-Flash is a 321B mixture-of-experts model, and its 3.05 bpw EXL3 quant is 125 GB, too big to fit in my workstation's 128 GB of RAM next to the OS.&lt;/strong&gt; It still runs locally here: on one RTX 5090 with 128 GB of RAM, with a 512K-token context, at about 25 tokens per second. This guide shows how, with &lt;a href="https://github.com/mw00/project-maya" rel="noopener noreferrer"&gt;Project Maya&lt;/a&gt;, and what we measured on the way.&lt;/p&gt;

&lt;p&gt;Every number below was measured on our own machine on October 15, 2026. Where a number comes from someone else, I name the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; Since Maya 1.1.13, GLM decodes at about 30 tokens per second on this machine (30.1 in our side-by-side bench). The 25 in this article is from Maya 1.1.11 and 1.1.12.&lt;/p&gt;

&lt;p&gt;This is part of a series. It covers &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81"&gt;Qwen3.8-Flash-Next with Strata at 79 tokens per second&lt;/a&gt;, and before that I wrote about &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d"&gt;Qwen3.8-Flash-Next with EXL3 and TabbyAPI on 40+ GB of RAM&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/zai-org/GLM-5.3-Flash" rel="noopener noreferrer"&gt;GLM-5.3-Flash&lt;/a&gt; is an open-weight model from zai-org under the MIT license. Maya's README describes it as 321B parameters with about 18B active per token and a context of up to 1M tokens. It has a hybrid attention design and an MTP layer for drafting.&lt;/p&gt;

&lt;p&gt;18B active parameters is three times the roughly 6B that the &lt;a href="https://huggingface.co/Qwen/Qwen3.8-Flash-Next" rel="noopener noreferrer"&gt;Qwen3.8-Flash-Next model card&lt;/a&gt; lists. That explains most of the speed difference below: more of the model has to move through the CPU and over PCIe for every token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not TabbyAPI
&lt;/h2&gt;

&lt;p&gt;I tried TabbyAPI first, with turboderp's &lt;a href="https://huggingface.co/turboderp/GLM-5.3-Flash-exl3" rel="noopener noreferrer"&gt;GLM-5.3-Flash EXL3 quants&lt;/a&gt;. ExLlamaV3 keeps CPU experts in a RAM arena and has no SSD tier for experts. So the whole model has to fit in VRAM plus RAM, next to the OS. On 128 GB, the largest quant that worked was the self-calibrated 2.60 bpw revision:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TabbyAPI, 262K context&lt;/th&gt;
&lt;th&gt;Decode (tok/s, server log)&lt;/th&gt;
&lt;th&gt;Prefill at 256K&lt;/th&gt;
&lt;th&gt;RAM free under load&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sc_2.10bpw&lt;/td&gt;
&lt;td&gt;32.4&lt;/td&gt;
&lt;td&gt;1,063 tok/s&lt;/td&gt;
&lt;td&gt;~36 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sc_2.60bpw&lt;/td&gt;
&lt;td&gt;25.5&lt;/td&gt;
&lt;td&gt;1,151 tok/s&lt;/td&gt;
&lt;td&gt;~19 GB, other processes in zram&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 3.05 bpw quant (125 GB) does not fit that way. You need a runtime that can leave part of the experts on the SSD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Project Maya
&lt;/h2&gt;

&lt;p&gt;Project Maya is a fork of &lt;a href="https://github.com/Niko1221/Strata" rel="noopener noreferrer"&gt;Strata&lt;/a&gt;, built for this one model. Its engine keeps the experts your conversation uses most on the GPU, the next ones in RAM and the rest on the NVMe drive, and moves them while you chat. It measures your hardware and tunes itself, exposes OpenAI- and Anthropic-compatible APIs, and has a dashboard with a live monitor.&lt;/p&gt;

&lt;p&gt;Maya ships its own GGUF quants (Maya-S 96.5 GB, Maya-M 116 GB, Maya-L 156 GB). It can also read turboderp's EXL3 quants in place from their safetensors, which its README marks as experimental and NVIDIA-only. I chose the EXL3 3.05 bpw route because I already trusted that quant from the TabbyAPI tests, and because Maya then decoded as fast as the smaller sc_2.60 quant did in TabbyAPI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GPU: RTX 5090, 32 GB of VRAM, also driving the desktop.&lt;/li&gt;
&lt;li&gt;CPU: Ryzen 9 9950X3D (16 cores, 32 threads).&lt;/li&gt;
&lt;li&gt;RAM: 128 GB DDR5 as 4x32 GB at 5,600 MT/s.&lt;/li&gt;
&lt;li&gt;Disk: one NVMe drive.&lt;/li&gt;
&lt;li&gt;OS: Arch Linux (Omarchy), NVIDIA driver 615.78, CUDA 13.4.&lt;/li&gt;
&lt;li&gt;Maya 1.1.11 (commit &lt;code&gt;d187195&lt;/code&gt;, October 15, 2026).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mw00/project-maya.git
&lt;span class="nb"&gt;cd &lt;/span&gt;project-maya
./maya.sh &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--check&lt;/code&gt; prints the GPUs, driver, CUDA toolkit, compiler, RAM and CPU. Then run setup non-interactively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./maya.sh &lt;span class="nt"&gt;--setup&lt;/span&gt; &lt;span class="nt"&gt;--plain&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--gpu&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--context&lt;/span&gt; 131072 &lt;span class="nt"&gt;--model&lt;/span&gt; EXL3-3.05bpw &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--models-dir&lt;/span&gt; ~/Models/maya &lt;span class="nt"&gt;--download-model&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-vision&lt;/span&gt; &lt;span class="nt"&gt;--draft&lt;/span&gt; mtp &lt;span class="nt"&gt;--no-start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens, according to Maya's &lt;a href="https://github.com/mw00/project-maya/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup docs&lt;/a&gt; and what we saw:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It checks the PC and installs its Python packages into &lt;code&gt;.venv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It fetches llama.cpp's source at a pinned commit and compiles the engine for your GPU. Maya's docs say 10-30 minutes, once.&lt;/li&gt;
&lt;li&gt;It downloads the 125.3 GB quant (27 files) with resumable &lt;code&gt;curl&lt;/code&gt; commands it prints first, and checks every file against its SHA-256.&lt;/li&gt;
&lt;li&gt;It builds the &lt;em&gt;pack&lt;/em&gt;, the engine's index of the model files, in the model folder. Here that was a 1.3 GB &lt;code&gt;pack/&lt;/code&gt; folder plus a 2 GB &lt;code&gt;maya-exl3.gguf&lt;/code&gt; next to it.&lt;/li&gt;
&lt;li&gt;It writes &lt;code&gt;maya-exl3-3.05bpw.json&lt;/code&gt; (the engine config) and &lt;code&gt;run-maya-exl3-3.05bpw.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The flags that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--model EXL3-3.05bpw&lt;/code&gt; selects turboderp's EXL3 quant instead of a Maya GGUF.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--draft mtp&lt;/code&gt; drafts with the model's own MTP block. The DFlash2 drafter is the other option, but its license is CC BY-NC-ND 4.0, non-commercial only, so I left it out.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-vision&lt;/code&gt; skips the image encoder (a 10-20 minute build and 1.1 GB).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-start&lt;/code&gt; installs without starting, so you can start it the way shown below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Edit the config before the first start
&lt;/h2&gt;

&lt;p&gt;Two settings go into the &lt;code&gt;args&lt;/code&gt; of &lt;code&gt;maya-exl3-3.05bpw.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--glm-pack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/you/Models/maya/EXL3-3.05bpw/pack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--max-context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"524288"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--expert-cache"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--prefill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--kv-streaming"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"--vram-reserve-mib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3072"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--vram-reserve-mib 3072&lt;/code&gt;: the VRAM Maya's expert pool leaves free. The default is 700 MiB. Our desktop runs on the same card, and with the VRAM full the screen flickered, so we keep 3 GB free everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-context 524288&lt;/code&gt;: GLM-5.3-Flash supports up to 1M tokens, so this is not rope scaling, just a bigger limit. With &lt;code&gt;--kv-streaming on&lt;/code&gt;, extra context costs RAM (about 13 KB per token, judging by the size of the sessions Maya saved to disk), not VRAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A new &lt;code&gt;--setup&lt;/code&gt; run writes this file again. Maya's docs say it keeps settings like the reserve, but keep a backup anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start it outside your shell's memory limits
&lt;/h2&gt;

&lt;p&gt;Maya pins most of the free RAM for its expert tier. On this machine it leaves about 4-5 GB free. That collides with two Linux defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shell inside a systemd scope with a memory limit. Our agent's terminal scopes have &lt;code&gt;memory.max&lt;/code&gt; of 4 GiB, and a big process started from there lands in swap.&lt;/li&gt;
&lt;li&gt;systemd-oomd, which Omarchy enables for the user's &lt;code&gt;app.slice&lt;/code&gt;. It kills a cgroup after 20 seconds above 50% memory pressure, and a model pinning RAM during load triggers exactly that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we start it in a separate slice, without a memory limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;--collect&lt;/span&gt; &lt;span class="nt"&gt;--slice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;llm.slice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"maya-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;infinity &lt;span class="se"&gt;\&lt;/span&gt;
  ./run-maya-exl3-3.05bpw.sh &lt;span class="nt"&gt;--plain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--plain&lt;/code&gt; keeps the output as plain text instead of Maya's full-screen dashboard, which is easier to log. Stop it with &lt;code&gt;systemctl --user stop 'maya-*.scope'&lt;/code&gt;. The start takes a few minutes. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8080/v1/models

curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8080/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "glm-5.3-flash", "messages": [{"role": "user", "content": "Say hello in five words."}],
       "max_tokens": 2000}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard is at &lt;code&gt;http://127.0.0.1:8080/#monitor&lt;/code&gt; and shows decode and prefill speed, the VRAM hit rate and where the experts live. Keep the server on &lt;code&gt;127.0.0.1&lt;/code&gt;; if you open it to your network, use &lt;code&gt;--host 0.0.0.0&lt;/code&gt; together with &lt;code&gt;--api-key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibrate
&lt;/h2&gt;

&lt;p&gt;Maya's calibration measures how to split the work between the CPU and the PCIe link, and how many CPU threads to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./maya.sh &lt;span class="nt"&gt;--calibrate&lt;/span&gt; &lt;span class="nt"&gt;--no-start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It loads the full model, so run it with Maya stopped and in the same way as the server (in &lt;code&gt;llm.slice&lt;/code&gt;, without a memory limit). It refuses to run while more than 1 GiB of VRAM is in use on the GPU, to make sure no other engine is loaded. A desktop on the same card already takes about 2 GB, so on our machine it never started. I raised that threshold locally in &lt;code&gt;busy_gpus()&lt;/code&gt; in &lt;code&gt;maya.py&lt;/code&gt; from 1024 to 3072 MiB. That is a local patch: check it after every update, and only use it if you are sure nothing else heavy is on the card.&lt;/p&gt;

&lt;p&gt;The calibration took about 10 minutes (Maya estimates 10-15). Its full output on our machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the engine's own split (PCIe share 0.60): 23.7 tok/s
PCIe share 0.00: 21.1 tok/s
PCIe share 0.10: 24.6 tok/s
PCIe share 0.25: 23.9 tok/s
PCIe share 0.50: 24.1 tok/s
PCIe share 0.75: 23.3 tok/s
16 CPU threads: 24.6 tok/s
12 CPU threads: 21.9 tok/s
11 CPU threads: 20.0 tok/s
8 CPU threads: 18.1 tok/s
4 CPU threads: 13.7 tok/s
confirming against the engine's own settings ...
[ok] tuned for this PC: STRATA_GLM_PCIE_SHARE=0.10 (25.7 tok/s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It stores the result as an environment variable in the config. In the sweep, a PCIe share of 0.10 beat the engine's own split by about 4% (24.6 vs 23.7), and the confirmation run measured 25.7. Fewer than 16 CPU threads hurt a lot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; since Maya 1.1.16 that variable is called &lt;code&gt;MAYA_GLM_PCIE_SHARE&lt;/code&gt;, not &lt;code&gt;STRATA_GLM_PCIE_SHARE&lt;/code&gt;. A new calibration at 512K context, on Maya 1.1.13, picked 0.25.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks
&lt;/h2&gt;

&lt;p&gt;How we measured: a client-side streaming bench against the OpenAI endpoint. Eight runs per setting, alternating a ~400-word Dutch prose prompt and a coding prompt (binary search with tests), greedy decoding, 512 output tokens, median reported. Prefill comes from a needle-in-a-haystack test at the given prompt length. The needle and filler text are fixed per length, and Maya caches prompt prefixes (also on disk, across restarts), so a later run can be flattered by an earlier one: read the prefill numbers as approximate. Nothing else heavy ran on the machine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maya, EXL3 3.05 bpw&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;th&gt;Dutch prose / code&lt;/th&gt;
&lt;th&gt;Prefill&lt;/th&gt;
&lt;th&gt;Needle&lt;/th&gt;
&lt;th&gt;VRAM peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;128K context&lt;/td&gt;
&lt;td&gt;26.3&lt;/td&gt;
&lt;td&gt;30.4 / 21.9&lt;/td&gt;
&lt;td&gt;2,165 tok/s at 126K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;31.4 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;262K context&lt;/td&gt;
&lt;td&gt;24.1&lt;/td&gt;
&lt;td&gt;27.5 / 21.2&lt;/td&gt;
&lt;td&gt;2,039 tok/s at 254K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;31.3 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;262K, calibrated, 3 GB reserve&lt;/td&gt;
&lt;td&gt;25.2&lt;/td&gt;
&lt;td&gt;28.6 / 22.9&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;29.5 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512K, calibrated, 3 GB reserve&lt;/td&gt;
&lt;td&gt;25.9&lt;/td&gt;
&lt;td&gt;29.3 / 23.2&lt;/td&gt;
&lt;td&gt;1,930 tok/s at 506K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;29.0 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows ran with the 700 MiB default reserve, and you can see the VRAM peak at the edge of the 31.8 GiB card. With the 3 GB reserve, the peak stayed at or below 29.5 GiB. Reading 506K tokens took 262 seconds. Decoding at 512K was as fast as at 262K, because the KV cache streams from RAM.&lt;/p&gt;

&lt;p&gt;Compared to TabbyAPI on the same machine: about the same decode speed as the smaller sc_2.60 quant (25.5, from TabbyAPI's server log rather than this client bench), with a bigger quant, about 1.7x the prefill speed, and twice the context.&lt;/p&gt;

&lt;p&gt;For reference, Maya's README lists "30+ tokens/s" for an RTX 5090 with a Ryzen 9 7950X and 96 GB of DDR5, with Maya's own Maya-M quant. That is a different quant and a different bench, so do not compare the numbers one to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality check
&lt;/h2&gt;

&lt;p&gt;We ran the same five coding tasks with hidden tests that we used for the Qwen setups: three trials each, sandboxed, reasoning effort medium, and each server's default sampling (Maya temperature 1.0 and top_p 0.95, Strata greedy, TabbyAPI temperature 0.7).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model and runtime&lt;/th&gt;
&lt;th&gt;Passed&lt;/th&gt;
&lt;th&gt;Test cases&lt;/th&gt;
&lt;th&gt;Seconds per task&lt;/th&gt;
&lt;th&gt;Decode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Flash-Next UD-Q5_K_XL, Strata&lt;/td&gt;
&lt;td&gt;15 / 15&lt;/td&gt;
&lt;td&gt;480 / 480&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Flash-Next EXL3 4.05 bpw, TabbyAPI&lt;/td&gt;
&lt;td&gt;15 / 15&lt;/td&gt;
&lt;td&gt;480 / 480&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.3-Flash EXL3 3.05 bpw, Maya&lt;/td&gt;
&lt;td&gt;14 / 15&lt;/td&gt;
&lt;td&gt;472 / 480&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one miss was invalid JSON (a trailing comma) in one trial of an incident-report task; all the code tasks passed. These tasks are too easy to say which model is smarter. What they do show is that a 3-bit GLM on a desktop produces working code, and that it takes about 1.5x the time per task because of the slower decode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls we ran into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing else fits next to it.&lt;/strong&gt; Maya pins RAM until about 4-5 GB is free and holds 29-30 GB of VRAM. Do not run a second model runtime, a game or a GPU miner next to it. Switching models means stopping one and starting the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait for the VRAM after a stop.&lt;/strong&gt; VRAM takes several seconds to come back after a runtime stops. Start the next one too early and it fails a VRAM check or gets a smaller expert pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unstable RAM corrupts downloads.&lt;/strong&gt; Our four DIMMs at their 6,000 MT/s EXPO profile were not stable: kernel oopses, crashes, and most of a model download written in that time was corrupt. We run 5,600 MT/s now. Maya checks its own downloads against SHA-256; do the same for anything you fetch yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure with fresh prompts.&lt;/strong&gt; A repeated long prompt hits the prefix cache. One of our runs reported 70,624 tokens per second of "prefill" for exactly that reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reasoning effort and sampling.&lt;/strong&gt; Setup wrote &lt;code&gt;"reasoning_effort": "high"&lt;/code&gt; with temperature 1.0 and top_p 0.95 as server defaults in the config. Long reasoning at 25 tokens per second adds up, so for interactive work consider sending a lower &lt;code&gt;reasoning_effort&lt;/code&gt; from the client. On the Qwen setup, medium scored best on our own bench; for GLM we measured it later. &lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; on October 25 we compared thinking off with medium. With thinking off, GLM scored 101 of 108 on the nine-task bench in 68 seconds per round. With medium it scored 37 of 54 in 109 seconds per round. The Search Console analysis task failed in both settings, so medium stays our default for that task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which setup we use when
&lt;/h2&gt;

&lt;p&gt;On this one machine we now have three ways to run a large model, and we switch with a stop and a start script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Qwen3.8-Flash-Next UD-Q5_K_XL on Strata: about 79 tokens per second, 512K context, our daily default for coding and agent work.&lt;/li&gt;
&lt;li&gt;Qwen3.8-Flash-Next EXL3 4.05 bpw on TabbyAPI (retired on October 21): about 68 tokens per second in its server log, at 262K context.&lt;/li&gt;
&lt;li&gt;GLM-5.3-Flash EXL3 3.05 bpw on Maya: about 25 tokens per second, a 321B model with 512K context, for when a second, bigger model should look at a problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Strata setup, with its calibration A/B and our own nine-task bench, is in the companion article: &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81"&gt;Run Qwen3.8-Flash-Next at 79 tok/s with Strata and a 5-bit quant on 128 GB of RAM&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Update, October 21, 2026: the same machine now also runs &lt;a href="https://dev.to/seppegadeyne/run-deepseek-v41-flash-748b-locally-with-project-maya-on-one-rtx-5090-4i4a"&gt;DeepSeek-V4.1-Flash (748B) with Project Maya&lt;/a&gt;, and all four local models are compared on the same day in &lt;a href="https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j"&gt;Four big local LLMs on one RTX 5090 and 128 GB of RAM, benchmarked side by side&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are deciding what hardware to buy for running AI locally, &lt;a href="https://voltti.ai/en/insights/running-ai-locally-what-computer-do-you-really-need" rel="noopener noreferrer"&gt;Voltti has a practical guide&lt;/a&gt; on the trade-offs between local and cloud AI for business use.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localllm</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Run Qwen3.8-Flash-Next at 79 tok/s with Strata and a 5-bit quant on 128 GB of RAM</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Fri, 16 Oct 2026 14:24:55 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81</link>
      <guid>https://dev.to/seppegadeyne/run-qwen38-flash-next-at-79-toks-with-strata-and-a-5-bit-quant-on-128-gb-of-ram-4a81</guid>
      <description>&lt;p&gt;&lt;strong&gt;Two weeks ago I wrote about running &lt;a href="https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d"&gt;Qwen3.8-Flash-Next with EXL3 and TabbyAPI on 40+ GB of RAM&lt;/a&gt;.&lt;/strong&gt; Since then the workstation got 128 GB of RAM, and that changed which setup is fastest. This guide covers the setup we now use every day: &lt;a href="https://github.com/Niko1221/Strata" rel="noopener noreferrer"&gt;Strata&lt;/a&gt; serving Unsloth's 5-bit UD-Q5_K_XL GGUF of the same 125B model, with a 512K context, at about 79 tokens per second on one RTX 5090.&lt;/p&gt;

&lt;p&gt;Every number below was measured on our own machine on October 15, 2026. Where a number comes from someone else, I name the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed since the EXL3 guide
&lt;/h2&gt;

&lt;p&gt;The EXL3 guide ran turboderp's 3.05 bpw quant because 64 GB of RAM did not leave room for anything bigger. With 128 GB, two routes opened up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TabbyAPI with the 4.05 bpw EXL3 quant and a pinned CPU expert arena. Its server log showed about 68 tokens per second for decoding with a 3 GB VRAM reserve, and about 1,780 tokens per second for prefill at 254K tokens (the prefill was measured with split 384, before we added the 3 GB reserve). &lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; we retired this route on October 21 and removed the 4.05 quant.&lt;/li&gt;
&lt;li&gt;Strata with Unsloth's UD-Q5_K_XL, a 5-bit quant with 8-bit down projections. Same model, more bits per expert, and a runtime built for this one model family.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the same machine, Strata decoded about as fast as TabbyAPI before tuning and faster after calibration, read long prompts about 1.8-2.4x faster, and ran at 512K context. That is why it is our default now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GPU: RTX 5090, 32 GB of VRAM. It also drives the desktop, which matters later.&lt;/li&gt;
&lt;li&gt;CPU: Ryzen 9 9950X3D (16 cores, 32 threads, AVX-512).&lt;/li&gt;
&lt;li&gt;RAM: 128 GB DDR5 as 4x32 GB, running at 5,600 MT/s. More on why not 6,000 in the pitfalls.&lt;/li&gt;
&lt;li&gt;Disk: one NVMe drive with room for the 158 GB download.&lt;/li&gt;
&lt;li&gt;OS: Arch Linux (Omarchy), NVIDIA driver 615.78, CUDA 13.4.&lt;/li&gt;
&lt;li&gt;Strata at commit &lt;code&gt;296b59d&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Strata
&lt;/h2&gt;

&lt;p&gt;Strata is a C++/CUDA engine plus a Python server for Qwen3.8-Flash-Next and its variants. It reads the model in three tiers: the experts your conversation uses most live in VRAM, the next ones in RAM, and anything that does not fit comes from the SSD. It moves experts between the tiers while it runs, drafts tokens with the model's own MTP layer, and exposes OpenAI- and Anthropic-compatible APIs plus a small web UI with a live monitor.&lt;/p&gt;

&lt;p&gt;The project's install docs are written so that a coding agent can follow them (&lt;a href="https://github.com/Niko1221/Strata/blob/main/docs/AI_SETUP.md" rel="noopener noreferrer"&gt;docs/AI_SETUP.md&lt;/a&gt;). I still recommend reading them yourself once: the recommended sizes in those docs target 32-96 GB gaming PCs, and the larger Unsloth quants are a separate family in setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the quant by RAM, not by bits
&lt;/h2&gt;

&lt;p&gt;Strata keeps every expert in RAM when the experts plus about 16 GB fit. If they do not fit, it reads the rest from the SSD while it answers, and that costs a lot of decode speed. So the rule is simple: take the largest quant whose experts fit in RAM with room to spare.&lt;/p&gt;

&lt;p&gt;From Strata's &lt;a href="https://github.com/Niko1221/Strata/blob/main/docs/MODELS.md" rel="noopener noreferrer"&gt;model table&lt;/a&gt;, with its own quality measure (how often the quant picks the same top token as Q8_0):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quant&lt;/th&gt;
&lt;th&gt;Download&lt;/th&gt;
&lt;th&gt;Experts&lt;/th&gt;
&lt;th&gt;RAM for all experts&lt;/th&gt;
&lt;th&gt;Same top token as Q8_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UD-IQ4_XS&lt;/td&gt;
&lt;td&gt;94 GB&lt;/td&gt;
&lt;td&gt;55 GiB&lt;/td&gt;
&lt;td&gt;~72 GB&lt;/td&gt;
&lt;td&gt;88.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UD-Q4_K_XL&lt;/td&gt;
&lt;td&gt;111 GB&lt;/td&gt;
&lt;td&gt;72 GiB&lt;/td&gt;
&lt;td&gt;~88 GB&lt;/td&gt;
&lt;td&gt;93.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UD-Q5_K_XL&lt;/td&gt;
&lt;td&gt;158 GB&lt;/td&gt;
&lt;td&gt;92 GiB&lt;/td&gt;
&lt;td&gt;~108 GB&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UD-Q6_K_XL&lt;/td&gt;
&lt;td&gt;169 GB&lt;/td&gt;
&lt;td&gt;102 GiB&lt;/td&gt;
&lt;td&gt;~118 GB&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0&lt;/td&gt;
&lt;td&gt;188 GB&lt;/td&gt;
&lt;td&gt;120 GiB&lt;/td&gt;
&lt;td&gt;~136 GB&lt;/td&gt;
&lt;td&gt;100% (reference)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 128 GB, UD-Q5_K_XL is the sweet spot. UD-Q6_K_XL scores the same 94.6% in Strata's table, with a mean KL divergence of 0.033 against 0.038 for Q5, and needs 10 GB more RAM that a 128 GB machine does not have to spare. Q8_0 does not fit.&lt;/p&gt;

&lt;p&gt;Before you download anything, let setup check your PC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Niko1221/Strata.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Strata
./setup.sh &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints the GPU, driver, RAM, CPU and free disk, and which sizes fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;This is the setup command we ran, non-interactive, with the model files on the NVMe drive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./setup.sh &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--family&lt;/span&gt; unsloth &lt;span class="nt"&gt;--model&lt;/span&gt; UD-Q5_K_XL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--context&lt;/span&gt; 262144 &lt;span class="nt"&gt;--vision&lt;/span&gt; no &lt;span class="nt"&gt;--gpu&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-dir&lt;/span&gt; ~/Models/strata &lt;span class="nt"&gt;--port&lt;/span&gt; 8081 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vram-reserve-mib&lt;/span&gt; 3072 &lt;span class="nt"&gt;--no-start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the flags do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--family unsloth --model UD-Q5_K_XL&lt;/code&gt; picks Unsloth's 5-bit files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--context 262144&lt;/code&gt; is the model's trained context. You can raise it later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--vram-reserve-mib 3072&lt;/code&gt; keeps 3 GB of VRAM free. Our desktop runs on the same card, and when the VRAM was completely full the screen started to flicker.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--port 8081&lt;/code&gt; because port 8080 is taken by our GLM setup (more on that at the end). The default is 8080.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--no-start&lt;/code&gt; installs without starting the server, so you can start it the way described below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup writes a JSON config (&lt;code&gt;strata-unsloth-ud-q5_k_xl.json&lt;/code&gt;) and a start script (&lt;code&gt;run-unsloth-ud-q5_k_xl.sh&lt;/code&gt;) in the Strata folder.&lt;/p&gt;

&lt;h3&gt;
  
  
  If the download is slow
&lt;/h3&gt;

&lt;p&gt;Strata's own downloader reached only about 7 MB/s here, which is around six hours for 158 GB. The faster route: stop setup, fetch the shards with the Hugging Face CLI at the exact revision setup pins, put them in the model folder, and run setup again. It checks every file against its SHA-256 and skips what is already there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the revision setup.py pins for Unsloth's repo&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'"unsloth/Qwen3.8-Flash-Next-GGUF"'&lt;/span&gt; setup.py

hf download unsloth/Qwen3.8-Flash-Next-GGUF &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--revision&lt;/span&gt; 38bb39ee97821de2c9009abb7e93950eec396e66 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt; &lt;span class="s2"&gt;"UD-Q5_K_XL/*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--local-dir&lt;/span&gt; ~/Models/strata/hf-tmp

&lt;span class="nb"&gt;mv&lt;/span&gt; ~/Models/strata/hf-tmp/UD-Q5_K_XL/&lt;span class="k"&gt;*&lt;/span&gt;.gguf ~/Models/strata/models/unsloth-UD-Q5_K_XL/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the revision your own &lt;code&gt;setup.py&lt;/code&gt; prints, not the one above, if they differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  If setup says shard 1 is "short"
&lt;/h3&gt;

&lt;p&gt;The first of the six UD-Q5_K_XL shards holds only metadata and no tensors (10.9 MB). At commit &lt;code&gt;296b59d&lt;/code&gt;, setup's shard check still treated it as a few bytes too short and stopped on a correct download. There is a fix waiting upstream (&lt;a href="https://github.com/Niko1221/Strata/pull/2505" rel="noopener noreferrer"&gt;PR #2505&lt;/a&gt;, same idea in #2632; both still open on October 16, 2026). &lt;strong&gt;Update, October 29, 2026:&lt;/strong&gt; this is fixed upstream (#2346) in Strata v0.2.4.2, so the patch below is no longer needed. Before that, the one-line local patch is to skip shards without tensors in &lt;code&gt;check_shards()&lt;/code&gt; in &lt;code&gt;setup.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# in check_shards(), before: need = g.data_start + ...
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tensors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check after every &lt;code&gt;git pull&lt;/code&gt; whether the fix has landed upstream, so you do not keep a patch you no longer need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start it outside your shell's memory limits
&lt;/h2&gt;

&lt;p&gt;Strata pins about 98 GB of RAM with this quant. Two Linux defaults can kill or cripple a process that size:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shell or tool runner inside a systemd scope with a memory limit. Our agent's terminal scopes have &lt;code&gt;memory.max&lt;/code&gt; of 4 GiB, and a model started from there ends up mostly in swap.&lt;/li&gt;
&lt;li&gt;systemd-oomd. Omarchy enables it for the user's &lt;code&gt;app.slice&lt;/code&gt; and kills a cgroup after 20 seconds above 50% memory pressure. A model that pins most of the RAM looks exactly like that during load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we start the server in its own slice, without a memory limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;--collect&lt;/span&gt; &lt;span class="nt"&gt;--slice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;llm.slice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"strata-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;infinity &lt;span class="se"&gt;\&lt;/span&gt;
  ./run-unsloth-ud-q5_k_xl.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop it with &lt;code&gt;systemctl --user stop 'strata-*.scope'&lt;/code&gt;. Avoid a broad &lt;code&gt;pkill -f&lt;/code&gt;: the pattern also matches the shell that runs it.&lt;/p&gt;

&lt;p&gt;The first start takes a few minutes and the desktop can stall for a moment while the RAM gets pinned. Then check that it is up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8081/health
&lt;span class="c"&gt;# {"status": "ok", "max_context": 262144, "model": "...", "loaded": true, ...}&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8081/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "strata", "messages": [{"role": "user", "content": "Say hello in five words."}],
       "max_tokens": 64, "reasoning_effort": "none"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any OpenAI-compatible client works with base URL &lt;code&gt;http://127.0.0.1:8081/v1&lt;/code&gt;, any API key and any model name. Anthropic-compatible clients use &lt;code&gt;/v1/messages&lt;/code&gt;. Keep it on &lt;code&gt;127.0.0.1&lt;/code&gt;; if you open it to your network, set &lt;code&gt;--api-key&lt;/code&gt; as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go to 512K context
&lt;/h2&gt;

&lt;p&gt;The model is trained for 262,144 tokens. Strata can stretch that with YaRN rope scaling, and setup picks the method for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./setup.sh &lt;span class="nt"&gt;--setup&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; 524288
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;--no-start&lt;/code&gt; if you run it from a shell with a memory limit (see above), and start the server separately. Setup adds &lt;code&gt;--max-context 524288 --rope-scaling yarn --rope-scale 2&lt;/code&gt; to the engine arguments. If you use a custom chat template (see below), check afterwards that the &lt;code&gt;tokenizer&lt;/code&gt; key in the config still points to it. Strata's docs call it experimental and note that needle tests separate scaling methods poorly; real long-document Q&amp;amp;A does. On our machine it found the needle at 520K tokens, and decoding stayed the same speed as at 262K. The KV cache is int8 with 32K tokens resident on the GPU, so the extra context costs RAM, not VRAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibrate for your PC
&lt;/h2&gt;

&lt;p&gt;Strata has a calibration that measures a few engine settings on your hardware and keeps only changes that are more than 3% faster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./setup.sh &lt;span class="nt"&gt;--calibrate&lt;/span&gt; &lt;span class="nt"&gt;--no-start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--no-start&lt;/code&gt; it starts the server afterwards. Run it with the server stopped, after you set the VRAM reserve, and in the same way as the server (in &lt;code&gt;llm.slice&lt;/code&gt;, without a memory limit), because it loads the full model. Strata estimates 15-30 minutes. We called the same calibration function (&lt;code&gt;calibrate_config&lt;/code&gt; in &lt;code&gt;setup.py&lt;/code&gt;) from a small script, and it finished in about 11 minutes. On our machine it chose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--pcie-frac 0.35 --spec-min-p 0.70
--adapt-every 1 --adapt-swaps 160 --adapt-decay 0.97
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--adapt-*&lt;/code&gt; settings drive the adaptive expert cache: how often it swaps experts between RAM and VRAM, how many per step, and how fast old usage counts fade. &lt;code&gt;--spec-min-p 0.70&lt;/code&gt; makes the MTP drafter stop earlier when it is unsure. The number of CPU workers (4 to 15) made no difference.&lt;/p&gt;

&lt;p&gt;Calibration reports its own numbers (86.8 tokens per second at the end here), but those are its internal measurements. I wanted one I control, so I ran an A/B with a restart between each arm, old config and new config, twice each. Back up the config before you calibrate, so you can do the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks
&lt;/h2&gt;

&lt;p&gt;How we measured: a client-side streaming bench against the OpenAI endpoint. Eight runs per arm, alternating a ~400-word Dutch prose prompt and a coding prompt (a binary search function with tests), greedy decoding, 512 output tokens, median reported. Prefill comes from a needle-in-a-haystack test at the given prompt length. The needle and filler text are fixed per length, so a later run can get a partial prefix-cache hit from an earlier one: read the prefill numbers as approximate. Nothing else heavy ran on the machine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;th&gt;Dutch prose / code&lt;/th&gt;
&lt;th&gt;Prefill (tok/s)&lt;/th&gt;
&lt;th&gt;Needle&lt;/th&gt;
&lt;th&gt;VRAM peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;262K context, default settings&lt;/td&gt;
&lt;td&gt;67.3&lt;/td&gt;
&lt;td&gt;72.6 / 64.6&lt;/td&gt;
&lt;td&gt;4,205 at 259K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;29.3 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512K context (YaRN 2)&lt;/td&gt;
&lt;td&gt;66.8&lt;/td&gt;
&lt;td&gt;71.8 / 62.5&lt;/td&gt;
&lt;td&gt;3,817 at 520K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;29.3 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512K, before calibration (A/B, 2 runs)&lt;/td&gt;
&lt;td&gt;70.9 / 70.9&lt;/td&gt;
&lt;td&gt;77.0 / 65.8 and 76.5 / 65.5&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512K, after calibration (A/B, 2 runs)&lt;/td&gt;
&lt;td&gt;78.3 / 79.1&lt;/td&gt;
&lt;td&gt;82.1 / 74.3 and 83.2 / 75.2&lt;/td&gt;
&lt;td&gt;3,196 at 265K&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;29.1 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows ran earlier that day, without the custom chat template described below; the A/B rows both ran with it. Only rows measured in the same session are a fair comparison, which is why the A/B pairs matter more than the first two rows.&lt;/p&gt;

&lt;p&gt;Prefill ranged from 3,196 to 4,205 tokens per second at about 260K tokens, before and after calibration. For TabbyAPI with the 4.05 bpw EXL3 quant, its server log reported about 68 tokens per second for decoding with the same 3 GB reserve, and the same needle test prefilled 254K tokens at about 1,780 tokens per second. That prefill figure was measured before the 3 GB reserve, with split 384. Server-log and client-stream decode figures are measured differently, so treat that as close, not one to one.&lt;/p&gt;

&lt;p&gt;For comparison, Strata's docs list 57-60 tokens per second for stories and 82 for code with the same quant on an AMD Radeon AI PRO R9700 (32 GB). That is a different card and a different bench, so do not compare them one to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality checks
&lt;/h2&gt;

&lt;p&gt;Speed only matters if the answers hold up. We ran two checks of our own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coding tasks with hidden tests.&lt;/strong&gt; Five tasks, three trials each, run in a sandbox, reasoning effort medium. Strata with UD-Q5_K_XL passed 15 of 15 (480 of 480 test cases). TabbyAPI with EXL3 4.05 bpw scored the same. These tasks turned out too easy to separate the two quants; the difference between them is speed and context, not this score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our own work.&lt;/strong&gt; A bench with nine tasks from our day job, each with an automatic checker: two Astro helpers in JavaScript, two WordPress PHP snippets run in a PHP sandbox, SEO meta tags as JSON, LocalBusiness JSON-LD, a Search Console tool call, a Search Console quick-win analysis, and a Dutch blog intro. Two rounds per setting, 108 points maximum:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reasoning effort&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Time per round&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;100 / 108&lt;/td&gt;
&lt;td&gt;62 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;107 / 108&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;108 / 108&lt;/td&gt;
&lt;td&gt;221 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;xhigh&lt;/td&gt;
&lt;td&gt;90 / 108&lt;/td&gt;
&lt;td&gt;577 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;More thinking was not better: xhigh hit the token limit twice and produced a wrong tool call, at 2.6 times the time of medium. Strata's API defaults to high, so we send &lt;code&gt;"reasoning_effort": "medium"&lt;/code&gt; from our clients. Sampling did not matter on this bench either: greedy, temperature 0.7 and temperature 1.0 all scored 108 at the same speed. Without a &lt;code&gt;sampling&lt;/code&gt; block in its config, Strata decodes greedy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A custom chat template, if you need one
&lt;/h2&gt;

&lt;p&gt;We write a lot of Dutch, and the model likes to mix English words into Dutch text. A short rule in the system prompt helps. Strata renders &lt;code&gt;chat_template.jinja&lt;/code&gt; from the folder that the &lt;code&gt;tokenizer&lt;/code&gt; key in its config points to. So you can make a folder with symlinks to the pack's tokenizer files plus your own &lt;code&gt;chat_template.jinja&lt;/code&gt;, and point &lt;code&gt;tokenizer&lt;/code&gt; there. Leave the files in the pack itself alone, so a setup run or update does not overwrite your changes or trip over them.&lt;/p&gt;

&lt;p&gt;Two things to know. Strata passes only &lt;code&gt;enable_thinking&lt;/code&gt; and &lt;code&gt;reasoning_effort&lt;/code&gt; from &lt;code&gt;chat_template_kwargs&lt;/code&gt; to the template, so your own template variables cannot be switched per request: a template change means a config change and a restart. And measure it: in our A/B with restarts, the rule cut English words in 30 Dutch answers from 28 to 13, with no measurable speed difference on our use-case bench (79.8 vs 80.3 tokens per second) and about 350 extra prompt tokens, which the prompt cache absorbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls we ran into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free RAM is the real limit.&lt;/strong&gt; Strata leaves about 4 GB of RAM free on this machine. A browser with many tabs or a second agent process is enough to push part of the engine into zram. When that happened, the server log showed decoding at 40-55 instead of about 80 tokens per second. Check with &lt;code&gt;free -g&lt;/code&gt;, &lt;code&gt;swapon --show&lt;/code&gt; and the &lt;code&gt;VmSwap&lt;/code&gt; line in &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; of the engine process. Run it on its own: not next to another model runtime, a game or a GPU miner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four DIMMs at EXPO speed were not stable.&lt;/strong&gt; Our four sticks are rated for 6,000 MT/s, and at that speed the machine logged "Bad page state" errors and kernel oopses, and crashed. Worse: most of a model download written in that period was corrupt on disk. We now run the second EXPO profile at 5,600 MT/s, and we check every model file against the SHA-256 published on Hugging Face. Strata's setup does that for its own downloads; do it yourself for anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restart only after the VRAM is free.&lt;/strong&gt; After stopping a runtime, the VRAM takes a few seconds to come back. Starting the next one too early fails on a VRAM check, or leaves less VRAM for the expert cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure with fresh prompts.&lt;/strong&gt; Strata caches prompt prefixes. Repeating the same long prompt gives absurd prefill numbers. Change the prompt (or at least its start) for every prefill run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The same 128 GB also runs GLM-5.3-Flash, a 321B model, through Project Maya, a fork of Strata built for that model. It runs at about 40% of the speed of this setup (about 30 tokens per second since Maya 1.1.13) but is a much bigger model, and it reads up to 512K tokens without using more VRAM. I wrote a companion article about that setup and its numbers: &lt;a href="https://dev.to/seppegadeyne/run-glm-53-flash-321b-locally-with-project-maya-and-exl3-on-one-rtx-5090-24pj"&gt;Run GLM-5.3-Flash (321B) locally with Project Maya and EXL3 on one RTX 5090&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Update, October 21, 2026: the same machine now also runs &lt;a href="https://dev.to/seppegadeyne/run-deepseek-v41-flash-748b-locally-with-project-maya-on-one-rtx-5090-4i4a"&gt;DeepSeek-V4.1-Flash (748B) with Project Maya&lt;/a&gt;, and all four local models are compared on the same day in &lt;a href="https://dev.to/seppegadeyne/four-big-local-llms-on-one-rtx-5090-and-128-gb-of-ram-benchmarked-side-by-side-2h8j"&gt;Four big local LLMs on one RTX 5090 and 128 GB of RAM, benchmarked side by side&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are deciding what hardware to buy for running AI locally, &lt;a href="https://voltti.ai/en/insights/running-ai-locally-what-computer-do-you-really-need" rel="noopener noreferrer"&gt;Voltti has a practical guide&lt;/a&gt; on the trade-offs between local and cloud AI for business use.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localllm</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Website performance: 9 steps to a faster site, in the right order</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Fri, 16 Oct 2026 10:45:50 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/website-performance-9-steps-to-a-faster-site-in-the-right-order-1o9e</link>
      <guid>https://dev.to/seppegadeyne/website-performance-9-steps-to-a-faster-site-in-the-right-order-1o9e</guid>
      <description>&lt;p&gt;Most slow websites don't need another caching plugin. They need a diagnosis, one fix for the biggest delay, and a second measurement that proves the fix worked.&lt;/p&gt;

&lt;p&gt;This is the sequence I use when I make a site faster, including my own site, &lt;a href="https://straffesites.com/en" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt;. It's written for developers who maintain marketing sites, blogs, or content-heavy frontends. The order is deliberate: compressing a heavy hero image does more than optimizing twenty tiny icons, and fixing a slow server matters more than polishing a loading animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Measure first rather than relying on guesswork
&lt;/h2&gt;

&lt;p&gt;Start with at least two kinds of measurement. A lab test such as Lighthouse or WebPageTest gives you a reproducible snapshot. Field data from the Chrome UX Report (CrUX) or your own real-user monitoring shows what visitors experience on slower devices and connections. The two don't need to match because they answer different questions. When I measured 19 website builders, the gap was bigger than I expected: &lt;a href="https://straffesites.com/en/blog/lab-vs-field-pagespeed" rel="noopener noreferrer"&gt;lab LCP diverged from the CrUX LCP by up to 16.7x&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Don't stop at one total score. Note the &lt;strong&gt;Largest Contentful Paint (LCP)&lt;/strong&gt; for the most important visible element, &lt;strong&gt;Interaction to Next Paint (INP)&lt;/strong&gt; for responsiveness, and &lt;strong&gt;Cumulative Layout Shift (CLS)&lt;/strong&gt; for visual stability. The thresholds come from Google: "LCP should occur within 2.5 seconds of when the page first starts loading", "INP of 200 milliseconds or less" and "CLS of 0.1" (&lt;a href="https://web.dev/articles/vitals" rel="noopener noreferrer"&gt;web.dev, Core Web Vitals&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Assess the 75th percentile of each metric separately for mobile and desktop. A page passes when LCP, INP, and CLS each meet their threshold at that percentile. Averages don't count, and it doesn't mean the same 75% of page loads pass all three.&lt;/p&gt;

&lt;p&gt;Also record the server response, total JavaScript weight, and the largest network requests. That's your baseline.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Test mobile and desktop on a cold cache.&lt;/li&gt;
&lt;li&gt;Measure a homepage, a content page, and a conversion page separately.&lt;/li&gt;
&lt;li&gt;Keep the waterfall; it shows waiting time and dependencies.&lt;/li&gt;
&lt;li&gt;Repeat the same test after every meaningful change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Lower the waiting time before the first HTML
&lt;/h2&gt;

&lt;p&gt;If the browser waits too long for the first document, no frontend optimization can fully hide the delay. Check the Time to First Byte (TTFB). Slow database queries, heavy server-side plugins, a distant hosting region, or dynamic rendering without a cache can delay every page before the browser even starts.&lt;/p&gt;

&lt;p&gt;For mostly static marketing pages, static generation is often the simplest win: the HTML is prepared in advance and served from a global network. For dynamic applications, targeted server caching helps, but never cache personal or shopping cart data blindly. Measure the origin directly as well, because a CDN can temporarily mask a slow source.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make the main image as light as its function allows
&lt;/h2&gt;

&lt;p&gt;Images often account for most of a page's weight, and the hero image is frequently the LCP element too. HTTP Archive's yearly measurement leaves little doubt: the median mobile home page weighed 2,559 KB in July 2025 (&lt;a href="https://almanac.httparchive.org/en/2025/page-weight" rel="noopener noreferrer"&gt;Web Almanac 2025, Page Weight&lt;/a&gt;). The 2024 edition measured the median mobile home page in October 2024 at 900 KB of images and 558 KB of JavaScript (&lt;a href="https://almanac.httparchive.org/en/2024/page-weight" rel="noopener noreferrer"&gt;Web Almanac 2024, Page Weight&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Never deliver a 3000-pixel-wide photo to a 720-pixel box. Generate responsive variants, use a modern format such as WebP or AVIF, and choose the compression level based on the image type. A soft photograph tolerates more compression than an interface image with sharp text.&lt;/p&gt;

&lt;p&gt;Don't lazy-load the main image above the fold; the browser should discover it immediately. Images further down the page can load lazily. Always include &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; so the browser reserves space in advance and the layout doesn't shift.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Above the fold: discoverable right away, space reserved --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/hero-1440.avif"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1440"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"810"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Further down: load when needed --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/detail-720.webp"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"720"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"405"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No single format always wins. Choose based on the content, browser support, and whether you actually need transparency or animation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Watch out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;Photos and complex illustrations&lt;/td&gt;
&lt;td&gt;Small file at high quality&lt;/td&gt;
&lt;td&gt;Encoding takes longer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;General web use&lt;/td&gt;
&lt;td&gt;Broadly supported and compact&lt;/td&gt;
&lt;td&gt;Sometimes larger than AVIF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG&lt;/td&gt;
&lt;td&gt;Logos, icons, and diagrams&lt;/td&gt;
&lt;td&gt;Sharp at every resolution&lt;/td&gt;
&lt;td&gt;Not suitable for photos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;Lossless graphics and transparency&lt;/td&gt;
&lt;td&gt;Exact pixels and alpha&lt;/td&gt;
&lt;td&gt;Often unnecessarily heavy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  4. Don't let critical CSS wait for the rest
&lt;/h2&gt;

&lt;p&gt;The browser needs CSS to draw the first viewport correctly. One large stylesheet with rules for every page delays that moment. Remove unused rules, split the CSS where it makes sense, and avoid a chain of &lt;code&gt;@import&lt;/code&gt; files. Keep the critical path smaller than the full component library so relevant content appears sooner.&lt;/p&gt;

&lt;p&gt;Inlining critical CSS can help in large applications, but it also adds maintenance work. Start with a simpler setup: one well-cached stylesheet, local fonts, and no blocking external styles. Then check whether splitting it further produces a measurable gain.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ship less JavaScript and run it later
&lt;/h2&gt;

&lt;p&gt;JavaScript costs more than download time. The browser must parse, compile, and execute it, usually on the same main thread that also handles your visitor's input. A bundle that feels harmless on your laptop can cause a noticeable delay on an average phone.&lt;/p&gt;

&lt;p&gt;For responsiveness, Google now measures INP instead of First Input Delay: "INP is the successor metric to First Input Delay (FID)" (&lt;a href="https://web.dev/articles/inp" rel="noopener noreferrer"&gt;web.dev, Interaction to Next Paint&lt;/a&gt;). Optimizing for an old FID score today means optimizing for a metric that is no longer assessed.&lt;/p&gt;

&lt;p&gt;Remove libraries that solve one small interaction, load page-specific code only where it's needed, and use native HTML for accordions, forms, and video where possible. Add &lt;code&gt;defer&lt;/code&gt; to non-critical scripts and initialize heavy widgets only after a deliberate interaction. The fastest code is still the code you don't ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Limit web fonts without losing your identity
&lt;/h2&gt;

&lt;p&gt;Five families and twelve weights are rarely necessary. Choose one strong display family and one readable body family, subset the characters you use, and load only the weights that appear on the page. Host fonts locally when you want control over caching, privacy, and availability.&lt;/p&gt;

&lt;p&gt;Preload only the font needed directly above the fold. Too many preloads compete with the main image and CSS. Use &lt;code&gt;font-display: swap&lt;/code&gt; so text stays visible while the font arrives, and choose fallback fonts with similar proportions to limit shifting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Lighthouse run shows on a static site
&lt;/h2&gt;

&lt;p&gt;Numbers only matter when you can check them. This is a summary of the Lighthouse CLI report I ran on the straffesites.com homepage on September 4, 2026, under emulated mobile conditions. The site is a static Astro build on Vercel, so treat it as one lab snapshot, not field data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Lighthouse 13.4.1 — https://straffesites.com/
Tested September 4, 2026 · Chrome 143 (headless) · emulation: Moto G Power, slow 4G (simulated throttling)

## Performance 100/100

| Metric | Value | Assessment |
|--------|-------|------------|
| First Contentful Paint | 1.3 s | Good |
| Largest Contentful Paint | 1.7 s | Good |
| Total Blocking Time | 0 ms | Good |
| Cumulative Layout Shift | 0.014 | Good |
| Speed Index | 1.3 s | Good |

This is a laboratory run, not CrUX field data: the scores describe how the page performs under controlled network and device conditions, not what every visitor experiences today.

## What the run confirms

- Root document served in 90 ms (server-response-time: 90 ms).
- 16 network requests, 502 KiB transferred in total (total-byte-weight).
- No render-blocking requests with savings left (render-blocking: no savings).
- Main-thread work 0.8 s, JS boot-up time 0.0 s — there is almost no JavaScript.
- CLS 0.014: image and font dimensions are reserved, nothing shifts while loading.
- The LCP element is the top case image; it is discovered and loaded without delay.

## Remaining opportunities (outside the score)

| Audit | Savings | Detail |
|-------|---------|--------|
| Improve image delivery | 272 KiB | Case images are served larger than their rendered size; WebP at the exact render size or a responsive srcset saves the most |
| Reduce unused CSS | 11 KiB | One inlined @font-face rule carries glyphs the homepage never uses |

Both opportunities are optimizations, not defects: no audit pulls the score below 100. A 100 does not mean "done forever" — every new image, script, and font can drag the next run back down. That is why we measure again every month.

---

Run environment: Lighthouse CLI 13.4.1, Node 26, headless Chrome on Linux, performance category only.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even a perfect lab score still lists image work. That's the point of step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Use caching and a CDN as accelerators, not bandages
&lt;/h2&gt;

&lt;p&gt;Set long cache headers for assets with unique, hashed file names. When the content changes, the hash changes and the browser automatically gets the new version. HTML usually needs a shorter cache period because current content matters more than maximum retention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# Hashed assets: safe to cache for a year
Cache-Control: public, max-age=31536000, immutable

# HTML: always revalidate
Cache-Control: public, max-age=0, must-revalidate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CDN brings files closer to visitors and absorbs traffic peaks. But it doesn't make a file smaller and doesn't remove blocking code. Fix the weight and dependencies first, then distribute the efficient version.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Treat every external script as a performance budget
&lt;/h2&gt;

&lt;p&gt;Chat widgets, heatmaps, ad pixels, video players, and tag managers often come from other servers. You don't control their speed, and one tag manager can quietly start dozens of requests. Keep an inventory of every external script with an owner and a clear purpose.&lt;/p&gt;

&lt;p&gt;Load marketing tags only after consent. Show a local video poster first, then load the external player after a click. Remove tools that nobody uses. Treat "can this script be added?" as seriously as "can this feature be added?"&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Make speed part of every release
&lt;/h2&gt;

&lt;p&gt;A one-off optimization doesn't stay effective on its own. New images, tags, and components gradually increase the page weight. Set budgets for JavaScript, images, and Core Web Vitals, make the build warn when a budget is exceeded, and recheck the most important templates after a major change.&lt;/p&gt;

&lt;p&gt;Review field data and segments each month: mobile versus desktop, new versus returning visitors, and slow versus fast connections. An average can hide a poor experience for mobile users. Performance work is done when it's monitored, not when a single Lighthouse run turns green.&lt;/p&gt;

&lt;p&gt;Assign every warning to an owner. A red number without a responsible person can stay unresolved, while a ticket with the URL, the regression, and the expected budget is easier to discuss. For new components, include performance in the acceptance criteria: how much JavaScript is added, which image becomes the LCP element, and what happens on a slow connection? That turns speed from a reactive fix into a normal design decision.&lt;/p&gt;

&lt;p&gt;Don't forget reliability. A fast first render that then freezes because of a faulty script is still a poor experience. Check console errors, failed requests, and timeouts alongside the vitals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to tackle first on Monday
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a baseline.&lt;/strong&gt; Note LCP, INP, CLS, server response, and the three heaviest requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix one big bottleneck.&lt;/strong&gt; Usually the main image, server work, or blocking JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the same page again.&lt;/strong&gt; Prove the gain before starting the next optimization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That keeps the work verifiable, and you can explain why a change got priority. You don't end up with a pile of plugins, but with a measurably faster site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Adapted for DEV from &lt;a href="https://straffesites.com/en/blog/website-performance" rel="noopener noreferrer"&gt;Website performance: improve your site speed in 9 clear steps&lt;/a&gt;, originally published on straffesites.com.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>frontend</category>
      <category>webperf</category>
    </item>
  </channel>
</rss>
