<?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>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.&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 below.&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 about 30 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;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 48 plugin copies (39 files) across our 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. One count in its final summary (56 patched plugin copies) didn't match the earlier report (48), and I haven't fully reconciled that difference yet. 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 92 GiB of weights 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 and most reliable model on this hardware.&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. 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, 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; &lt;span class="nt"&gt;-F3&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="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; &lt;span class="nt"&gt;-F3&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;md5sum /RunCloud/Packages/php74rc/sbin/php-fpm /proc/$(systemctl show -p MainPID --value php74rc-fpm)/exe&lt;/code&gt;.&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.30 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.30 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;The PHP vulnerability and 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.2 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;72%&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.&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 looks like the version, not the drafter. 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; on 1.1.17 with DSpark, about four 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: 784 seconds per round on average, against 103 seconds for Qwen on Strata, because it writes more (about 15,700 tokens per round, against 9,000) at about a quarter of the speed.&lt;/p&gt;

&lt;p&gt;I measured the other three models on this machine on the same day 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 quarter 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;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. 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;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;tr&gt;
&lt;td&gt;Ornith-1.5-122B, TabbyAPI&lt;/td&gt;
&lt;td&gt;37 / 54&lt;/td&gt;
&lt;td&gt;46 / 54&lt;/td&gt;
&lt;td&gt;83 / 108&lt;/td&gt;
&lt;td&gt;485&lt;/td&gt;
&lt;td&gt;~22,600&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On October 21 the totals were 108, 108, 105 and 89, in the same order.&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; 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.&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 22,600 tokens, mostly reasoning, and takes about 8 minutes. GLM writes the least, about 3,900 tokens, which is why it is quick despite its slower decode.&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. On our tasks it scored the lowest, partly because of stray tokens in its output, so we read what it writes before we run it.&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 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. Tuning per model could change the quality ranking.&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;This is the second of two articles. The first one 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;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 have not measured that yet.&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: 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.&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). Until one of them is merged, 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": 524288, "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. 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 is a third of the speed of this setup but 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;). A year earlier, that median home page included 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 the unedited 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 |
| Time to Interactive | 1.7 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>
    <item>
      <title>Run Qwen3.8-Flash-Next Locally with EXL3 and TabbyAPI on 40+ GB of RAM</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Sun, 04 Oct 2026 17:09:23 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d</link>
      <guid>https://dev.to/seppegadeyne/run-qwen38-flash-next-locally-with-exl3-and-tabbyapi-on-40-gb-of-ram-3m8d</guid>
      <description>&lt;p&gt;&lt;strong&gt;Qwen3.8-Flash-Next is a 125B mixture-of-experts model, and you can run it locally on one consumer GPU.&lt;/strong&gt; This guide shows the exact route we use on our own workstation: TabbyAPI as the server, ExLlamaV3 as the inference engine, and turboderp's 3.05 bpw EXL3 quant. The machine is a Ryzen 9 9950X3D with an RTX 5090 (32 GB), 64 GB of DDR5-6000, and Arch Linux. Every number below was measured on that machine. Where a figure comes from someone else, I name the source.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, October 2026:&lt;/strong&gt; the workstation now has 128 GB of RAM. With that, I run the same model with a 5-bit quant at about 79 tokens per second: &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;. The same machine also runs the 321B GLM-5.3-Flash: &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;. A &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) guide&lt;/a&gt; and 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 local models&lt;/a&gt; followed. This guide stays valid for 64 GB machines.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You get an OpenAI-compatible endpoint on &lt;code&gt;localhost&lt;/code&gt; that serves a 262,144-token context at about 50 tokens per second. The trade-off: the model needs more than your GPU alone. About 26 GB sits in VRAM, and about 43 GB of expert weights live in system RAM, where the CPU computes them. That is why the title says 40+ GB of RAM. In practice, plan for 64 GB installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Qwen3.8-Flash-Next is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/Qwen/Qwen3.8-Flash-Next" rel="noopener noreferrer"&gt;Qwen3.8-Flash-Next&lt;/a&gt; is an open-weight model from the Qwen team. The model card lists 125B parameters, of which about 6B are active per token, plus a 51B n-gram embedding table and a 4B multi-token prediction (MTP) layer. Each of the 48 layers has 512 routed experts, and 10 of them are picked per token.&lt;/p&gt;

&lt;p&gt;That design makes it a good fit for a single GPU with CPU offload. Only a small part of the model does work for each token, so the experts that are rarely used can sit in system RAM without slowing every step. The n-gram table is a lookup, not a compute step, and ExLlamaV3 can stream it from disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why EXL3 and TabbyAPI
&lt;/h2&gt;

&lt;p&gt;EXL3 is the quantization format of &lt;a href="https://github.com/turboderp-org/exllamav3" rel="noopener noreferrer"&gt;ExLlamaV3&lt;/a&gt;. According to the project README, it is a streamlined variant of &lt;a href="https://github.com/Cornell-RelaxML/qtip" rel="noopener noreferrer"&gt;QTIP&lt;/a&gt;, a trellis-based method from Cornell RelaxML. The practical gain is quality per bit. On the &lt;a href="https://huggingface.co/turboderp/Qwen3.8-Flash-Next-exl3" rel="noopener noreferrer"&gt;model page of the EXL3 quants&lt;/a&gt;, turboderp publishes a KL divergence chart for this model. The 3.05 bpw quant scores 0.0177 there, against 0.0349 for the UD-IQ3_XXS GGUF quant at a similar size. Lower means closer to the original model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/theroyallab/tabbyAPI" rel="noopener noreferrer"&gt;TabbyAPI&lt;/a&gt; is the official API server for ExLlamaV3. It exposes an OpenAI-compatible API, so editors, chat clients, and agent frameworks that speak that API can use your local model without code changes. It also wires up the ExLlamaV3 options this model needs: CPU expert offload, the n-gram table, and MTP drafting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware requirements
&lt;/h2&gt;

&lt;p&gt;This is the hardware we measured on, and what the model used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPU: RTX 5090 with 32 GB of VRAM. About 26 GB in use with the config below.&lt;/li&gt;
&lt;li&gt;System RAM: 64 GB of DDR5-6000. About 43 GB resident for the CPU expert arena.&lt;/li&gt;
&lt;li&gt;CPU: Ryzen 9 9950X3D (16 cores, 32 threads). The CPU computes the cold experts, so core count matters.&lt;/li&gt;
&lt;li&gt;Disk: an NVMe drive with room for the 85.1 GB download. The n-gram table is read from it during inference.&lt;/li&gt;
&lt;li&gt;OS: Arch Linux with a current NVIDIA driver.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things did not work well. With 60 GB of RAM it gets tight as soon as a browser and other heavy programs run next to the model. And once part of the arena lands in zram or swap, decoding collapses, as described in the pitfalls below. Treat 64 GB as the realistic minimum for this configuration, and run the model on a machine that is not doing other heavy work at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install TabbyAPI with the prebuilt exllamav3 wheel
&lt;/h2&gt;

&lt;p&gt;Clone TabbyAPI and create a Python 3.11 virtual environment inside it:&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/theroyallab/tabbyAPI
&lt;span class="nb"&gt;cd &lt;/span&gt;tabbyAPI
python3.11 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;".[cu12]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cu12&lt;/code&gt; extra installs PyTorch 2.9.0 (CUDA 12.8) and the prebuilt exllamav3 1.5.1 wheel for your Python version. At the time of writing, TabbyAPI's main branch pins exllamav3 1.5.1. Check that both landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip show exllamav3 torch | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(Name|Version)'&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import torch; print(torch.cuda.is_available())"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;exllamav3&lt;/code&gt; at &lt;code&gt;1.5.1+cu128.torch2.9.0&lt;/code&gt; and &lt;code&gt;True&lt;/code&gt; for CUDA.&lt;/p&gt;

&lt;p&gt;Use the prebuilt wheel rather than a just-in-time (JIT) build. The wheel is faster to set up, and on our system the JIT build of the CUDA extension broke on GCC 16. That build only went through after adding &lt;code&gt;-Xcompiler -Wno-template-body&lt;/code&gt; to the compiler flags. With the wheel you skip the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download the 3.05 bpw EXL3 quant
&lt;/h2&gt;

&lt;p&gt;The quants live in one Hugging Face repository, with one branch per size. This guide uses the &lt;code&gt;3.05bpw_h5_ng5&lt;/code&gt; revision: 3.05 bits per weight for the experts, a 5-bit output head, and a 5-bit n-gram table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf download turboderp/Qwen3.8-Flash-Next-exl3 &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--revision&lt;/span&gt; 3.05bpw_h5_ng5 &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--local-dir&lt;/span&gt; models/qwen3.8-flash-next-exl3-3.05bpw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hf&lt;/code&gt; command comes with &lt;code&gt;huggingface_hub&lt;/code&gt;, which TabbyAPI installs into the venv. Older guides use &lt;code&gt;huggingface-cli download&lt;/code&gt;, but recent &lt;code&gt;huggingface_hub&lt;/code&gt; versions no longer run that command. TabbyAPI also has its own downloader (&lt;code&gt;./start.sh download &amp;lt;repo&amp;gt; --revision &amp;lt;branch&amp;gt;&lt;/code&gt;) if you prefer that.&lt;/p&gt;

&lt;p&gt;The download is 85.1 GB. One file stands out: &lt;code&gt;ngram_embedding.safetensors&lt;/code&gt; is 32.6 GB on its own. Keep the folder on NVMe, because that table is streamed from disk while the model runs. The folder name becomes the model name in TabbyAPI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure config.yml and a sampling preset
&lt;/h2&gt;

&lt;p&gt;Copy &lt;code&gt;config_sample.yml&lt;/code&gt; to &lt;code&gt;config.yml&lt;/code&gt; and change the keys below. The rest can stay at the defaults. These are the values we run and measured:&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;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1&lt;/span&gt;
 &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5000&lt;/span&gt;
 &lt;span class="na"&gt;disable_auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;model_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;models&lt;/span&gt;
 &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen3.8-flash-next-exl3-3.05bpw&lt;/span&gt;
 &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;exllamav3&lt;/span&gt;
 &lt;span class="na"&gt;max_seq_len&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;262144&lt;/span&gt;
 &lt;span class="na"&gt;cache_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;262144&lt;/span&gt;
 &lt;span class="na"&gt;cache_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Q8&lt;/span&gt;
 &lt;span class="na"&gt;gpu_split_auto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
 &lt;span class="na"&gt;autosplit_reserve&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1000&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
 &lt;span class="na"&gt;cpu_moe_split_experts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;
 &lt;span class="na"&gt;cpu_moe_threads&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt;
 &lt;span class="na"&gt;ngram_ram&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
 &lt;span class="na"&gt;reasoning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;draft_model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;draft_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mtp&lt;/span&gt;
 &lt;span class="na"&gt;draft_num_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
 &lt;span class="na"&gt;dynamic_draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;sampling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;override_preset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen3_8_flash_next&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the important keys do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;backend: exllamav3&lt;/code&gt; selects the engine. The value is &lt;code&gt;exllamav3&lt;/code&gt;, not &lt;code&gt;exl3&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cpu_moe_split_experts: 400&lt;/code&gt; keeps the 400 coldest of the 512 experts per layer in system RAM and computes them on the CPU. The hot experts stay in VRAM, and ExLlamaV3 adjusts the placement while it runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cpu_moe_threads: 24&lt;/code&gt; sets the CPU worker threads for those experts. Without it, ExLlamaV3 uses half the core count.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ngram_ram: false&lt;/code&gt; streams the 51B n-gram table from disk. Loading it into RAM would need tens of gigabytes more than a 64 GB machine has left.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache_mode: Q8&lt;/code&gt; stores the KV cache in 8 bits instead of 16, which halves its size for the full 262,144-token context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autosplit_reserve: [1000]&lt;/code&gt; leaves 1,000 MB of VRAM free on the GPU for the desktop and CUDA overhead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;draft_mode: mtp&lt;/code&gt; uses the model's own MTP layer for speculative decoding. No separate draft model is needed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disable_auth: true&lt;/code&gt; is only acceptable because the server listens on &lt;code&gt;127.0.0.1&lt;/code&gt;. If you open it to your network, turn authentication back on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now create the sampling preset in &lt;code&gt;sampler_overrides/qwen3_8_flash_next.yml&lt;/code&gt;. TabbyAPI finds it by name in that folder:&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;temperature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;
 &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;top_k&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
 &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;top_p&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.95&lt;/span&gt;
 &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;min_p&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
 &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.0&lt;/span&gt;
 &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These values are fallbacks. A request that sends its own sampler settings still overrides them. Skipping this preset is one of the pitfalls below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start the server and send a first request
&lt;/h2&gt;

&lt;p&gt;Start TabbyAPI from the project folder with the venv active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a warm NVMe, the model is loaded in about two minutes on our machine. A first load from a cold disk can take longer. Check that the server is up and the model is loaded:&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:5000/health
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:5000/v1/models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send a first chat request to the OpenAI-compatible endpoint:&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:5000/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": "qwen3.8-flash-next-exl3-3.05bpw",
 "messages": [{"role": "user", "content": "Write a Python function that checks whether a string is a palindrome."}],
 "max_tokens": 4000
 }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model reasons before it answers. TabbyAPI splits that reasoning into a separate &lt;code&gt;reasoning_content&lt;/code&gt; field, and the answer arrives in &lt;code&gt;content&lt;/code&gt;. Note the high &lt;code&gt;max_tokens&lt;/code&gt;. The reason is explained in the pitfalls.&lt;/p&gt;

&lt;p&gt;The server log prints the speed per request, and with MTP drafting also the acceptance rate of the drafted tokens. That log is the source for all the measurements in the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sanity check before you measure
&lt;/h3&gt;

&lt;p&gt;Before you trust any tokens-per-second figure, check that the model actually fits. Paging does not slow decoding down a little: once part of the expert arena lands in swap, it falls off a cliff (more on that in the pitfalls). Run these checks right after the model loads, and again during or after your first long request, because that is when the KV cache and the expert arena fill up.&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;# si and so should stay at 0 while the model decodes&lt;/span&gt;
vmstat 1 10

&lt;span class="c"&gt;# swap used by the TabbyAPI process: should be 0 kB or close to it&lt;/span&gt;
&lt;span class="nb"&gt;grep &lt;/span&gt;VmSwap /proc/&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="s2"&gt;"python main.py"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/status

&lt;span class="c"&gt;# VRAM in use: about 26 GB with the config above&lt;/span&gt;
nvidia-smi &lt;span class="nt"&gt;--query-gpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory.used,memory.total &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;si&lt;/code&gt; and &lt;code&gt;so&lt;/code&gt; columns of &lt;code&gt;vmstat&lt;/code&gt; show how much memory moves in from and out to swap each second. A steady 0 there tells you more than a stable RSS in htop, because RSS only shows what is still in RAM, not what has already been pushed out. In &lt;code&gt;free -h&lt;/code&gt;, look at the &lt;code&gt;available&lt;/code&gt; column rather than &lt;code&gt;free&lt;/code&gt;: the n-gram table is streamed from disk, so the page cache grows and free memory shrinks without anything being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance tuning: our measured A/B results
&lt;/h2&gt;

&lt;p&gt;We tuned three settings one at a time, with greedy decoding, reading the figures from the TabbyAPI server log. All runs were done with nothing else heavy running on the machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU threads for the expert arena
&lt;/h3&gt;

&lt;p&gt;The thread count for the CPU experts had the biggest effect on decoding. More threads is not always better: at 32 threads the SMT siblings compete for the same cores, and decoding got slower than at 24.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decode speed by cpu_moe_threads (median, tokens per second)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;cpu_moe_threads&lt;/th&gt;
&lt;th&gt;Decode (tok/s)&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;48.1&lt;/td&gt;
&lt;td&gt;Fewer threads than the CPU can feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;51.7&lt;/td&gt;
&lt;td&gt;Best result; prefill 188 tok/s in this run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;47.6&lt;/td&gt;
&lt;td&gt;Slower due to SMT contention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a 16-core, 32-thread CPU, 24 threads was the sweet spot. On a different CPU, run the same test with your own core count.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many experts go to the CPU
&lt;/h3&gt;

&lt;p&gt;In a separate A/B run, we lowered &lt;code&gt;cpu_moe_split_experts&lt;/code&gt; from 430 to 400. That puts 30 more experts per layer on the GPU. Decoding stayed the same, prefill rose from 174 to 204 tokens per second, and the machine kept about 2 GB more free RAM. With 32 GB of VRAM, 400 fit with the 1,000 MB reserve. On a card with less VRAM you need a higher value, which means more RAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Draft depth for MTP speculative decoding
&lt;/h3&gt;

&lt;p&gt;With MTP drafting, the model proposes several tokens ahead and verifies them in one pass. &lt;code&gt;draft_num_tokens: 4&lt;/code&gt; with &lt;code&gt;dynamic_draft: true&lt;/code&gt; gave the best result. A draft depth of 6 was 4% slower. The acceptance rate stayed around 56–58%, so longer drafts mostly produced more tokens that were thrown away.&lt;/p&gt;

&lt;h3&gt;
  
  
  The end result on our machine
&lt;/h3&gt;

&lt;p&gt;With all three settings in place, we see about 48–52 tokens per second for decoding and about 175–205 tokens per second for prefill. The model loads in about two minutes from a warm NVMe. These figures were measured without other heavy workloads running.&lt;/p&gt;

&lt;p&gt;For context: the &lt;a href="https://github.com/turboderp-org/exllamav3/releases/tag/v1.5.0" rel="noopener noreferrer"&gt;ExLlamaV3 1.5.0 release notes&lt;/a&gt; list higher numbers for this model on an RTX 5090 paired with a Threadripper 7960X, with the n-gram table in RAM and a pinned arena. That is different hardware with more memory and different settings, so do not treat our numbers as the ceiling, or theirs as what a 64 GB desktop will reach.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Swap kills decoding
&lt;/h3&gt;

&lt;p&gt;If part of the expert arena is pushed into zram or swap, decoding falls from about 50 to about 2.4 tokens per second. The model keeps running, so it is easy to miss. The sanity check after startup catches this before you measure. To check the process on its own:&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;VmSwap /proc/&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="s2"&gt;"python main.py"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything far above zero means the arena no longer fits. Close other programs, keep an eye on available RAM with &lt;code&gt;free -h&lt;/code&gt;, and do not start the model next to other memory-hungry work.&lt;/p&gt;

&lt;h3&gt;
  
  
  No sampling preset, no stable output
&lt;/h3&gt;

&lt;p&gt;Without a sampling preset, requests that send no sampler values run with top_k 0 and top_p 1. In that state the model can drift into other languages mid-answer. The preset from step 03 (temperature 0.7, top_k 20, top_p 0.95) works well and keeps clients that send nothing on safe values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reasoning eats max_tokens
&lt;/h3&gt;

&lt;p&gt;Qwen3.8-Flash-Next thinks before it writes the answer, and those reasoning tokens count toward &lt;code&gt;max_tokens&lt;/code&gt;. With a low limit, the response can stop before the actual answer starts, leaving &lt;code&gt;content&lt;/code&gt; empty. Use at least 400 tokens, and 4,000 for real tasks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small config mistakes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;backend&lt;/code&gt; must be &lt;code&gt;exllamav3&lt;/code&gt;; &lt;code&gt;exl3&lt;/code&gt; is not a valid value. And run this model on its own: a second model, a game, or another GPU workload on the same machine takes VRAM or RAM the model needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it gets you
&lt;/h2&gt;

&lt;p&gt;The result is a strong coding model on hardware you control. On the &lt;a href="https://huggingface.co/Qwen/Qwen3.8-Flash-Next" rel="noopener noreferrer"&gt;Qwen model card&lt;/a&gt;, Qwen3.8-Flash-Next scores 58.7 on DeepSWE 1.1 and 62.5 on SWE-bench Pro. Those are the Qwen team's figures for the full model, not measurements of this quant. In our own comparison, it is the strongest model for coding tasks that we can run on this consumer machine. Nothing leaves your network, there is no per-token bill, and any tool that speaks the OpenAI API can connect, from a code editor to an AI agent running a workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternatives worth considering
&lt;/h2&gt;

&lt;p&gt;This setup is not the only route, and it is not always the best one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A smaller quant. The same repository has a 2.05 bpw version that needs less memory, at a clear cost in quality: turboderp's chart shows a KL divergence of 0.0684 for it, against 0.0177 for 3.05 bpw. We did not measure its speed or memory use.&lt;/li&gt;
&lt;li&gt;GGUF with llama.cpp. This route runs on more hardware, including Macs and CPU-only machines, and Unsloth publishes &lt;a href="https://unsloth.ai/docs/models/qwen3.8-next" rel="noopener noreferrer"&gt;GGUF quants with a run guide&lt;/a&gt;. In our comparison, that route was slower on this model than ExLlamaV3.&lt;/li&gt;
&lt;li&gt;A cloud API. If you do not have the hardware, or only need the model now and then, a hosted API costs less than a new GPU and a RAM upgrade. You give up the privacy and fixed costs of a local setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The short recipe: TabbyAPI in a Python 3.11 venv with the prebuilt exllamav3 1.5.1 wheel, the &lt;code&gt;3.05bpw_h5_ng5&lt;/code&gt; quant on NVMe, &lt;code&gt;backend: exllamav3&lt;/code&gt;, 400 experts per layer on the CPU with 24 threads, a Q8 cache, MTP drafting with 4 tokens, and a sampling preset. Watch swap, give reasoning room, and run the model on its own. On one RTX 5090 with 64 GB of RAM, that gives a 125B model at about 50 tokens per second. ExLlamaV3 and TabbyAPI move quickly, so check the &lt;a href="https://github.com/turboderp-org/exllamav3/releases" rel="noopener noreferrer"&gt;ExLlamaV3 releases&lt;/a&gt; for new versions before you start.&lt;/p&gt;

&lt;p&gt;Wondering what hardware your business needs to run AI locally? We wrote a practical guide: &lt;a href="https://voltti.ai/en/insights/running-ai-locally-what-computer-do-you-really-need" rel="noopener noreferrer"&gt;What computer do you really need to run AI locally?&lt;/a&gt;. It covers 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>A website without a CMS: Astro Content Collections in practice</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:42:14 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/a-website-without-a-cms-astro-content-collections-in-practice-17</link>
      <guid>https://dev.to/seppegadeyne/a-website-without-a-cms-astro-content-collections-in-practice-17</guid>
      <description>&lt;p&gt;I moved &lt;a href="https://straffesites.com/en" rel="noopener noreferrer"&gt;straffesites.com&lt;/a&gt; from Astro with Storyblok to Astro Content Collections with local MDX and JSON files. The site still has articles, case studies, landing pages, and Dutch and English versions. What it no longer has is a separate CMS for managing that content.&lt;/p&gt;

&lt;p&gt;I am the only person managing the content on straffesites.com, and I work with Hermes, an AI agent. Since I already work in Git, moving the content there was a useful simplification. Content changes now go through the same repository and build pipeline as template changes. I can inspect a paragraph edit next to the schema or component it depends on.&lt;/p&gt;

&lt;p&gt;This is a case study of that setup: how the content is organized, what the schemas check, how publishing works, and which CMS features I chose to give up. It is not a recommendation to make every editor learn Git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I removed Storyblok from this project
&lt;/h2&gt;

&lt;p&gt;Astro and Storyblok worked well together. I still use that combination for client projects where an editing interface is useful. On straffesites.com, however, I am the only human editing and managing the content. Hermes helps me work with the files and run checks. I did not need a visual editor for that workflow.&lt;/p&gt;

&lt;p&gt;Keeping a CMS meant maintaining content models in another system, managing my CMS account and its permissions, and configuring the integration that fetched content during builds. Code lived in Git; content lived in Storyblok. Changes that touched both required checking both.&lt;/p&gt;

&lt;p&gt;Working with Hermes was another part of the decision. The agent could already read the project, edit files, and run checks. To work with CMS content, it also needed an authenticated client and a separate content workflow. That integration was possible, but it was unnecessary for this project.&lt;/p&gt;

&lt;p&gt;Moving the content into Git removed the CMS-specific access layer. It did not remove repository permissions, deployment credentials, or the need to maintain the AI tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content Collections provide a content layer, not an editor
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.astro.build/en/guides/content-collections/" rel="noopener noreferrer"&gt;Astro Content Collections&lt;/a&gt; let you organize, validate, and query structured content. They can use local files, remote sources, or live data. The setup on straffesites.com uses local files loaded at build time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MDX for pages and articles.&lt;/li&gt;
&lt;li&gt;JSON for datasets, case metrics, people, and site settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are six collections in this project. You do not need that exact split to use the same approach.&lt;/p&gt;

&lt;p&gt;A collection combines a loader with a schema. The loader finds the entries; the schema defines the fields the templates can expect. Here is an excerpt from the article collection configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reference&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:content&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;glob&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/loaders&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;articleFrontmatterSchema&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;./content/schemas/article&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;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/content/articles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;articleFrontmatterSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeExtend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;people&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;superRefine&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&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="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;articleType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;glossary&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIssue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;custom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;path&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;author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog and case articles must include an author&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="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The full configuration also registers the people collection.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt; &lt;span class="cm"&gt;/*, pages, datasets, caseMetrics, people, settings */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a project-specific excerpt, not a complete starter configuration. It imports the article schema from another file, and the author reference depends on the &lt;code&gt;people&lt;/code&gt; collection being registered in the full configuration.&lt;/p&gt;

&lt;p&gt;The same article collection holds blog posts, case studies, and glossary entries. The refinement requires an author for blog posts and cases while allowing glossary entries without one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What validation catches
&lt;/h3&gt;

&lt;p&gt;The frontmatter schema defines fields such as the title, route, publication date, category, tags, summary, and FAQ items. Required fields must be present. A strict schema also rejects unknown field names, so a typo cannot silently become unused metadata.&lt;/p&gt;

&lt;p&gt;That gives the templates a defined content contract. It does not make the article correct.&lt;/p&gt;

&lt;p&gt;A schema can reject a missing title or a value that violates a configured rule. It cannot establish that an explanation is accurate, a source supports a claim, or an internal link is helpful. A nonempty summary also does not imply a sensible length unless you add a length limit.&lt;/p&gt;

&lt;p&gt;I use additional contract tests and build-output checks for rules that go beyond frontmatter. Content Collections alone do not check every link, component choice, or editorial requirement.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you lose without a CMS
&lt;/h3&gt;

&lt;p&gt;There is no visual editor in this local-file setup. Content Collections do not provide editor accounts, approval screens, a media library, or a publishing calendar.&lt;/p&gt;

&lt;p&gt;You can add previews and access controls around a Git workflow. You can also put a Git-based CMS in front of the files. That is a valid option, but it adds an editing interface; it is different from editing the files directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I organize the content
&lt;/h2&gt;

&lt;p&gt;The MDX files on straffesites.com are grouped by language and content type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/content/
├── pages/            # landing pages and hub pages (MDX)
│   ├── nl/           # Dutch-language pages
│   └── en/           # English pages
└── articles/
    ├── nl/
    │   ├── blog/      # blogs
    │   ├── case/      # case studies
    │   └── begrippen/ # glossary terms
    └── en/
        ├── blog/      # blogs
        ├── case/      # case studies
        └── glossary/ # glossary terms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON collections sit alongside these directories. The tree above only shows the MDX content.&lt;/p&gt;

&lt;p&gt;Each MDX entry starts with frontmatter. The body uses Markdown inside typed content-block components. On the original website, for example, this article's source file contains its metadata and body, while the page template renders shared elements such as the author card and related articles. The DEV version you are reading is an adapted Markdown crosspost, not that MDX file running inside Astro.&lt;/p&gt;

&lt;p&gt;For this project, the route comes from an explicit frontmatter field rather than the file's location. That lets me reorganize source files without treating every move as a URL change. The templates and tests still need to respect that convention; Astro does not infer the project's routing rules from the directory tree alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping translations connected
&lt;/h3&gt;

&lt;p&gt;Dutch and English pages have a shared pair registry in the repository. It records which routes are counterparts.&lt;/p&gt;

&lt;p&gt;Build checks can then verify that those routes exist and that the relationships are consistent. The files being next to each other is not enough: translation pairing is a rule I have to define and test.&lt;/p&gt;

&lt;p&gt;This is one advantage of keeping content and code together. The content, route relationships, and checks can change in the same diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing is a deployment workflow
&lt;/h2&gt;

&lt;p&gt;On straffesites.com, a push to &lt;code&gt;main&lt;/code&gt; triggers a Vercel build and deployment. Astro validates the content and renders static pages. A local commit by itself does not publish anything.&lt;/p&gt;

&lt;p&gt;The workflow is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Edit MDX or JSON
    -&amp;gt; inspect the diff
    -&amp;gt; run content and build checks
    -&amp;gt; commit and push
    -&amp;gt; verify the deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That diagram describes the work to do, not a security guarantee. Git does not force someone to inspect a diff, and a schema does not require human approval. Repository permissions and required checks determine what can reach production.&lt;/p&gt;

&lt;p&gt;Git also makes rollback understandable: revert the relevant change, build again, and deploy the result. Reverting a commit locally does not change the live site until the reverted version is deployed successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  The maintenance moves rather than disappearing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Local content in Git&lt;/th&gt;
&lt;th&gt;API-first headless CMS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Editing&lt;/td&gt;
&lt;td&gt;Files and repository tooling&lt;/td&gt;
&lt;td&gt;CMS dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content validation&lt;/td&gt;
&lt;td&gt;Configured schemas and project checks&lt;/td&gt;
&lt;td&gt;CMS field rules; project checks can also apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content history&lt;/td&gt;
&lt;td&gt;Diffs for committed changes&lt;/td&gt;
&lt;td&gt;CMS revisions, depending on the platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static publishing&lt;/td&gt;
&lt;td&gt;Build and deploy after a repository change&lt;/td&gt;
&lt;td&gt;Build and deploy after a CMS content change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Systems to maintain&lt;/td&gt;
&lt;td&gt;Dependencies, CI, hosting, and repository access&lt;/td&gt;
&lt;td&gt;Those systems plus the CMS integration and accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are common workflows, not fixed limits. A headless CMS can feed a fully static site, and a Git-based CMS can provide a dashboard while keeping content in files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I work with Hermes
&lt;/h2&gt;

&lt;p&gt;Hermes is the AI agent I work with on this project, not another human editor. It can read an MDX file, update a paragraph, and run the same checks used for code changes. It does not need a CMS-specific API key to edit checked-out files.&lt;/p&gt;

&lt;p&gt;For me, the useful part is how easy the changes are to inspect. A content edit can include its frontmatter, body, and related template changes in one diff. There is no separate CMS revision to reconstruct alongside the code change.&lt;/p&gt;

&lt;p&gt;Reading a local file avoids a CMS API request. It does not make model usage free: text sent to the model still consumes tokens. Nor does it prove that agents perform better with files than with a CMS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instructions are not access controls
&lt;/h3&gt;

&lt;p&gt;Project instructions can tell an agent which files to use, which commands to run, and what it must not change. They help it work within the project's conventions. They are not a security boundary.&lt;/p&gt;

&lt;p&gt;An agent with broad repository or deployment access can still make harmful changes. Limit its permissions, make checks explicit, and verify what it changed. For straffesites.com, I remain responsible for the scope and the published result, including when I authorize Hermes to deploy after checks.&lt;/p&gt;

&lt;p&gt;Plausible but false prose can pass every schema check. That is an editorial review problem, not something Zod can solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Removing a CMS does not automatically make a website faster
&lt;/h2&gt;

&lt;p&gt;I track straffesites.com through monthly PageSpeed measurements and Search Console data on its &lt;a href="https://straffesites.com/en/case/straffe-sites" rel="noopener noreferrer"&gt;public case page&lt;/a&gt;. Those measurements describe the deployed site. They are not a controlled test of whether removing Storyblok improved performance.&lt;/p&gt;

&lt;p&gt;A headless CMS can supply content during the build and produce the same static HTML. Visitors do not necessarily make a CMS request when they open that page.&lt;/p&gt;

&lt;p&gt;Removing the CMS API from my build reduced an external build dependency. Visitor performance still depends on the output: JavaScript, images, fonts, caching, and how the page renders.&lt;/p&gt;

&lt;p&gt;The change I can describe directly is operational. My content workflow no longer needs a CMS service, a separate editor account, or a CMS integration. Dependencies, hosting, and access control still need maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would keep the CMS
&lt;/h2&gt;

&lt;p&gt;For a client project, I would keep a CMS when the editing interface solves a problem the client or their editorial team actually has.&lt;/p&gt;

&lt;p&gt;If editors do not work in Git, asking them to maintain MDX may just move the burden onto them or the development team. Draft states, permissions, scheduling, and media management are useful features, especially when several people publish regularly.&lt;/p&gt;

&lt;p&gt;Translation workflows can also be more involved than maintaining two files and a route pair. Editors may need field-level translation status or separate approval steps. A CMS can provide those workflows without requiring the team to build them.&lt;/p&gt;

&lt;p&gt;Changing a content model is another consideration. In my setup, adding a new content type means changing a schema and its templates. A CMS dashboard may make field management easier, although frontend changes can still require development work.&lt;/p&gt;

&lt;p&gt;You can build versions of these features around Git. But if you need an editor, scheduling, media permissions, and approval screens, consider whether you are rebuilding the CMS you removed.&lt;/p&gt;

&lt;p&gt;For straffesites.com, direct file editing fits how I manage the content with help from Hermes. For a client with an editorial team, I may choose a CMS instead. I would start with who edits the content and what their publishing process needs, then choose the storage and tooling.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Adapted for DEV from &lt;a href="https://straffesites.com/en/blog/website-without-cms" rel="noopener noreferrer"&gt;A website without a CMS: Astro Content Collections in practice&lt;/a&gt;, originally published on straffesites.com.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>webdev</category>
      <category>cms</category>
      <category>git</category>
    </item>
    <item>
      <title>Automating SEO with AI: a self-improving content loop</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Mon, 28 Sep 2026 11:52:53 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/automating-seo-with-ai-a-self-improving-content-loop-2aka</link>
      <guid>https://dev.to/seppegadeyne/automating-seo-with-ai-a-self-improving-content-loop-2aka</guid>
      <description>&lt;p&gt;Everyone agrees you should "let AI do your SEO." Few people explain what that looks like when you actually build it. This is the setup I run on my own site: an AI agent with read-only access to Google Search Console, a dedicated service account via gcloud, and one small, verified improvement per run — every thirty days.&lt;/p&gt;

&lt;p&gt;The article below is a crosspost from my site. It covers the full loop: setup, prioritization, one-change-per-run, measurement, and the honest learning curve before anything runs on its own.&lt;/p&gt;

&lt;p&gt;Automating SEO with AI, in practice, means: an AI agent with read access to your Google Search Console that pulls the real search queries from your data every thirty days and makes one small, verified improvement to your pages. That is not a robot that lifts your website to the top of Google while you drink coffee — that robot does not exist. It is a loop: pull data, prioritize, improve, measure, and again thirty days later. Not everything at once, not on a hunch — one thing per run, measured and logged.&lt;/p&gt;

&lt;p&gt;That approach works because SEO is not a one-time project but a habit. Anyone who looks at the same data every month and makes one targeted intervention each time will see their pages gradually align better with what people actually search for. The agent makes that habit affordable: it does the heavy lifting — pulling data, prioritizing, preparing — while you make the decisions. At first you supervise every run. After a few months, most of it runs automatically on a &lt;a href="https://straffesites.com/en/glossary/cron" rel="noopener noreferrer"&gt;cron&lt;/a&gt; job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why automating SEO with AI makes sense now
&lt;/h2&gt;

&lt;p&gt;An agent like Hermes can query an API, read and write files, run a build, and inspect the result. For recurring GSC analysis, I combine those tools with a dedicated service account: gcloud handles the Cloud setup, and Search Console grants that account read access to the property. The agent can then retrieve data without waiting for me to sign in through a browser again.&lt;/p&gt;

&lt;p&gt;SEO is an ideal application for this, for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The source data is objective. Search Console shows literally how people find you: queries, impressions, clicks, positions. That gives the agent source data, but does not prevent hallucinations or incorrect interpretation. Check its conclusions against the export; anonymized queries and API limits make the data incomplete.&lt;/li&gt;
&lt;li&gt;The feedback loop already exists. Thirty days after every change you see in the same data whether it had an effect. That is a perfect learning loop for a step-by-step process.&lt;/li&gt;
&lt;li&gt;The work is repetitive but requires judgment. Pulling and organizing data is pure routine. Interpreting and prioritizing takes judgment. That exact mix is where a supervised agent shines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Done manually, most people pick this up once a quarter with a spreadsheet and good intentions. The agent turns it into a fixed rhythm — and rhythm is the most underrated success factor in SEO.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need: gcloud, GSC access, and an agent
&lt;/h2&gt;

&lt;p&gt;For scheduled SEO runs, I use gcloud with a dedicated service account. The agent gets its own Google identity, added as a read-only user to the Search Console property, rather than relying on my personal login. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project and gcloud, with an administrator who can enable the Search Console API and create the service account.&lt;/li&gt;
&lt;li&gt;A Search Console property owner who can add that account under Users and permissions.&lt;/li&gt;
&lt;li&gt;An agent that can use tools, such as &lt;a href="https://hermes-agent.nousresearch.com/docs" rel="noopener noreferrer"&gt;Hermes from Nous Research&lt;/a&gt;, Claude Code, Cursor agents, or your own application around a language model.&lt;/li&gt;
&lt;li&gt;A git repository for small, reviewable website changes, separate from the Google credentials.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Create a dedicated service account with gcloud
&lt;/h3&gt;

&lt;p&gt;Run these setup commands as a human administrator with the required Cloud permissions. Replace &lt;code&gt;YOUR_PROJECT_ID&lt;/code&gt; with your existing project ID; the example account name is &lt;code&gt;seo-reader&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;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;searchconsole.googleapis.com iam.googleapis.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID
gcloud iam service-accounts create seo-reader &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SEO read-only access"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account's email address will be &lt;code&gt;seo-reader@YOUR_PROJECT_ID.iam.gserviceaccount.com&lt;/code&gt;. Do not give it broad Cloud Owner or Editor roles. A Cloud IAM role does not grant access to your Search Console property; you add that separately. If it needs permission to consume the quota project, grant the narrow Service Usage Consumer role there, not project-wide administration. See &lt;a href="https://docs.cloud.google.com/iam/docs/service-accounts-create" rel="noopener noreferrer"&gt;Google's service account setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add the service account as a restricted GSC user
&lt;/h3&gt;

&lt;p&gt;In Search Console, the property owner opens the exact property, then &lt;strong&gt;Settings → Users and permissions → Add user&lt;/strong&gt;. Enter the service account's full email address, choose &lt;strong&gt;Restricted&lt;/strong&gt;, and add it. You do not need to make the account an owner or verify the domain again. Google's &lt;a href="https://support.google.com/webmasters/answer/7687615?hl=en" rel="noopener noreferrer"&gt;permissions table&lt;/a&gt; confirms that Restricted users can read the Performance report, which is what this loop needs.&lt;/p&gt;

&lt;p&gt;Configure the API client to request &lt;code&gt;https://www.googleapis.com/auth/webmasters.readonly&lt;/code&gt;, as documented in &lt;a href="https://developers.google.com/webmaster-tools/v1/how-tos/authorizing" rel="noopener noreferrer"&gt;Search Console authorization&lt;/a&gt;. The property role limits what the account can do; the OAuth scope limits what its API token can do. That scope is not tied to one website, so add the account only to properties the workflow needs. A domain property uses an identifier such as &lt;code&gt;sc-domain:example.com&lt;/code&gt;; a URL-prefix property uses its exact URL prefix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run without a recurring personal login
&lt;/h3&gt;

&lt;p&gt;Use an attached service account or Workload Identity Federation when the runtime and GSC client support it. These avoid storing a long-lived private key. If your local runner needs a JSON key, create one in Google Cloud under the service account's &lt;strong&gt;Keys → Add key → Create new key&lt;/strong&gt;. Follow your organization's key policy and store the file outside your repository. The following path is a placeholder for that protected file:&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;chmod &lt;/span&gt;600 /secure/path/seo-reader.json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/secure/path/seo-reader.json"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_CLOUD_QUOTA_PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"YOUR_PROJECT_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set these variables in the environment that actually starts the agent or its GSC connector, including scheduled runs. A cron job does not automatically inherit your interactive shell's exports. Use a client that supports &lt;a href="https://docs.cloud.google.com/docs/authentication/application-default-credentials" rel="noopener noreferrer"&gt;Application Default Credentials (ADC)&lt;/a&gt;, with the read-only scope above. ADC locates the credentials; it does not choose the permissions for you. ADC and the gcloud CLI also use separate credential configurations: being logged into gcloud does not configure the application's credentials.&lt;/p&gt;

&lt;p&gt;Google's authentication library obtains and renews short-lived access tokens for the service account without an interactive browser login. Personal OAuth can also refresh tokens, but it ties the job to a person's authorization. A service account removes that dependency, making unattended runs easier to maintain. It is not maintenance-free: rotate keys, revoke unused credentials, and review access. Keep keys and tokens out of prompts, logs, Git, and browser code; Google's &lt;a href="https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys" rel="noopener noreferrer"&gt;key-management guidance&lt;/a&gt; explains the risks.&lt;/p&gt;

&lt;p&gt;Before scheduling the loop, run a small Performance-data query through the same client and environment the scheduled job will use. Confirm the intended property and service account, and stop if access fails rather than falling back to your personal credentials. Isolate the runner from other credentials too. Website changes still go through your codebase and review flow; GSC read access does not authorize publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop: pull the data
&lt;/h2&gt;

&lt;p&gt;Every run starts the same way: the agent pulls fresh Search Console data for a bounded period — say the last thirty days, or a comparison with the thirty days before that. It requests the top queries per page, with impressions, clicks, CTR, and average position, and writes that away as a structured file in the repo. From that moment it no longer works with live APIs but with a snapshot you can inspect and compare against previous runs.&lt;/p&gt;

&lt;p&gt;One important detail: Search Console data only becomes final after a few days. Google itself says about fresh data points: "Each fresh data point will be replaced with the final data point after a few days" (&lt;a href="https://developers.google.com/search/blog/2019/09/search-performance-fresh-data" rel="noopener noreferrer"&gt;Google Search Central Blog on data freshness&lt;/a&gt;). Measure too early and you draw conclusions from numbers that still shift. That is why the agent works with closed periods and preferably compares month over month, never day over day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prioritize: where is the biggest win?
&lt;/h2&gt;

&lt;p&gt;With the data on the table, the agent looks for patterns worth a decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Many impressions, few clicks on a query your page already ranks for: usually a title or meta description problem, not a content problem.&lt;/li&gt;
&lt;li&gt;Position 8 to 20 with a declining trend: the page is relevant enough to rank but not convincing enough to break through. Sharpening the search intent and internal links helps here.&lt;/li&gt;
&lt;li&gt;Queries you have no good page for: content gaps. Those lead to a new article or an expansion of an existing page — not keyword stuffing on some random page.&lt;/li&gt;
&lt;li&gt;Multiple pages competing for the same query: cannibalization. Strengthen one page, give the other its own angle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each finding the agent states a hypothesis: this page, this query, this expected intervention, this expected effect. No more than one or two per run. If you change ten things at once, you will never know what worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make one small improvement
&lt;/h2&gt;

&lt;p&gt;Then comes the human moment. The agent prepares the change — a rewritten intro that puts the real search intent first, a better title tag, an internal link from a strong page — and for now you always review it. The change is deliberately small: one page, one aspect, describable in one sentence. "This intro now promises what the searcher actually asks for." Not: "The entire page optimized."&lt;/p&gt;

&lt;p&gt;Next, the agent verifies its own work. For a website in git that means: commit the change, run the build, fetch the changed route again, and confirm the promised text is actually there. A successful tool call is not a successful task; the run is only complete when the published page checks out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure and log
&lt;/h2&gt;

&lt;p&gt;Every run ends in a log: what the agent saw in the data, what it changed, what the expected effect is, and when to check it. Ideally in your issue tracker, so that thirty days later an on-the-record comparison is possible. Did the intervention raise the CTR on that one query? Did the position move from 14 to 9? Or did nothing happen — and why not?&lt;/p&gt;

&lt;p&gt;That logging is not administrative luxury. It is the only way to learn from the agent and to teach the agent. Because every time a hypothesis does not pan out, it says something about your instructions: was the prioritization wrong, the intervention too small, or the expectation too high?&lt;/p&gt;

&lt;h2&gt;
  
  
  Repeat every 30 days
&lt;/h2&gt;

&lt;p&gt;A single run does little. The power is in the repetition. After six cycles you have six verified improvements, six measurement moments, and a growing archive of what did and did not work. Along the way your pages have gradually been tuned to the real search intent of your visitors — not to your assumption of what they search for, but to what the data shows they search for.&lt;/p&gt;

&lt;p&gt;Why thirty days and not every week? Because Search Console data only finalizes after several days and positions show too much noise week over week. Thirty days gives a signal you can act on, and it matches the pace of content changes: thorough enough to matter, slow enough to measure cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  From supervising to automatic: be realistic
&lt;/h2&gt;

&lt;p&gt;This needs honesty: &lt;strong&gt;the first time, this does not work&lt;/strong&gt;. The first run of a new agent rarely produces anything publishable. The data gets misinterpreted, the prioritization misses, the proposed copy reads like a committee memo. That is not the concept failing — it is the onboarding period.&lt;/p&gt;

&lt;p&gt;This is an illustrative three-phase plan, not a measured timeline or guarantee. Automate only after repeated tests show that the workflow is reliable enough:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;th&gt;What the agent does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Months 1–2&lt;/td&gt;
&lt;td&gt;Supervise every run, correct every change, rewrite instructions&lt;/td&gt;
&lt;td&gt;Pull data, draft the analysis, make mistakes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months 3–4&lt;/td&gt;
&lt;td&gt;Review results, feed in edge cases&lt;/td&gt;
&lt;td&gt;Prepare complete runs, propose improvements with reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months 5+&lt;/td&gt;
&lt;td&gt;Check in occasionally, judge escalations&lt;/td&gt;
&lt;td&gt;Run on schedule by itself, ask for human input when in doubt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How you speed up that curve: give the agent &lt;strong&gt;skills&lt;/strong&gt;. An agent like Hermes works with reusable instruction packages — how to structure a page audit, how to interpret search intent, how to rewrite a title without clickbait. You teach those once, and every next run builds on them. After a few months those skills are the difference between an intern and a colleague: the process is in its fingers, and you only look over its shoulder for the hard cases.&lt;/p&gt;

&lt;p&gt;From that moment on, you turn the loop into a &lt;strong&gt;cron job&lt;/strong&gt;: a scheduled task that runs the agent on a schedule — say the first of every month. The run follows the same rules as when you sat next to it, only without you having to be there. Hard decisions (new pages, major rewrites, anything touching money or reputation) stay explicitly human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills: teaching the agent how to run SEO audits
&lt;/h2&gt;

&lt;p&gt;The fastest way to climb that curve: give the agent skills — reusable instruction packages that describe one task in detail. A skill for a page audit specifies exactly what data gets pulled (live HTML, Search Console rows, robots.txt), which dimensions get scored (search intent, E-E-A-T, content depth, on-page, structure, technical), with what weights, and in what format the report appears. You write such a package once, or teach it from an existing collection. I've documented that installation route step by step in &lt;a href="https://dev.to/seppegadeyne/hermes-agent-for-seo-from-search-console-to-a-blog-post-1ap0"&gt;Hermes Agent for SEO: from Search Console to a blog post&lt;/a&gt;: installing the agent, connecting Search Console through gcloud, and adapting the SEO skills for it. After that, the agent runs every next audit in exactly the same way — including the cron run in the middle of the night.&lt;/p&gt;

&lt;p&gt;The historical audit below shows what such a report looks like. It was recorded on September 2, 2026, before this article switched to the service account setup. Its earlier authentication advice is preserved as part of the original output; follow the updated setup above for new runs.&lt;/p&gt;

&lt;p&gt;On my own site, this article includes the full recorded audit terminal: the complete first run's output, scores, findings, and the GSC baseline of that moment (recorded on September 2, 2026). It's too long to paste here, but you can read it in &lt;a href="https://straffesites.com/en/blog/automating-seo-with-ai#skills" rel="noopener noreferrer"&gt;the original article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The audit reported that the page had no Search Console data on its publication day instead of inventing numbers. Its scores and baseline describe that moment, not the current page. Each finding comes with a concrete fix. For later changes, record a fresh baseline and wait at least thirty days before assessing the effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an agent may and may not do on its own
&lt;/h2&gt;

&lt;p&gt;Automation without boundaries is a risk, especially on something as visible as your website. A few rules of thumb that work in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read permissions for data, write access through git. The agent reads Search Console, but every change goes through your codebase and review flow.&lt;/li&gt;
&lt;li&gt;Small commits. One change per run, with explanation. Not because the agent cannot do more, but because otherwise you lose the measurement effect.&lt;/li&gt;
&lt;li&gt;No invented numbers. Every metric in copy comes from the actual data export, with period and source attached. An agent that smooths over or invents numbers is useless for this work.&lt;/li&gt;
&lt;li&gt;Human decisions stay human. Merging pages, restructuring key landing pages, changing prices or promises: do not automate.&lt;/li&gt;
&lt;li&gt;Useful stays useful, even at speed. Google's guidance on &lt;a href="https://developers.google.com/search/docs/fundamentals/using-gen-ai-content" rel="noopener noreferrer"&gt;AI-generated content&lt;/a&gt; stresses that automation changes nothing about the requirement that content be made for people and be genuinely helpful — regardless of who or what wrote it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a broader look at deploying such an agent without losing control, read &lt;a href="https://straffesites.com/en/glossary/ai-agent" rel="noopener noreferrer"&gt;What is an AI agent?&lt;/a&gt;. And for the bigger picture around recurring work: &lt;a href="https://straffesites.com/en/glossary/workflow-automation" rel="noopener noreferrer"&gt;Workflow automation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the change lands: MCP into your CMS, or Git for your Astro site
&lt;/h2&gt;

&lt;p&gt;So far, every improvement went to "your codebase". But where exactly the agent writes depends on how your website is built — and that is less complicated than it sounds. Broadly, there are two routes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route one: your site runs on a CMS.&lt;/strong&gt; Then you want the agent to work directly in that CMS, and for that you use MCP: the Model Context Protocol, an open standard that connects AI applications to external systems in a structured way. An MCP server for WordPress or Storyblok offers the agent a limited, explicit list of functions — create a draft, update a field, publish a page — with matching permissions. That way the agent can adjust a page on its own after an approved analysis, but only through the doors you open. And because every CMS draft starts as a draft rather than going live immediately, you keep a natural review step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route two: your site is static, like this one.&lt;/strong&gt; Straffe Sites runs on Astro with Content Collections: every page and article is a file in a git repository, with strict schemas as the quality gate. Here the agent does not edit a CMS but the files themselves — update frontmatter, rewrite an intro, add an internal link — and every change goes straight through the schema and build checks. Each run becomes a small commit you can read, revert, or approve. No database, no admin screen; the repository is the CMS.&lt;/p&gt;

&lt;p&gt;Both routes end in the same place: one small, verified change per run, with a human eye on it before it goes live.&lt;/p&gt;

&lt;p&gt;Further reading by topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://straffesites.com/en/blog/what-is-a-headless-cms" rel="noopener noreferrer"&gt;What is a headless CMS?&lt;/a&gt; — when managing content separately from the frontend makes sense, and what it means for AI workflows&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://straffesites.com/en/blog/mcp-servers-for-cms" rel="noopener noreferrer"&gt;MCP servers for CMS systems&lt;/a&gt; — permissions, a real draft repair, and readback before publication&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://straffesites.com/en/blog/wordpress-maintenance" rel="noopener noreferrer"&gt;WordPress maintenance&lt;/a&gt; — what periodic maintenance on a CMS site involves, and where an agent can help&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Plan B if the loop does not take off
&lt;/h2&gt;

&lt;p&gt;Sometimes it does not work. The data is too thin (less than a few hundred impressions a month), the market too competitive, or the agent keeps making silly choices after months of course-correcting. Then the honest conclusion is: automation adds no value here.&lt;/p&gt;

&lt;p&gt;But you still win. Because the agent still functions as an &lt;strong&gt;analyst&lt;/strong&gt;: it pulls your data monthly, flags the notable movements, and puts it all in a fixed report. You do the interpretation and the changes yourself, once a quarter. That is not a self-improving loop, but it is a return on your onboarding time — and sometimes that is the realistic picture for a small website. Start small, measure honestly, and let the automation earn what it is worth. And if you would rather build that cycle with guidance than figure it out alone, &lt;a href="https://straffesites.com/en/ai-automation" rel="noopener noreferrer"&gt;AI automation with human control&lt;/a&gt; is where we help. Doubting the business case? &lt;a href="https://straffesites.com/en/blog/seo-costs" rel="noopener noreferrer"&gt;What SEO costs in 2026&lt;/a&gt; lets you compare the agent approach with what freelancers and agencies charge for the same recurring work. Considering an agent beyond SEO, for your whole business? The guide on &lt;a href="https://straffesites.com/en/blog/hiring-someone-to-build-an-ai-agent" rel="noopener noreferrer"&gt;hiring someone to build an AI agent&lt;/a&gt; walks through the signals, costs, and pitfalls.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was first published on &lt;a href="https://straffesites.com/en/blog/automating-seo-with-ai" rel="noopener noreferrer"&gt;Straffe Sites&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>automation</category>
      <category>googlesearchconsole</category>
    </item>
    <item>
      <title>Google Search Console setup: verification and first checks</title>
      <dc:creator>Seppe Gadeyne</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:38:15 +0000</pubDate>
      <link>https://dev.to/seppegadeyne/google-search-console-setup-verification-and-first-checks-2cni</link>
      <guid>https://dev.to/seppegadeyne/google-search-console-setup-verification-and-first-checks-2cni</guid>
      <description>&lt;p&gt;A deployed site isn't necessarily an indexed site. Google Search Console lets you check what Google has discovered, which pages it has indexed, and which searches bring people to your site.&lt;/p&gt;

&lt;p&gt;This guide covers adding a property, verifying ownership, and the first checks I'd make after setup. You need a Google account and access to the site's DNS settings, or help from whoever manages them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Search Console tells you
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://search.google.com/search-console/about" rel="noopener noreferrer"&gt;Google Search Console&lt;/a&gt; is Google's free tool for tracking search performance. It reports impressions and clicks, the queries behind them, indexing status, and technical issues that may affect visibility.&lt;/p&gt;

&lt;p&gt;Google Analytics measures what visitors do on your site. Search Console measures how the site appears in Google Search before they click. They answer different questions. Search Console alone won't tell you whether a visitor submitted a form or became a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign in with your Google account
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://search.google.com/search-console/" rel="noopener noreferrer"&gt;Search Console&lt;/a&gt; and sign in. Choose an account you'll retain access to. If you're setting this up for a client, make sure they retain ownership rather than leaving access tied only to your account. You can add other users with their own permissions later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add your website: domain or URL prefix
&lt;/h2&gt;

&lt;p&gt;Click "Add property" and choose between two types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;domain property&lt;/strong&gt; covers the entire domain, including subdomains and both HTTP and HTTPS. Enter the domain without a protocol or path. This requires DNS verification.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;URL-prefix property&lt;/strong&gt; covers addresses that begin with the exact prefix you enter, such as &lt;code&gt;https://www.example.com/&lt;/code&gt;. Use this when you deliberately want to measure one part or version of the site, or need a verification method other than DNS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a whole website, I'd usually start with a domain property. Be careful with URL prefixes: &lt;code&gt;https://example.com/&lt;/code&gt; and &lt;code&gt;https://www.example.com/&lt;/code&gt; are different prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify ownership
&lt;/h2&gt;

&lt;p&gt;For a domain property, copy the TXT value Google provides and add it to the DNS records at your DNS provider. Keep existing records intact. Then return to Search Console and click "Verify."&lt;/p&gt;

&lt;p&gt;DNS changes can take time. If verification fails, check the record name and value against your provider's instructions before trying again. Keep the verification record after setup so Google can recheck ownership.&lt;/p&gt;

&lt;p&gt;A URL-prefix property supports additional methods, including an HTML file or tag, Google Analytics, and Google Tag Manager. Each has its own requirements. &lt;a href="https://support.google.com/webmasters/answer/9008080" rel="noopener noreferrer"&gt;Google's verification documentation&lt;/a&gt; explains those requirements and troubleshooting steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submit your sitemap
&lt;/h2&gt;

&lt;p&gt;Open "Sitemaps" and submit the actual XML sitemap URL generated by your site. Common paths include &lt;code&gt;/sitemap.xml&lt;/code&gt; and &lt;code&gt;/sitemap-index.xml&lt;/code&gt;, but check your deployment rather than guessing.&lt;/p&gt;

&lt;p&gt;Open that URL yourself first. It should return the sitemap, not an HTML error page or a login screen. A sitemap helps Google discover URLs; it doesn't guarantee indexing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect an important page
&lt;/h2&gt;

&lt;p&gt;Paste a complete page URL into the URL Inspection field. Start with your homepage and one important content page.&lt;/p&gt;

&lt;p&gt;Read the indexed result before running a live test. The indexed result describes Google's stored information; a live test checks the current page. A successful live test doesn't mean the URL is already indexed.&lt;/p&gt;

&lt;p&gt;For a new or substantially updated page, you can request indexing through URL Inspection. That is a request, not a guarantee of inclusion or a particular crawl time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the Performance report
&lt;/h2&gt;

&lt;p&gt;Data collection starts when the property is first added to any Search Console account, even before verification. It doesn't recreate search performance from before that first addition.&lt;/p&gt;

&lt;p&gt;Once you have enough data, open the &lt;a href="https://support.google.com/webmasters/answer/7576553" rel="noopener noreferrer"&gt;Performance report&lt;/a&gt; and select "Search results."&lt;/p&gt;

&lt;p&gt;Start in the Pages tab. Click a page with substantial impressions, then switch to Queries to see the searches associated with it. Ask whether the page answers what those people are looking for before changing its title or adding content.&lt;/p&gt;

&lt;p&gt;Average position isn't a fixed ranking. It combines different searches, and results vary by device, location, and other factors. Likewise, low click-through rate doesn't prove that a title is wrong. Compare similar queries and conditions, then inspect the actual search results.&lt;/p&gt;

&lt;p&gt;If you want more context on choosing relevant queries, I've written a guide to &lt;a href="https://straffesites.com/en/blog/how-to-search-for-keywords" rel="noopener noreferrer"&gt;keyword research&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review indexing separately from traffic
&lt;/h2&gt;

&lt;p&gt;The Pages report under Indexing shows which URLs are indexed and which are not, with reasons. "Discovered, currently not indexed" and "Crawled, currently not indexed" can have several causes. Inspect the affected URL and its content before assuming you need a code fix.&lt;/p&gt;

&lt;p&gt;The Sitemaps report shows the status of submitted sitemaps and the URLs Google discovered through them. Those discovery counts aren't a count of indexed pages.&lt;/p&gt;

&lt;p&gt;For ongoing work, keep a record of what you changed and when. Review a meaningful period rather than reacting to a single day's movement. If the goal is more inquiries or sales, track those outcomes separately from search clicks.&lt;/p&gt;




&lt;p&gt;Adapted from my guide on &lt;a href="https://straffesites.com/en/blog/google-search-console" rel="noopener noreferrer"&gt;setting up Google Search Console&lt;/a&gt;, originally published on Straffe Sites.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>beginners</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
