<?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: CodeWithRomi</title>
    <description>The latest articles on DEV Community by CodeWithRomi (@theeromi).</description>
    <link>https://dev.to/theeromi</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%2F4169897%2F5cbdce92-1f76-4cb9-90de-fcc47aa5b5a6.jpg</url>
      <title>DEV Community: CodeWithRomi</title>
      <link>https://dev.to/theeromi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theeromi"/>
    <language>en</language>
    <item>
      <title>160 NVMe Drives in a 4U Box: Inside Supermicro's ASG-4116S-NU160R</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:54:49 +0000</pubDate>
      <link>https://dev.to/theeromi/160-nvme-drives-in-a-4u-box-inside-supermicros-asg-4116s-nu160r-2k85</link>
      <guid>https://dev.to/theeromi/160-nvme-drives-in-a-4u-box-inside-supermicros-asg-4116s-nu160r-2k85</guid>
      <description>&lt;p&gt;At FMS 2026 (the Future of Memory and Storage conference, formerly Flash Memory Summit), Supermicro pulled back the curtain on the ASG-4116S-NU160R, a 4U server crammed with 160 NVMe SSD bays. That number is not a typo. Getting that many drives into a standard 4U chassis is an engineering challenge worth understanding, whether you are designing storage infrastructure or just keeping an eye on where the industry is heading.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Most high-density NVMe servers top out somewhere in the 24 to 48 drive range for a 2U or 4U footprint. Supermicro pushing to 160 bays in 4U changes the math on petabyte-scale flash deployments. Fewer servers mean fewer network ports, fewer power feeds, and less management overhead. For hyperscalers and large storage clusters, that consolidation is a direct cost reduction.&lt;/p&gt;

&lt;p&gt;It also shows that density does not require a new drive form factor. Rather than moving to E3.S or other EDSFF drives, Supermicro sticks with 2.5-inch U.2 SSDs and borrows the layout of a top-loading SAS storage server, using PCIe switches where that design would use SAS expanders. The ASG-4116S-NU160R is a preview of what dense all-flash arrays can look like at the server level rather than the appliance level.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏗️ The Architecture Behind 160 Bays
&lt;/h2&gt;

&lt;p&gt;Fitting 160 NVMe drives into 4U requires a few design choices happening in parallel. Supermicro confirmed at FMS 2026 that the drives are 2.5-inch U.2 NVMe SSDs, not a smaller EDSFF form factor. It reaches 160 bays in 4U with a two-level slide-out tray design: the top tray carries the hot-swap SSDs, and the bottom tray holds the CPU, memory, and networking.&lt;/p&gt;

&lt;p&gt;The connectivity side is the harder problem. NVMe drives connect over PCIe, and PCIe lanes are a finite resource. Running all 160 drives at four lanes each would take 640 PCIe lanes, far more than a single CPU provides. Supermicro solves this with PCIe switches in the center of the chassis, fanning out from the host CPU to the drive bays the way SAS expanders do in a top-loading SAS server. Supermicro lists the 160 U.2 bays as PCIe 4.0 x1, so each drive gets a single Gen4 lane rather than the usual x4. The switch chips have not been named, so check the official product page and the ServeTheHome coverage at the source link below for updates.&lt;/p&gt;

&lt;p&gt;Power and thermals are the other constraint. 160 NVMe drives all active under load generate substantial heat. Supermicro's spec sheet lists hot-swappable rear fans and redundant (1+1) 2600W Titanium-level power supplies. ServeTheHome estimates roughly 130 to 137W per petabyte of storage at the outlet when the system is filled with 122TB-class drives.&lt;/p&gt;

&lt;h2&gt;
  
  
  📐 Form Factor and Drive Standards
&lt;/h2&gt;

&lt;p&gt;The small form factor NVMe ecosystem is worth knowing if you are evaluating platforms like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;E3.S:&lt;/strong&gt; An EDSFF standard designed specifically for data center density. About 76mm by 112.75mm (7.5mm thick for 1T drives), with a native PCIe connector, NVMe, and hot-swap support. It uses a different connector and shape, so it does not fit 2.5-inch U.2 bays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E1.S:&lt;/strong&gt; Even smaller than E3.S, used in ultra-dense JBOF designs. Lower capacity per drive but maximum bays per rack unit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M.2 (22110):&lt;/strong&gt; Sometimes used in density builds but limited in hot-swap support and thermal headroom at high drive counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;U.2 / U.3:&lt;/strong&gt; The workhorse for enterprise NVMe. The ASG-4116S-NU160R uses 2.5-inch U.2 drives. Supermicro fits 160 of them in 4U with a two-level slide-out tray design rather than moving to a smaller form factor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supermicro's product page confirms the design: 160 top-loading hot-swap 2.5-inch NVMe bays running at PCIe 4.0, four front hot-swap E1.S PCIe 5.0 x4 bays, a single AMD EPYC 9005 series processor, up to 6TB of DDR5 across 12 DIMM slots, and three PCIe 5.0 x16 slots for networking or DPUs. See the official &lt;a href="https://www.supermicro.com/en/products/system/storage/4u/asg-4116s-nu160r" rel="noopener noreferrer"&gt;ASG-4116S-NU160R product page&lt;/a&gt; and the ServeTheHome coverage for the full datasheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  🖧 Where This Fits in a Storage Stack
&lt;/h2&gt;

&lt;p&gt;A 160-bay NVMe server is not a general-purpose compute node. It is purpose-built for specific workloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Distributed storage clusters running Ceph, MinIO, or similar software-defined storage layers&lt;/li&gt;
&lt;li&gt;AI training dataset storage where random read throughput matters more than latency&lt;/li&gt;
&lt;li&gt;Database tiering where the entire working set needs to live on flash&lt;/li&gt;
&lt;li&gt;Video ingest and media asset storage at scale&lt;/li&gt;
&lt;li&gt;NVMe-oF (NVMe over Fabrics) targets serving compute nodes over Ethernet or InfiniBand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For homelab use, this is firmly in the "interesting to know about" category rather than the "go buy one" category. But the architectural patterns it uses, PCIe switching in place of SAS expanders and slide-out service trays, show up at smaller scales too.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 What to Watch For in the Spec Sheet
&lt;/h2&gt;

&lt;p&gt;Supermicro's product page is already live, and these are the numbers worth paying attention to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total PCIe bandwidth:&lt;/strong&gt; Supermicro lists the U.2 bays as PCIe 4.0 x1, and PCIe switches share the CPU's lanes across all 160 drives, so per-drive throughput is well below a typical x4 U.2 link. That trade-off favors capacity over peak per-drive speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drive form factor:&lt;/strong&gt; confirmed as 2.5-inch U.2, with four E1.S bays on the front panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host interface:&lt;/strong&gt; Onboard networking is just two 10GbE RJ45 ports (Broadcom BCM57416), so plan on adding NICs or DPUs in the three PCIe 5.0 x16 slots to move data at a speed that matches 160 NVMe drives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supported CPUs:&lt;/strong&gt; a single AMD EPYC 9005 series processor (up to 360W TDP) drives the platform. The E1.S bays and expansion slots are PCIe 5.0, while the 160 U.2 bays run at PCIe 4.0 behind PCIe switches. Lane count from the CPU constrains everything downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redundancy:&lt;/strong&gt; Drives and rear fans are hot-swap, and the two 2600W Titanium power supplies are 1+1 redundant. Whether the drive backplane and PCIe switch layer have any redundancy is worth asking Supermicro directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🛡️ Security Considerations at Scale
&lt;/h2&gt;

&lt;p&gt;When you consolidate 160 NVMe drives into one chassis, the blast radius of a single point of failure goes up. A few things to think through before deploying dense storage like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable self-encrypting drive (SED) support if the drives offer it, so physical drive removal does not equal data exposure.&lt;/li&gt;
&lt;li&gt;Plan your erasure coding or replication factor in software-defined storage to tolerate the loss of an entire chassis, not just a single drive.&lt;/li&gt;
&lt;li&gt;Secure BMC/IPMI access on the management interface. A chassis this dense is a high-value target for ransomware via out-of-band management.&lt;/li&gt;
&lt;li&gt;Keep firmware update cadence tight. NVMe drive firmware vulnerabilities are rare but real, and patching 160 drives manually is painful without automation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;The Supermicro ASG-4116S-NU160R is a signal, not just a product. It shows that all-flash density at the server level is catching up to what purpose-built appliances have offered, and that PCIe switching plus a top-loading 2.5-inch U.2 layout is the architecture getting it there. Whether you are planning a petabyte-scale Ceph cluster or just tracking where storage hardware is heading, this is worth bookmarking.&lt;/p&gt;

&lt;p&gt;For confirmed specs and drive form factor, go straight to the &lt;a href="https://www.supermicro.com/en/products/system/storage/4u/asg-4116s-nu160r" rel="noopener noreferrer"&gt;Supermicro product page&lt;/a&gt; and the full ServeTheHome coverage at &lt;a href="https://www.servethehome.com/160-bay-nvme-ssd-4u-server-shown-at-fms-2026-supermicro-asg-4116s-nu160r/" rel="noopener noreferrer"&gt;servethehome.com&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/pcie-gen5-vs-gen6-for-ai-storage-which-one-actually-matters-for-your-homelab/" rel="noopener noreferrer"&gt;PCIe Gen5 vs Gen6 for AI Storage: Which One Actually Matters for Your Homelab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/build-a-minimal-zfs-nas-without-synology-qnap-or-truenas-2024/" rel="noopener noreferrer"&gt;Build a Minimal ZFS NAS Without Synology, QNAP, or TrueNAS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/nfs-vs-smb-which-protocol-should-you-use-for-your-homelab-storage/" rel="noopener noreferrer"&gt;NFS vs SMB: Which Protocol Should You Use for Your Homelab Storage?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/160-nvme-drives-in-a-4u-box-inside-supermicros-asg-4116s-nu160r/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>homelab</category>
      <category>cryptography</category>
      <category>storage</category>
    </item>
    <item>
      <title>Pi 5 RAM Swaps Have Been Firmware-Locked Since 2024. Here's What That Actually Means</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:54:35 +0000</pubDate>
      <link>https://dev.to/theeromi/pi-5-ram-swaps-have-been-firmware-locked-since-2024-heres-what-that-actually-means-2lgk</link>
      <guid>https://dev.to/theeromi/pi-5-ram-swaps-have-been-firmware-locked-since-2024-heres-what-that-actually-means-2lgk</guid>
      <description>&lt;p&gt;Raspberry Pi enthusiasts have been quietly upgrading the RAM on Pi 5 boards for a while now. The process involved swapping out the LPDDR4X memory package, and for the technically adventurous, it worked. It stopped working two years ago. The restriction is not new: Pi 5 bootloader EEPROM releases dated 2024-09-23 and later refuse to bring up memory that does not match what the board was provisioned with at the factory, and a board with a swapped chip fails SDRAM initialisation and does not boot. What is new is that the behaviour has finally been documented in public. One naming note: this is Raspberry Pi Ltd, the trading company, not the Raspberry Pi Foundation.&lt;/p&gt;

&lt;p&gt;Jeff Geerling wrote it up on 21 September 2026, which is why it is being discussed now. His own summary of the timing: "The firmware update that disabled RAM swaps was actually from late 2024, two years ago." Let's break down what the firmware checks, what the changelog does and does not say about it, and what your options actually look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Raspberry Pi lists the Pi 5 in 1 GB, 2 GB, 4 GB, 8 GB and 16 GB variants, all LPDDR4X-4267. For users who wanted more RAM than their board shipped with, unofficial chip swaps were an appealing path. It kept older boards in service and gave power users more headroom for workloads like running local AI models, containers, or memory-intensive self-hosted apps. It was also, according to Raspberry Pi, being abused at the other end of the market: the company's position is that resellers were buying cheap low-RAM boards, soldering larger chips of unknown provenance onto them, and selling them as factory 8 GB units.&lt;/p&gt;

&lt;p&gt;Firmware-level lockdowns change that calculus entirely. When the bootloader refuses to initialise memory that does not match the board's factory provisioning, the board does not boot at all. The reported symptom is the green ACT LED flashing eight times in a repeating pattern, which is the SDRAM failure code. This is not a soft restriction you can work around with a config file tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 What the Firmware Actually Does
&lt;/h2&gt;

&lt;p&gt;Modern SBCs rely on their firmware to train and initialize RAM at boot. The firmware contains timing parameters, voltage settings, and compatibility tables specific to the memory chips the manufacturer has validated. When Raspberry Pi updates that firmware to check against an approved list of memory configurations, any chip not on that list can fail initialization entirely or be flagged and rejected.&lt;/p&gt;

&lt;p&gt;The practical result is that a board that previously booted fine after a RAM swap stops booting. The restriction lives below the OS, so there is no Linux-side fix. One important caveat on sourcing: the &lt;code&gt;rpi-eeprom&lt;/code&gt; changelog entry for 2024-09-23 says only "SDRAM performance tuning", "Allow BANKLOW to be configured by SDRAM_BANKLOW parameter" and "Manufacturing test updates". It never mentions a memory check, so the changelog will not tell you this happened. The clearest primary evidence is &lt;a href="https://github.com/raspberrypi/rpi-eeprom/issues/761" rel="noopener noreferrer"&gt;rpi-eeprom issue 761&lt;/a&gt;, filed 15 October 2025 by a user whose Pi 5, sold as 8 GB, failed to boot on every EEPROM release from 2024-09-23 onward and booted fine on 2024-09-10. Raspberry Pi engineers asked for &lt;code&gt;vcgencmd otp_dump&lt;/code&gt;, concluded from the OTP provisioning that the board had been modified after manufacture, and said such a board "won't cope with any changes in firmware / otp provisioning and is stuck on a pre-2712D0 firmware release with old/test SDRAM code." A Raspberry Pi engineer has also stated the policy directly on the company forums, in &lt;a href="https://forums.raspberrypi.com/viewtopic.php?t=399212" rel="noopener noreferrer"&gt;this thread&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 Why Would Raspberry Pi Do This?
&lt;/h2&gt;

&lt;p&gt;There are a few plausible reasons, and they are not mutually exclusive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stability and support:&lt;/strong&gt; Unofficial RAM swaps can cause hard-to-diagnose instability. Restricting unsupported configurations reduces support burden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supply chain and binning:&lt;/strong&gt; Memory chips are binned and validated for specific performance envelopes. A chip that works in one context may not meet the timing tolerances Raspberry Pi has tested against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revenue protection:&lt;/strong&gt; Selling higher-RAM SKUs is a meaningful part of the business model. Unofficial upgrades cut into that, particularly now that the Pi 5 is widely available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-part memory tuning:&lt;/strong&gt; The &lt;code&gt;rpi-eeprom&lt;/code&gt; changelog is full of entries adding timings for specific memory configurations, including "Improved SDRAM refresh timings for Pi5 16GB" and a Broadcom SDRAM firmware bump "to support additional memory variants." The bootloader has to know which part is actually fitted. That also explains the part people find hardest to accept: a same-capacity chip pulled from another Pi 5 can still be rejected, because the check is not only about size. This has nothing to do with RP1, the Pi 5's I/O controller, which does not sit on the memory interface at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these reasons are inherently sinister, but the combined effect is a more closed platform than many in the community expected from Raspberry Pi.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ What This Means for Your Homelab
&lt;/h2&gt;

&lt;p&gt;If you are running a Pi 5 with an unofficial RAM upgrade and it is working today, the immediate risk is a firmware update breaking your setup. Here is how to think about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your swapped board works today, you are already running old firmware.&lt;/strong&gt; The only bootloader releases that tolerate a swapped chip are 2024-09-10 and earlier, so a board still running a RAM mod is two years behind on bootloader fixes. There is no release channel that keeps you both current and modded. The real choice is old firmware or a factory memory configuration, and it is worth knowing which one you have picked.&lt;/p&gt;

&lt;p&gt;To check your current EEPROM version and what update is staged:&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;sudo &lt;/span&gt;rpi-eeprom-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The release channel is not set with &lt;code&gt;rpi-eeprom-config&lt;/code&gt;. That command edits the bootloader's own configuration, things like &lt;code&gt;BOOT_ORDER&lt;/code&gt;. The channel is the &lt;code&gt;FIRMWARE_RELEASE_STATUS&lt;/code&gt; variable in &lt;code&gt;/etc/default/rpi-eeprom-update&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;sudo &lt;/span&gt;nano /etc/default/rpi-eeprom-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the current &lt;code&gt;rpi-eeprom&lt;/code&gt; package that variable defaults to &lt;code&gt;default&lt;/code&gt;, and &lt;code&gt;latest&lt;/code&gt; is the other channel; the older &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;stable&lt;/code&gt; and &lt;code&gt;beta&lt;/code&gt; names are historical and no longer what the tooling uses. The same file also holds &lt;code&gt;RPI_EEPROM_ALLOW_DOWNGRADE&lt;/code&gt; and &lt;code&gt;STRICT_MIN_VER_CHECK&lt;/code&gt;, which matter if you ever need to go backwards. Checked against the &lt;code&gt;rpi-eeprom&lt;/code&gt; repository on 29 September 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Going backwards is getting harder.&lt;/strong&gt; The 2026-09-23 bootloader release added a manufacture-version check: "If the MFG version of the image to update is less than MFG version in OTP then recovery.bin and self update will both fail to write the eeprom update", with &lt;code&gt;bootloader_allow_mfg_downgrade=1&lt;/code&gt; in &lt;code&gt;config.txt&lt;/code&gt; as the documented bypass. On newer boards whose minimum version is burned into OTP, rolling all the way back to a 2024 bootloader is no longer a simple reflash. That change landed six days before this post went up, so treat it as current rather than settled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider your upgrade path instead.&lt;/strong&gt; If you need more RAM for your workload, the official 16 GB Pi 5 is a real option. It costs more than a DIY swap, but it comes with full firmware support and no risk of a future update refusing to initialise your memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 Alternatives If You Need More Headroom
&lt;/h2&gt;

&lt;p&gt;If the Pi 5 RAM ceiling is genuinely limiting your homelab, here are paths that do not depend on unofficial hardware modifications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raspberry Pi 5 16 GB:&lt;/strong&gt; The official high-RAM option. Full firmware support, no risk from future updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap space on fast storage:&lt;/strong&gt; Not a substitute for real RAM, but an NVMe-backed swap partition on a Pi 5 with an M.2 HAT can extend effective memory for workloads that tolerate it. Add a swap file on your NVMe mount point rather than the SD card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the workload:&lt;/strong&gt; For memory-intensive tasks like running local LLMs, a small x86 mini PC or a used workstation server often makes more sense than pushing a Pi to its limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orange Pi, Rock Pi, or other SBCs:&lt;/strong&gt; The broader ARM SBC ecosystem has boards with more RAM and less restrictive firmware policies. They come with their own trade-offs around community support and software compatibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔍 The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;This situation highlights a tension that has been building in the SBC space. As these boards become more capable and more commercially successful, the platforms inevitably become more controlled. The original Pi was almost impossibly open at the hardware level. The Pi 5 is a far more sophisticated piece of hardware, and sophistication tends to come with tighter firmware control.&lt;/p&gt;

&lt;p&gt;That does not make the Pi 5 a bad board. It is still one of the most capable and well-supported SBCs you can buy. But it does mean that the "buy a cheap board and hack it into something bigger" model has real limits now. Plan your Pi 5 deployments around the official RAM tiers, and treat any unofficial hardware modification as something that may stop working after a firmware update.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;Raspberry Pi's firmware-level RAM lockdown on the Pi 5 closed the door on a useful, if unofficial, upgrade path, and it did so in September 2024. If you have a working unofficial RAM swap today, you are on a two-year-old bootloader and any update will end that. If you are planning a new Pi 5 build that needs more than 8 GB, buy the 16 GB model and skip the uncertainty entirely.&lt;/p&gt;

&lt;p&gt;Jeff Geerling's 21 September 2026 post is the place to start on the community side. For the primary material, &lt;a href="https://github.com/raspberrypi/rpi-eeprom/issues/761" rel="noopener noreferrer"&gt;rpi-eeprom issue 761&lt;/a&gt; is where Raspberry Pi engineers describe the check themselves, and the &lt;a href="https://github.com/raspberrypi/rpi-eeprom/blob/master/firmware-2712/release-notes.md" rel="noopener noreferrer"&gt;Pi 5 bootloader release notes&lt;/a&gt; are worth watching for future changes, with the caveat that this particular behaviour never appeared in them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/why-you-shouldnt-power-a-raspberry-pi-through-its-gpio-pins/" rel="noopener noreferrer"&gt;Why You Shouldn't Power a Raspberry Pi Through Its GPIO Pins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/pironman-5-max-case-setup/" rel="noopener noreferrer"&gt;Pironman 5 Max Case Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/self-host-apache-guacamole-on-raspberry-pi-5-browser-remote-access/" rel="noopener noreferrer"&gt;Self-Host Apache Guacamole on a Raspberry Pi 5: Browser Remote Access for Your Homelab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/pi-5-ram-swaps-have-been-firmware-locked-since-2024/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>security</category>
      <category>linux</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How Signal Made the Phone Number Optional, and What the Zero-Knowledge Proof Actually Protects</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:54:15 +0000</pubDate>
      <link>https://dev.to/theeromi/how-signal-made-the-phone-number-optional-and-what-the-zero-knowledge-proof-actually-protects-1nne</link>
      <guid>https://dev.to/theeromi/how-signal-made-the-phone-number-optional-and-what-the-zero-knowledge-proof-actually-protects-1nne</guid>
      <description>&lt;p&gt;Signal has long had a privacy contradiction at its core: the most secure mainstream messenger still required a phone number to sign up. That number became a de facto identity anchor, linkable to you even if you never shared it with contacts. Signal has now added an optional way around that, and zero-knowledge proofs (ZKPs) are part of it, though not in the way most coverage suggests. Phone-numberless registration landed in the Signal Android 8.28.0 beta on 16 September 2026. It is optional, Android only, and it costs a one-time in-app purchase: 3 USD in the United States, with per-country differences, checked 29 September 2026. The zero-knowledge proof is not what replaces the phone number. It is what stops Signal from linking that purchase to the account it creates. This post breaks down what Signal actually built, and what self-hosters can and cannot take from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Phone numbers are lousy identifiers for a private messaging app. They are tied to real-world identity through carriers, they can be ported or spoofed, and they hand a centralized third party leverage over who can register. Signal has been aware of this tension for years, and their community forums have tracked the demand for phone-number-free registration since at least 2018.&lt;/p&gt;

&lt;p&gt;What shipped is narrower than proving eligibility with a zero-knowledge proof. A numberless account is identified by an Account ID and an Account Key, which Signal Android lead Greyson Parrelli describes as "a username and password." Nothing is proved about the person at all. The anti-spam control is the fee, and the zero-knowledge proof exists so that the payment cannot be tied back to the account. That is still a real change to the trust model, but it is worth being precise about which part the cryptography is doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 What Zero-Knowledge Proofs Actually Do Here
&lt;/h2&gt;

&lt;p&gt;A zero-knowledge proof lets one party (the prover) convince another party (the verifier) that a statement is true, without revealing anything beyond the truth of that statement itself. In Signal's registration flow, the proof is not about you. It is about the payment. Signal's own description, from Parrelli's 16 September 2026 post on the Signal Community forum: "The payment uses the same zero-knowledge proofs our donation system uses so that there is no link between your payment and your account."&lt;/p&gt;

&lt;p&gt;The machinery being reused is the anonymous credential code behind Signal's donation badges, which lives in the &lt;strong&gt;zkgroup&lt;/strong&gt; and &lt;strong&gt;zkcredential&lt;/strong&gt; crates inside &lt;strong&gt;libsignal&lt;/strong&gt;. Note what is &lt;em&gt;not&lt;/em&gt; happening: nothing verifies a phone number, and nothing verifies a username either. Registration simply mints an Account ID and Account Key. The credential work is confined to the purchase, so that redeeming it proves a payment was made without identifying which payment.&lt;/p&gt;

&lt;p&gt;Concretely: you never give Signal a phone number in this flow, because no number is involved at any point. One caveat that the cryptography does not address. The purchase currently has to go through a Play Store in-app payment, so Google still sees a Signal purchase on your account even though Signal cannot link it to the Signal account. Signal says more payment methods are planned, and that a device without Play Services cannot buy an account today.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏗️ The Architecture in Plain Terms
&lt;/h2&gt;

&lt;p&gt;Here is the rough flow, based on how ZKP-based credential systems like this typically work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credential issuance:&lt;/strong&gt; A trusted issuer issues a blinded credential to the user. The issuer signs it without seeing the full content. Signal is not federated, so in Signal's case the issuer is Signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof generation:&lt;/strong&gt; The client uses the credential to generate a zero-knowledge proof that says "I hold a valid credential issued by this authority" without revealing the credential itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side verification:&lt;/strong&gt; Signal's registration endpoint verifies the proof cryptographically. If it checks out, registration proceeds. No phone number is logged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-time use:&lt;/strong&gt; The proof is typically bound to the session or includes a nullifier so the same credential cannot register multiple accounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One-time redemption matters for abuse prevention in any scheme like this. Read the four bullets above as a description of the general pattern rather than of Signal's registration endpoint, because in Signal's case the abuse control is the price, not the cryptography. Parrelli's stated reason for charging anything at all: "Spammers, unfortunately. If it was free, spammers would acquire massive numbers of these accounts and ruin our network." Signal has also said it may raise the price if numberless accounts turn into a spam vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  📋 Status as of 29 September 2026
&lt;/h2&gt;

&lt;p&gt;What is actually shipping, from Signal's own release notes and forum posts rather than press coverage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; Android only, and still in beta. It arrived in the 8.28.0 beta on 16 September 2026, and the 8.29 beta notes on 23 September 2026 say Signal is "keeping phoneless registration in beta for another week to get more testing." Signal iOS release notes through 8.30.0 do not mention it, and Signal Desktop has no standalone registration at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; a one-time in-app purchase, 3 USD in the United States, with prices differing by country. Play Store in-app payment is the only method at the moment, so a device without Play Services cannot buy an account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery:&lt;/strong&gt; none. The Account ID and Account Key are the only credentials, and Signal states plainly that there is no other recovery mechanism if you lose them. These accounts have no PIN, because the Account Key replaces it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reachability:&lt;/strong&gt; a username is optional, and without one the account is, in Signal's words, "completely unreachable" except in conversations you start yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing accounts:&lt;/strong&gt; you cannot remove the phone number from an account you already have. Signal says that may become possible later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-factor:&lt;/strong&gt; TOTP now, with passkeys and hardware keys promised. It is not yet available on phone number accounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🧰 What Self-Hosters Can Learn From This Pattern
&lt;/h2&gt;

&lt;p&gt;You do not need to be Signal to apply this pattern. The ZKP credential model is useful any time you want to gate access to a service without storing identifying data about your users. Here are a few real scenarios where this architecture applies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invite-only self-hosted apps:&lt;/strong&gt; Issue blinded invite tokens. Users redeem them with a ZKP so you know the token is valid but cannot trace which token a specific account used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age or membership verification:&lt;/strong&gt; Prove "user is over 18" or "user is a paid member" without storing birthdate or subscription ID server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting without tracking:&lt;/strong&gt; Issue anonymous rate-limit tokens. Each token proves the holder is allowed N requests without tying requests back to an IP or user ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Libraries worth looking at if you want to experiment with this pattern include &lt;strong&gt;zk-kit&lt;/strong&gt; (JavaScript/TypeScript), &lt;strong&gt;bellman&lt;/strong&gt; and &lt;strong&gt;arkworks&lt;/strong&gt; (Rust), and &lt;strong&gt;snarkjs&lt;/strong&gt; for browser-compatible proof generation. For the credential-issuance side, Signal's own &lt;strong&gt;zkgroup&lt;/strong&gt; is open source and worth reading even if you do not use it directly. Check each project's documentation for current API stability before building on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Security Considerations
&lt;/h2&gt;

&lt;p&gt;ZKPs are not magic. There are real failure modes to understand before you rely on this pattern.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trusted setup:&lt;/strong&gt; Some ZKP systems (like Groth16) require a trusted setup ceremony. If that ceremony is compromised, proofs can be forged. Systems using transparent setups (STARKs, or PLONK with a universal SRS) reduce this risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential theft:&lt;/strong&gt; If an attacker steals a user's credential before it is blinded, the ZKP does not help. The issuance step still needs to be secured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation bugs:&lt;/strong&gt; A ZKP circuit that does not correctly encode the constraint you think it does will verify invalid proofs. Formal audits of ZKP circuits are expensive and specialized. Do not roll your own for production without one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata leakage:&lt;/strong&gt; Even with a valid ZKP, timing, IP addresses, and request patterns can still de-anonymize users. Layer your ZKP registration behind Tor or a VPN if anonymity is the goal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Practical Next Steps If You Want to Explore This
&lt;/h2&gt;

&lt;p&gt;If you want to get hands-on with ZKP-based authentication, here is a reasonable starting path:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Read Signal's anonymous credential code. The standalone &lt;code&gt;signalapp/zkgroup&lt;/code&gt; repository is archived and GitHub now points it at &lt;a href="https://github.com/signalapp/libsignal" rel="noopener noreferrer"&gt;signalapp/libsignal&lt;/a&gt;, so start there. The &lt;code&gt;rust/zkgroup&lt;/code&gt; and &lt;code&gt;rust/zkcredential&lt;/code&gt; crates are where credential issuance and verification live. Checked 29 September 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Run through the snarkjs tutorial to build a trivial ZKP circuit locally. It uses Circom for the circuit definition and outputs a Solidity or JavaScript verifier. The two tools do not install the same way:&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;# snarkjs does ship on npm&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; snarkjs

&lt;span class="c"&gt;# circom does not. The "circom" package on npm is circom 1,&lt;/span&gt;
&lt;span class="c"&gt;# which upstream marks deprecated. circom 2 is a Rust&lt;/span&gt;
&lt;span class="c"&gt;# compiler you build from source:&lt;/span&gt;
&lt;span class="c"&gt;#   https://github.com/iden3/circom&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Design your constraint. Write out in plain English what your proof needs to show, for example "the prover knows a value x such that hash(x) equals a public commitment." Translating that into a Circom circuit is the hard part, and the circom documentation is the right reference. Use whichever documentation link the &lt;a href="https://github.com/iden3/circom" rel="noopener noreferrer"&gt;iden3/circom&lt;/a&gt; README currently gives, because &lt;code&gt;docs.circom.io&lt;/code&gt; failed to resolve from two separate clients when this was checked on 29 September 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Wire up your verifier to your registration or access endpoint. The verifier function takes a proof and public inputs and returns true or false. That boolean is your gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pseudocode for a Node.js endpoint&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;groth16&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;snarkjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/register&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicSignals&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;vKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./verification_key.json&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;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;groth16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicSignals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid proof&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Proceed with account creation&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Add nullifier tracking. Store the nullifier from each proof in a database table. On each registration attempt, check that the nullifier has not been used before. This prevents credential reuse without revealing who the credential belongs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL example&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;used_nullifiers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;nullifier&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;used_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;Signal has not dropped the phone number requirement. It has added an optional, paid, Android-beta alternative to it, and free phone number registration remains the default path. What the feature does demonstrate is narrower and still useful: an anonymous credential can sit between a payment and an account so that the two cannot be correlated, even by the service that issued both. That is the principle worth borrowing, wherever you currently store an identifier only to prove that someone paid or was invited.&lt;/p&gt;

&lt;p&gt;The tooling is approachable enough that a small homelab project or self-hosted app can implement a basic ZKP gate without a cryptography PhD. Start with snarkjs, read the zkgroup source for inspiration, and design your constraints carefully. The hardest part is not the math, it is being precise about exactly what you need to prove.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/the-design-of-everyday-cryptography-a-homelab-primer/" rel="noopener noreferrer"&gt;The Design of Everyday Cryptography: A Homelab Primer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/reclaim-your-phone-self-hosting-android-alternatives-for-real-ownership/" rel="noopener noreferrer"&gt;Reclaim Your Phone: Self-Hosting Android Alternatives for Real Ownership&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/you-should-self-host-your-email-yes-even-in-2026/" rel="noopener noreferrer"&gt;You Should Self-Host Your Email (Yes, Even in 2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/how-signal-made-the-phone-number-optional/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>homelab</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building a Linux GPU Driver for the M4 Mac mini in One Month, Mostly With Coding Agents</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:52:53 +0000</pubDate>
      <link>https://dev.to/theeromi/building-a-linux-gpu-driver-for-the-m4-mac-mini-in-one-month-mostly-with-coding-agents-2pkm</link>
      <guid>https://dev.to/theeromi/building-a-linux-gpu-driver-for-the-m4-mac-mini-in-one-month-mostly-with-coding-agents-2pkm</guid>
      <description>&lt;p&gt;Apple Silicon is powerful hardware, but Linux support on it has always been a community-driven uphill battle. In September 2026 two developers, Cody Ho and Niklas Sheth, published a working, OpenGL ES 3.0 conformant Linux GPU driver for the M4 Mac mini and the MacBook Neo, built in about a month. Two facts frame how to read it, and both are missing from most coverage. First, the bulk of the code was written by LLM coding agents: Ho used Codex for the kernel and firmware reverse engineering and Claude for the user-space work, and he describes the result as "likely the first ever fully LLM-written GPU driver." Second, it ships in Gravity Linux, a Fedora remix that was forked from Asahi Linux over precisely that question. Asahi's contribution policy forbids generative AI; Gravity's permits it under disclosure and provenance rules. Neither of those facts settles whether the work is good, but you cannot read the project accurately without them.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Most people running Linux on Apple Silicon hardware are doing it through Asahi Linux, which has made incredible progress on M1 and M2 chips. The M4 generation is newer territory. Getting GPU acceleration working on Linux means the difference between a usable desktop and a machine that renders everything in software, burning CPU cycles on tasks the GPU should be handling.&lt;/p&gt;

&lt;p&gt;Beyond the Apple Silicon story, this project is a case study in something narrower: what coding agents do when pointed at a hard reverse engineering problem that already has an exhaustive test suite attached. The authors credit that suite directly, writing that "the hardest and most sensitive part of working with LLMs, giving them good tests to ground them, is already done for us" by the Khronos conformance tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏗️ The Core Strategy: Clean-Room RE, Driven by Agents
&lt;/h2&gt;

&lt;p&gt;It is tempting to assume the M4 driver is the Asahi driver patched for a newer chip. It is not. The write-up is titled "Building a GPU Driver From Scratch in One Month", and the team reports reverse engineering the AGX firmware ABI and the M4 user space themselves from live hardware traces, without looking at Apple binaries. They did reuse Asahi's kernel UAPI unchanged, and they credit the M1 and M2 driver for having already settled many of the design questions, but the code is new, including a custom shader compiler and command stream builder. They also report that the A18 Pro firmware ABI has roughly 1.5 times as many structs and twice as many pointers as the M1 and M2 one, so it was not a matter of adjusting register offsets.&lt;/p&gt;

&lt;p&gt;The other half of the method is the part the usual framing leaves out. The reverse engineering and most of the implementation ran as long, largely unattended agent loops. On the kernel side, Ho describes the agent capturing full GPU memory state through a hypervisor he had written for an earlier project, replaying that state after a reboot, and then progressively reconstructing the firmware structures in code until nothing was being replayed any more. On the user-space side, Sheth's agent iterated Mesa against the Khronos conformance suite until the OpenGL ES 3.0 tests passed.&lt;/p&gt;

&lt;p&gt;What that cost in human judgement is documented too, and is more useful than the headline. Ho reports that one compute capture came back at 336 MB and was impossible to replay, that the agent spent over a week on it without success, and that the fix was a human decision about method: boot into single-user mode so no render work runs, trigger a minimal Metal compute program, and capture that instead. He also reports a day lost because the agent chose the hardest subtask first. Neither developer claims the agents worked without supervision.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 The Toolchain
&lt;/h2&gt;

&lt;p&gt;Reverse engineering a GPU driver without documentation means you need good instrumentation. The general toolchain for this kind of work includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A purpose-built hypervisor:&lt;/strong&gt; Ho had already written a hypervisor to run and observe macOS on the hardware. That is what produced the firmware ABI traces, and the agents drove it directly, capturing full address spaces and diffing them against known-good samples when their own code misbehaved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-written Metal programs, not Apple's tooling:&lt;/strong&gt; the reference workloads were small Metal shaders the team wrote themselves, compiled and run so they could observe what changed in memory. They report not looking at Apple binaries, treating required Apple blobs as opaque objects, and having a third party document those blobs so the implementation could be written clean-room&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Khronos conformance test suite:&lt;/strong&gt; an existing, exhaustive corpus the driver has to pass. This is what the authors credit for keeping agent-written code grounded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mesa and DRM:&lt;/strong&gt; The userspace side of the driver lives in Mesa; the kernel side lives in the Linux DRM subsystem. Both have to agree on the ABI for rendering to work end to end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of the reverse engineering experiments have been published so that the provenance of the work can be checked independently, which is the point of a clean-room process rather than an extra. On upstreaming, the authors say the Mesa side uses the unmodified Asahi UAPI so there are no policy obstacles there, but that it still needs more testing and human review, and that they expect their code "will be held to a higher standard than human-written code." The kernel driver is further off, because they intend to wait for the M1 and M2 driver to go upstream first.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 How GPU Drivers Actually Fit Together on Linux
&lt;/h2&gt;

&lt;p&gt;If you have not dug into this before, here is the rough stack. The kernel side handles memory management, command submission, power management, and scheduling. The userspace side (Mesa for open-source drivers) handles shader compilation, draw call translation, and OpenGL or Vulkan API implementation. They communicate through ioctls defined by the DRM subsystem.&lt;/p&gt;

&lt;p&gt;For Apple Silicon, there is an additional layer. The GPU firmware runs on a separate processor inside the chip and has its own command protocol. The kernel driver does not program the GPU registers directly the way a traditional PC GPU driver might. Instead it constructs command buffers and submits them to the firmware, which then drives the hardware. Reverse engineering that firmware protocol is where most of the hard work lives.&lt;/p&gt;

&lt;p&gt;A simplified view of the stack looks 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;Application (game, desktop compositor, etc.)
    |
Mesa (OpenGL / Vulkan userspace driver)
    |
DRM ioctl interface (kernel boundary)
    |
Kernel DRM driver (memory, scheduling, command submission)
    |
GPU firmware (Apple proprietary, runs on embedded processor)
    |
M4 GPU hardware
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every layer has to be correct for rendering to work. A bug at any level shows up as a crash, a black screen, or corrupted output.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Security and Stability Considerations
&lt;/h2&gt;

&lt;p&gt;Running out-of-tree kernel modules on production hardware carries real risk. A buggy GPU driver can cause kernel panics, memory corruption, or silent data errors. If you are experimenting with early driver work on Apple Silicon Linux, keep these things in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep your GPU driver experiments on a dedicated machine or a VM, not on anything you rely on day to day&lt;/li&gt;
&lt;li&gt;Use kernel lockdown and secure boot awareness appropriate to your threat model. Early driver code often requires disabling module signing, which opens up other attack surfaces&lt;/li&gt;
&lt;li&gt;Snapshot your disk before major driver updates. Tools like &lt;code&gt;timeshift&lt;/code&gt; or a simple &lt;code&gt;dd&lt;/code&gt; image of your root partition will save you a painful recovery&lt;/li&gt;
&lt;li&gt;Watch kernel logs closely with &lt;code&gt;dmesg -w&lt;/code&gt; when testing new driver builds so you catch soft errors before they become hard crashes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🚀 What a One-Month Timeline Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;"Functional" here means more than a usable desktop. The claimed result is full OpenGL ES 3.0 conformance, with the failing tests being optional extensions, plus OpenGL 3.3 in the shipped Gravity Linux alpha, WebGL working in Chrome and Firefox with compositing, and Minecraft at 200fps. Vulkan 1.4, OpenGL 4.6, OpenGL ES 3.2, OpenCL, Direct3D 12 via Proton and ray tracing are all listed as remaining work rather than as done.&lt;/p&gt;

&lt;p&gt;What made a month possible is a specific combination, and the authors are explicit about each part: a hypervisor that already existed for capture, a conformance suite that already existed to grade against, the M1 and M2 driver as a map of which design decisions tend to be correct, and coding agents doing the repetitive reverse engineering and implementation. It was not uniformly fast. One compute capture consumed more than a week and was abandoned, and the productive approach was only found afterwards.&lt;/p&gt;

&lt;p&gt;For scale, the M1 and M2 Asahi GPU driver took years and many contributors, and the Gravity authors credit that work in plain terms. The two efforts are now separate projects: Asahi Linux continues on its own terms and its own policies, and Gravity Linux is the Fedora remix where this M4 driver ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔬 Applying This to Your Homelab
&lt;/h2&gt;

&lt;p&gt;Even if you are not writing kernel drivers, there are practical takeaways here for anyone running ARM hardware in a homelab.&lt;/p&gt;

&lt;p&gt;First, if you are running Linux in a VM or container on an M-series Mac, check whether your virtualization stack exposes GPU acceleration to guests. Tools like QEMU with VirtIO GPU, or commercial options, vary significantly in how much of the host GPU they surface. Knowing the driver stack helps you understand what you are actually getting.&lt;/p&gt;

&lt;p&gt;Second, the habit of iterating inside a snapshotted VM is worth adopting generally, even though it is not what this project did. Its iteration ran against real hardware, observed through a hypervisor written for the purpose. Still, if you are developing anything that touches kernel APIs or low-level hardware, do your iteration inside a VM with snapshots. The time you spend setting that up is paid back the first time a bad config would have bricked a bare-metal node.&lt;/p&gt;

&lt;p&gt;Third, be clear about which project covers which hardware, because the two are easy to conflate. Asahi Linux is the long-running effort and is where M1 and M2 support lives. The M4 driver described here is Gravity Linux's, and its first release is an early alpha for the M4 Mac mini published on 18 September 2026, which the authors label "FOR DEVELOPERS" and say is "not yet ready to be used as a daily driver." Suspend, display output over the USB-C ports and Thunderbolt or USB4 do not work in it, HDMI does, and the authors warn there may be no upgrade path from the alpha to the beta. Neither project's Apple Silicon GPU driver is upstream in the kernel yet. Check both projects' blogs and the upstream kernel lists before committing hardware to a production workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;A conformant Linux GPU driver for the M4 Mac mini in about a month is a notable result, and the method behind it is specific: clean-room reverse engineering from hypervisor traces, an existing conformance suite as the grader, and coding agents writing most of the code. The code is not ready for general use, it is not upstream, and it ships for now in Gravity Linux rather than Asahi Linux, for policy reasons rather than technical ones. If you want the detail first hand, the write-up is at &lt;a href="https://codyho.dev/blog/gpu-driver/" rel="noopener noreferrer"&gt;codyho.dev&lt;/a&gt; and the driver repositories are linked from it. The two projects' positions on generative AI in contributions are set out in full at &lt;a href="https://gravitylinux.org/policies/llm/" rel="noopener noreferrer"&gt;Gravity's LLM Use and Clean-Room Policy&lt;/a&gt; and &lt;a href="https://asahilinux.org/llm-policy/" rel="noopener noreferrer"&gt;Asahi's Generative AI Policy&lt;/a&gt;, and both are short enough to read before forming a view.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/proxmox-ve-now-officially-supports-arm64/" rel="noopener noreferrer"&gt;Proxmox VE Now Officially Supports ARM64: What It Means for Homelabs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/local-ai-self-hosting-best-practices-for-your-homelab/" rel="noopener noreferrer"&gt;Local AI Self-Hosting: Best Practices for Your Homelab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/run-local-llms-in-docker-no-vendor-lock-in-full-control/" rel="noopener noreferrer"&gt;Run Local LLMs in Docker: No Vendor Lock-In, Full Control&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/building-a-linux-gpu-driver-for-the-m4-mac-mini-in-one-month/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>homelab</category>
      <category>ai</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>An AI Coding Agent Uploaded Whole Git Histories: How to Lock Down Your Homelab</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:42:31 +0000</pubDate>
      <link>https://dev.to/theeromi/an-ai-coding-agent-uploaded-whole-git-histories-how-to-lock-down-your-homelab-52oo</link>
      <guid>https://dev.to/theeromi/an-ai-coding-agent-uploaded-whole-git-histories-how-to-lock-down-your-homelab-52oo</guid>
      <description>&lt;p&gt;On 18 September 2026 a developer called ferstar published a teardown of ZCode, the desktop coding app from Z.ai (Zhipu AI) built on the GLM models, and the Hacker News thread that followed put it in front of everyone. While clearing disk space, ferstar noticed &lt;code&gt;~/.zcode&lt;/code&gt; holding more than 700 MB, unpacked the app, and found that a logged in client was packaging workspace snapshots and uploading them to Alibaba Cloud object storage. The snapshots were not just the source tree: in one 313 MB example covering 42,411 files, 86.6 percent of the payload was the &lt;code&gt;.git&lt;/code&gt; directory, LFS objects and reflogs included. Each snapshot was encrypted with a key wrapped to a server held certificate, so the user could not decrypt their own upload. AI coding tools that run locally or integrate into your editor have deep filesystem access by design, and that access is increasingly being used in ways that are not obvious from the UI. If you run a homelab and use any AI agent that touches your code, this is your wake-up call.&lt;/p&gt;

&lt;p&gt;The resolution first, because it changes how you should read the rest. Z.ai responded the same day, initially attributing the uploads to a wiki generation feature. It deleted the Alibaba Cloud bucket on 20 September and on 21 September published ZCode's source under Apache 2.0 at &lt;a href="https://github.com/zai-org/ZCode" rel="noopener noreferrer"&gt;github.com/zai-org/ZCode&lt;/a&gt;, citing third party audits. ferstar's follow-up review of the published code found the upload endpoint, the object storage path and the key delivery code all absent from version 3.14.0 onward, with indexing running on locally bundled tools. So the specific behavior is gone. What nobody can audit is what happened to whatever was uploaded before 18 September, which is exactly why the cleanup below is still worth doing. Now: why homelabbers are a soft target, and what to do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters for Homelabbers
&lt;/h2&gt;

&lt;p&gt;Your homelab repos are not just toy projects. They often contain &lt;code&gt;.env&lt;/code&gt; files, hardcoded API keys, internal IP ranges, VPN configs, Ansible inventories, and Docker Compose files with credentials baked in. Git history makes this worse because a secret you deleted three commits ago is still fully recoverable from the log.&lt;/p&gt;

&lt;p&gt;AI coding agents are designed to read your project context, and that context is your entire repo tree. When an agent "understands your codebase," it is reading files and history. The question is whether it keeps that data local or phones home with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux or macOS machine running AI coding tools (ZCode, Cursor, the GitHub Copilot coding agent, Cline, Continue, etc.). If you see GitHub Copilot Workspace referenced in older guides, that preview was sunset on 30 May 2025 and its ideas moved into the Copilot coding agent.&lt;/li&gt;
&lt;li&gt;Basic comfort with the terminal and Git&lt;/li&gt;
&lt;li&gt;Optional: a network monitoring tool like &lt;code&gt;mitmproxy&lt;/code&gt;, &lt;code&gt;Wireshark&lt;/code&gt;, or a Pi-hole with logging enabled&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔍 Step 1: Audit What Your AI Tools Are Actually Sending
&lt;/h2&gt;

&lt;p&gt;Before you lock anything down, you need to know what is leaving your machine. The bluntest tool here is watching outbound connections while your AI agent is active.&lt;/p&gt;

&lt;p&gt;On Linux, use &lt;code&gt;ss&lt;/code&gt; or &lt;code&gt;nethogs&lt;/code&gt; to watch live traffic per process:&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;# Watch outbound connections from a specific process name&lt;/span&gt;
ss &lt;span class="nt"&gt;-tp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;zcode

&lt;span class="c"&gt;# Or monitor per-process bandwidth in real time&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nethogs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to inspect the actual payload, route the tool's traffic through &lt;code&gt;mitmproxy&lt;/code&gt;. Install it, set your &lt;code&gt;HTTP_PROXY&lt;/code&gt; and &lt;code&gt;HTTPS_PROXY&lt;/code&gt; environment variables to point at it, and trust its certificate. Then launch the AI agent from the same shell session and watch what it posts. Check the official docs for your specific tool to understand whether it respects proxy settings.&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;export &lt;/span&gt;&lt;span class="nv"&gt;HTTP_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:8080
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HTTPS_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:8080
&lt;span class="c"&gt;# Then launch your agent in the same terminal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🔧 Step 2: Scrub Secrets from Git History Before They Can Be Harvested
&lt;/h2&gt;

&lt;p&gt;If you have ever committed a secret to a repo that an AI agent can read, treat that secret as compromised. Rotate it first, then clean the history. The two most practical tools for this are &lt;code&gt;git filter-repo&lt;/code&gt; and BFG Repo Cleaner. Check each project's documentation for the exact flags, since the syntax differs and getting it wrong can corrupt history.&lt;/p&gt;

&lt;p&gt;A basic &lt;code&gt;git filter-repo&lt;/code&gt; invocation to strip a file from all history looks like this:&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;# Install git-filter-repo first (see https://github.com/newren/git-filter-repo)&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; secrets.env &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rewriting history, force-push to all remotes and immediately rotate any exposed credentials. Do not skip the rotation step. Cleaning history only helps for future reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Step 3: Use a .gitignore (and a Global One)
&lt;/h2&gt;

&lt;p&gt;The fastest mitigation is making sure sensitive files never enter Git in the first place. Add a thorough &lt;code&gt;.gitignore&lt;/code&gt; to every project and also configure a global gitignore that covers your whole machine.&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;# Set a global gitignore&lt;/span&gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; core.excludesFile ~/.gitignore_global

&lt;span class="c"&gt;# ~/.gitignore_global: common patterns to exclude&lt;/span&gt;
.env
.env.&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;.pem
&lt;span class="k"&gt;*&lt;/span&gt;.key
id_rsa
id_ed25519
secrets/
vault_pass.txt
ansible/group_vars/all/vault.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not protect history that already exists, but it prevents new secrets from being committed accidentally, which is the most common source of leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Step 4: Sandbox AI Agents with Network Policies
&lt;/h2&gt;

&lt;p&gt;If you want a harder boundary, run AI coding tools inside a sandboxed environment that limits outbound network access. There are a few practical ways to do this in a homelab.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker with network restrictions:&lt;/strong&gt; Run the agent in a container with &lt;code&gt;--network none&lt;/code&gt; or a custom bridge that only allows connections to your LAN and a specific AI API endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall rules per user:&lt;/strong&gt; On Linux, you can use &lt;code&gt;iptables&lt;/code&gt; or &lt;code&gt;nftables&lt;/code&gt; to restrict outbound traffic from a specific Unix user that you create just for running the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dedicated VLAN:&lt;/strong&gt; If you have managed switches and a router that supports VLANs (like pfSense or OPNsense), put your dev machine on a VLAN where you can log and filter all outbound connections at the firewall level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VLAN approach is the most powerful because it works regardless of what the agent does inside your OS. You see everything at the network edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Step 5: Prefer Local or Self-Hosted Models Where Possible
&lt;/h2&gt;

&lt;p&gt;The cleanest solution to data exfiltration is keeping data on your own hardware. Tools like Ollama let you run capable open-source models locally and expose them on a local API endpoint. Many AI coding frontends (Continue, for example) can be pointed at a local Ollama instance instead of a cloud API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Example: point Continue or a compatible tool at a local Ollama endpoint
# In the tool's config, set the API base URL to:
http://localhost:11434
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You give up some model capability compared to frontier cloud models, but for sensitive internal codebases the trade-off is often worth it. Check the documentation for whichever coding agent you use to confirm it supports custom API base URLs and what model format it expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Troubleshooting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mitmproxy shows no traffic from the agent:&lt;/strong&gt; Some tools use certificate pinning or ignore system proxy settings. Try monitoring at the network level with Wireshark on your loopback or default interface instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;git filter-repo says "not a git repository":&lt;/strong&gt; Make sure you are running the command from inside the repo root, and that you have the tool installed correctly. It is not bundled with Git by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama model responses are slow:&lt;/strong&gt; This is normal on CPU-only hardware. Quantized models (Q4 or Q5) give you a better speed and quality balance on machines without a dedicated GPU. Check the Ollama model library for available quantization levels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;The ZCode incident is a reminder that AI coding agents are powerful precisely because they have broad access to your project context, and that access can be abused, intentionally or accidentally. ZCode itself was patched and opened up within days, so treat this as a warning about the category rather than about one product, and note what it took to find out: a paying user had to measure a directory, unpack an Electron bundle and watch the traffic. Your homelab repos deserve the same scrutiny you would give a production environment.&lt;/p&gt;

&lt;p&gt;Start by auditing what your current tools are sending, scrub any secrets from Git history, and put network-level controls in place so you can see and limit outbound connections. If you are dealing with genuinely sensitive internal code, a local model via Ollama is worth the setup time. These are not exotic measures. They are just good hygiene for an era where your code editor is also a networked agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/better-homelab-secret-management-with-infisical/" rel="noopener noreferrer"&gt;Better Homelab Secret Management with Infisical&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/an-ai-agent-racked-up-a-massive-bill-scanning-dn42-heres-how-to-stop-that-from-happening-to-you/" rel="noopener noreferrer"&gt;An AI Agent Ran Up a $6,531 AWS Bill Scanning DN42. Here's How to Cap Yours&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/crowdsec-community-powered-security/" rel="noopener noreferrer"&gt;CrowdSec: Community-Powered Security&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/an-ai-coding-agent-uploaded-whole-git-histories-how-to-lock-down-your-homelab/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>docker</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Self-Host BookOrbit on Your NAS: Ebooks, Audiobooks and Comics in One Library</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:42:12 +0000</pubDate>
      <link>https://dev.to/theeromi/self-host-bookorbit-on-your-nas-ebooks-audiobooks-and-comics-in-one-library-5ge4</link>
      <guid>https://dev.to/theeromi/self-host-bookorbit-on-your-nas-ebooks-audiobooks-and-comics-in-one-library-5ge4</guid>
      <description>&lt;p&gt;I had ebooks on a laptop, audiobooks in an app that wanted a subscription, and comics in a folder I had stopped opening. &lt;a href="https://bookorbit.app/" rel="noopener noreferrer"&gt;BookOrbit&lt;/a&gt; collapses all three into one self-hosted library with a browser reader, an OPDS catalogue, and two way progress sync with Kobo and KOReader. It is AGPL-3.0 and Docker only.&lt;/p&gt;

&lt;p&gt;This is the setup I actually ran, on a TerraMaster NAS rather than a VM, plus the one permissions mistake that will have you convinced your paths are wrong when they are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the NAS and not a VM
&lt;/h2&gt;

&lt;p&gt;My first plan put it on a Linux VM and mounted the library over CIFS. Moving it to the NAS deleted three problems at once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No network mount at all.&lt;/strong&gt; The books already live on the NAS disk. The VM plan needed a CIFS mount, stored credentials, and systemd hardening so that a NAS hiccup could not hang the VM at boot. A container on the box that owns the files needs none of that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller blast radius.&lt;/strong&gt; BookOrbit falling over does not touch anything else I run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Space.&lt;/strong&gt; Terabytes free on the NAS against tens of gigabytes on the VM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest caveat. If your NAS also holds backups, you have just added a workload to the thing that protects everything else. BookOrbit itself is light, under two gigabytes with Postgres, but a runaway library can fill a volume. Keep an eye on free space.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need first
&lt;/h2&gt;

&lt;p&gt;Docker on the NAS, which on TerraMaster TOS means installing &lt;strong&gt;Docker Engine&lt;/strong&gt; first and then &lt;strong&gt;Docker Manager&lt;/strong&gt; on top of it. Synology and QNAP have equivalents. Then check over SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;docker compose&lt;/code&gt; is missing from the CLI, you can still deploy: Docker Manager has a &lt;strong&gt;Projects&lt;/strong&gt; feature that takes a &lt;code&gt;docker-compose.yml&lt;/code&gt; and an &lt;code&gt;.env&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;Create a shared folder for the library, give your user read and write, and make the layout:&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; /Volume1/Books/&lt;span class="o"&gt;{&lt;/span&gt;Ebooks,Audiobooks,Comics,_dock&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Two services, the app and Postgres. The full &lt;code&gt;docker-compose.yml&lt;/code&gt;, an &lt;code&gt;.env.example&lt;/code&gt; and a setup script are in the download at the end of this post, so I will not paste all of it here. The script creates the directories, generates the four secrets, detects your real UID and GID, and writes the &lt;code&gt;.env&lt;/code&gt; with mode 600.&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;# dry run first, it prints what it would do and changes nothing&lt;/span&gt;
&lt;span class="nv"&gt;BOOKS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/Volume1/Books &lt;span class="nv"&gt;STACK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/Volume1/docker/bookorbit ./setup.sh
&lt;span class="nv"&gt;BOOKS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/Volume1/Books &lt;span class="nv"&gt;STACK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/Volume1/docker/bookorbit ./setup.sh &lt;span class="nt"&gt;--apply&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; /Volume1/docker/bookorbit
docker compose config &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"compose valid"&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want the app &lt;code&gt;running&lt;/code&gt; and the database &lt;code&gt;healthy&lt;/code&gt;. Then browse to the app, paste the &lt;code&gt;SETUP_BOOTSTRAP_TOKEN&lt;/code&gt; the script printed, and create the administrator account. That token is single use and stops working afterwards.&lt;/p&gt;

&lt;p&gt;Two details in that compose file worth knowing rather than copying blindly. The app container ships &lt;code&gt;read_only: true&lt;/code&gt; with &lt;code&gt;cap_drop: ALL&lt;/code&gt;, because upstream has already hardened it, so leave that alone. And the Postgres volume must be on &lt;strong&gt;local disk&lt;/strong&gt;. Books can live on a network share. The database cannot, because Postgres on a network filesystem risks corruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a scan that succeeds and finds nothing
&lt;/h2&gt;

&lt;p&gt;This is the single most common failure with this app and it does not look like a permissions problem. The scan runs, reports success, and your library is empty. You check the paths. The paths are correct. You check them again.&lt;/p&gt;

&lt;p&gt;The cause is &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; not matching whoever owns the library folder. On a NAS the admin user is frequently &lt;strong&gt;not&lt;/strong&gt; uid 1000, which is what every tutorial assumes.&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;id&lt;/span&gt;                      &lt;span class="c"&gt;# your real uid and gid&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ln&lt;/span&gt; /Volume1/Books   &lt;span class="c"&gt;# who actually owns the library&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the real numbers in the &lt;code&gt;.env&lt;/code&gt; and bring the stack back up. That is why the setup script detects them rather than letting you guess.&lt;/p&gt;

&lt;p&gt;A related one: when you create a library in the UI, the folder you enter is the path &lt;strong&gt;as the container sees it&lt;/strong&gt;. That is &lt;code&gt;/books/Audiobooks&lt;/code&gt;, not the host path. The host path appears exactly once, in &lt;code&gt;BOOKS_HOST_PATH&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming your library so it scans correctly
&lt;/h2&gt;

&lt;p&gt;The scanner infers author, series and title from &lt;strong&gt;folder names&lt;/strong&gt;. One folder per book, always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Author - Series NN - Title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bram Stoker - Dracula                                   &amp;lt;- standalone, no series part
Rebecca Yarros - Empyrean 01 - Fourth Wing
Rebecca Yarros - Empyrean 3.5 - Threshing Day           &amp;lt;- novellas keep the decimal
Sarah J. Maas - ACOTAR 01 - A Court of Thorns and Roses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four rules, each of which I learned by having it wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never leave a loose file directly in the media folder.&lt;/strong&gt; In &lt;em&gt;Folder as Book&lt;/em&gt; mode it has no folder to belong to. A single &lt;code&gt;.m4b&lt;/code&gt; still gets its own folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never nest a series folder.&lt;/strong&gt; &lt;code&gt;Crescent City/1 - House of Earth and Blood&lt;/code&gt; makes the scanner read the &lt;em&gt;parent&lt;/em&gt; as one book containing every volume. Flatten it and put the series in the folder name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the separator exactly space, hyphen, space.&lt;/strong&gt; &lt;code&gt;Author- Title&lt;/code&gt; and &lt;code&gt;Author-Title&lt;/code&gt; parse differently, and you will end up with all three variants by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-pad series numbers.&lt;/strong&gt; &lt;code&gt;01&lt;/code&gt; not &lt;code&gt;1&lt;/code&gt;, or book 10 sorts before book 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Drop edition noise like &lt;code&gt;(m4b)&lt;/code&gt; or &lt;code&gt;Unabridged&lt;/code&gt;. It is not part of the title and it confuses the metadata match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Folder as Book versus File as Book
&lt;/h2&gt;

&lt;p&gt;Make one library per media type, because the allowed formats and metadata priority differ. The setting that matters most is the scan mode.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Folder as Book&lt;/strong&gt; keeps everything in a book's folder together: alternate formats, covers, sidecar &lt;code&gt;.opf&lt;/code&gt; metadata, and every audio track of one audiobook. It understands disc folders like &lt;code&gt;CD 1&lt;/code&gt; and &lt;code&gt;Disc 2&lt;/code&gt;. Use this for audiobooks and for any ebook you keep in more than one format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File as Book&lt;/strong&gt; means one file equals one title. Only correct for a flat pile of single-file EPUBs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an audiobook appears as thirty separate titles, the library is in &lt;em&gt;File as Book&lt;/em&gt;. That is the entire diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading and listening on a phone
&lt;/h2&gt;

&lt;p&gt;There is an &lt;strong&gt;official iOS app&lt;/strong&gt;, &lt;a href="https://apps.apple.com/us/app/bookorbit-the-official-app/id6811807346" rel="noopener noreferrer"&gt;BookOrbit: The Official App&lt;/a&gt;, free with in-app purchases. It is offline first, streams or downloads audiobooks with chapter navigation, does text to speech, and has Apple Watch support for offline listening. It needs a BookOrbit server to point at, so it is a client and not a standalone reader.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;Android&lt;/strong&gt; there is no official app yet. The web PWA plays audiobooks but without proper OS integration, so lockscreen controls and background playback are the weak points. Worth knowing before you promise someone in your house that this will work on their phone.&lt;/p&gt;

&lt;p&gt;For e-readers, BookOrbit exposes an &lt;strong&gt;OPDS&lt;/strong&gt; catalogue that KOReader, Thorium and Moon+ Reader can subscribe to. OPDS is ebook oriented, so it is not much use for audiobooks.&lt;/p&gt;

&lt;p&gt;Away from home, the honest answer is not to expose the NAS. Reach it over a mesh VPN such as Tailscale and browse to its address on the tailnet, for example &lt;code&gt;http://100.101.102.103:3000&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I left out on purpose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kokoro&lt;/code&gt; text to speech.&lt;/strong&gt; Upstream ships it behind a &lt;code&gt;tts&lt;/code&gt; profile. It is CPU heavy and a NAS processor is usually modest, so it is omitted. Add it later with &lt;code&gt;docker compose --profile tts up -d&lt;/code&gt; and watch your load average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxy and HTTPS.&lt;/strong&gt; Unnecessary when it is reachable on the LAN and over a VPN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OIDC and SSO.&lt;/strong&gt; Supported, but overkill for a handful of users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The backup conversation nobody has
&lt;/h2&gt;

&lt;p&gt;If your hypervisor runs nightly backups, those back up &lt;strong&gt;guests&lt;/strong&gt;. They do not back up the NAS. Your books and the BookOrbit database live on the NAS, so unless the NAS has its own backup job, nothing is protecting either of them. Decide that deliberately rather than discovering it.&lt;/p&gt;

&lt;p&gt;And dump the database before every update, because BookOrbit runs schema migrations on start, which means rolling the image back is &lt;strong&gt;not&lt;/strong&gt; a rollback once a migration has run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;bookorbit-db pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; bookorbit bookorbit &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; bookorbit-db-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When something is wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scan completes, finds no books&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; do not match the owner of the library folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An audiobook shows as many separate titles&lt;/td&gt;
&lt;td&gt;Library is in &lt;em&gt;File as Book&lt;/em&gt; mode, switch to &lt;em&gt;Folder as Book&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One book contains an entire series&lt;/td&gt;
&lt;td&gt;A nested series folder, flatten it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker: command not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Engine not installed, or the SSH session needs a re-login after installing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port already in use&lt;/td&gt;
&lt;td&gt;Something else has 3000, check with `ss -lnt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres will not start, or is corrupt&lt;/td&gt;
&lt;td&gt;Its data directory is on a network share. It must be local disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containers vanish after a NAS firmware update&lt;/td&gt;
&lt;td&gt;The update restarted Docker. {% raw %}&lt;code&gt;restart: unless-stopped&lt;/code&gt; should bring them back&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The files below are the compose file, the &lt;code&gt;.env&lt;/code&gt; template, the setup script and the naming rules as a separate reference. Every address in them is an example, nothing points at a real host, and there are no secrets to strip because the script generates them on your machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Download the files
&lt;/h2&gt;

&lt;p&gt;Both are free, no sign up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://codewithromi.com/content/files/2026/10/bookorbit-nas-bundle.zip" rel="noopener noreferrer"&gt;bookorbit-nas-bundle.zip&lt;/a&gt; contains the &lt;code&gt;docker-compose.yml&lt;/code&gt;, an &lt;code&gt;.env.example&lt;/code&gt; with every variable documented, the &lt;code&gt;setup.sh&lt;/code&gt; that generates the secrets and detects your UID and GID, plus a README of the four things that will bite you.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://codewithromi.com/content/files/2026/10/bookorbit-naming-rules.md" rel="noopener noreferrer"&gt;bookorbit-naming-rules.md&lt;/a&gt; is the folder convention on its own, because that is the part you will want open while you reorganise a library.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/self-host-your-photos/" rel="noopener noreferrer"&gt;Self-Host Your Photos with Immich&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/nfs-vs-smb-which-protocol-should-you-use-for-your-homelab-storage/" rel="noopener noreferrer"&gt;NFS vs SMB: Which Protocol Should You Use for Your Homelab Storage?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/how-i-access-my-entire-homelab-from-anywhere-with-tailscale/" rel="noopener noreferrer"&gt;How I Access My Entire Homelab from Anywhere with Tailscale&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/self-host-bookorbit-on-your-nas/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>homelab</category>
      <category>database</category>
      <category>storage</category>
    </item>
    <item>
      <title>Patch These Four Linux Kernel Local Root Vulns Now: DirtyAH6, PPPoEject, TUNderflow, and DiagSpill</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Wed, 07 Oct 2026 23:00:49 +0000</pubDate>
      <link>https://dev.to/theeromi/patch-these-four-linux-kernel-local-root-vulns-now-dirtyah6-pppoeject-tunderflow-and-diagspill-4730</link>
      <guid>https://dev.to/theeromi/patch-these-four-linux-kernel-local-root-vulns-now-dirtyah6-pppoeject-tunderflow-and-diagspill-4730</guid>
      <description>&lt;p&gt;Four Linux kernel vulnerabilities went public on 18 September 2026, and they are the kind you should not sit on. DirtyAH6 (CVE-2026-80844), PPPoEject (CVE-2026-68121), TUNderflow (CVE-2026-81000) and DiagSpill (CVE-2026-74469) are all local privilege escalation bugs, meaning an attacker or malicious process with a foothold on your machine can escalate to root. Working proof of concept exploits for all four are public. If you are running Proxmox, a Raspberry Pi, or any self-hosted Linux node, this is a patching weekend.&lt;/p&gt;

&lt;p&gt;Security researcher Asim Manizada reported the four bugs in mid July 2026 and published a technical write-up plus working exploits on 18 September 2026, after a coordinated hold so distributions could ship fixes first. The &lt;a href="https://seclists.org/oss-sec/2026/q3/822" rel="noopener noreferrer"&gt;oss-security post&lt;/a&gt; and the accompanying &lt;a href="https://heyitsas.im/posts/lpe-quartet/" rel="noopener noreferrer"&gt;write-up&lt;/a&gt; carry the per-CVE detail. Upstream, the earliest stable releases that contain all four fixes are 5.10.270, 5.15.221, 6.1.188, 6.6.157, 6.12.109, 6.18.50 and 7.2.4. Note that the 7.0 series reached end of life on 27 June 2026, so a 7.0 kernel only carries these fixes if your vendor backported them.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters for Homelabbers
&lt;/h2&gt;

&lt;p&gt;Local root exploits are often dismissed as "low risk" because they require existing access. That framing misses the threat model for homelabs and self-hosted infrastructure. If any service you run gets compromised, a web app, an LXC container with a shared kernel, or even a rogue package, a local root bug turns that into full node ownership.&lt;/p&gt;

&lt;p&gt;Proxmox LXC containers share the host kernel. A container breakout combined with a local priv-esc is a realistic attack chain. Raspberry Pi nodes running exposed services are in the same boat. Patch the kernel, then verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SSH or console access to each node&lt;/li&gt;
&lt;li&gt;sudo or root privileges&lt;/li&gt;
&lt;li&gt;A maintenance window, since patching requires a reboot&lt;/li&gt;
&lt;li&gt;Proxmox nodes: access to the Proxmox web UI or CLI for VM/container shutdown sequencing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Step-by-Step: Patching Your Nodes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Check your current kernel version on every node so you know what you are starting from.&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;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note this output. You will compare it after the update to confirm the new kernel is running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Update your package index and apply all available security updates. On Debian and Ubuntu based systems, including Proxmox and Raspberry Pi OS:&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;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt full-upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Proxmox specifically, you also want to pull from the Proxmox repositories. If you are on the no-subscription repo, this is already covered by the command above. Run &lt;code&gt;pveversion -v&lt;/code&gt; afterward to confirm the Proxmox packages are current too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; If you are running Raspberry Pi OS, check the kernel package by its current name. On Bullseye and earlier the kernel shipped in the &lt;code&gt;raspberrypi-kernel&lt;/code&gt; package, but Raspberry Pi OS moved to Debian style &lt;code&gt;linux-image-rpi-*&lt;/code&gt; packages with Bookworm, so on a current install you want &lt;code&gt;linux-image-rpi-v8&lt;/code&gt; or &lt;code&gt;linux-image-rpi-2712&lt;/code&gt; depending on the board. The &lt;code&gt;apt full-upgrade&lt;/code&gt; command above will still catch it, but confirm with:&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;# Bookworm and newer&lt;/span&gt;
apt-cache policy linux-image-rpi-v8

&lt;span class="c"&gt;# Bullseye and older&lt;/span&gt;
apt-cache policy raspberrypi-kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "Installed" and "Candidate" versions should match after your upgrade. If they do not, run the upgrade again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Reboot to load the new kernel. This is not optional. The running kernel in memory is still the old one until you reboot.&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;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Proxmox hosts, shut down or migrate your VMs and containers before rebooting the host. Use the Proxmox web UI or:&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;# List running VMs&lt;/span&gt;
qm list

&lt;span class="c"&gt;# Shutdown a VM by ID&lt;/span&gt;
qm shutdown &lt;span class="o"&gt;[&lt;/span&gt;vmid]

&lt;span class="c"&gt;# List running containers&lt;/span&gt;
pct list

&lt;span class="c"&gt;# Shutdown a container by ID&lt;/span&gt;
pct shutdown &lt;span class="o"&gt;[&lt;/span&gt;ctid]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; After the reboot, confirm the new kernel 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;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version string should be newer than what you recorded in Step 1. Cross-reference it against your distro's security advisory to confirm the patched version is in place. For Debian, check &lt;a href="https://security-tracker.debian.org/tracker/" rel="noopener noreferrer"&gt;security-tracker.debian.org&lt;/a&gt;. For Ubuntu, check &lt;a href="https://ubuntu.com/security/notices" rel="noopener noreferrer"&gt;ubuntu.com/security/notices&lt;/a&gt;. For Raspberry Pi OS, check the &lt;a href="https://www.raspberrypi.com/news/" rel="noopener noreferrer"&gt;Raspberry Pi blog and release notes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Patch Status, Checked 29 September 2026
&lt;/h2&gt;

&lt;p&gt;This is the part of the post that goes stale fastest, so here is a dated snapshot. Everything below was checked against the Debian security tracker, the Ubuntu CVE tracker and the Proxmox kernel changelog on 29 September 2026. Re-check before you decide you are covered.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debian 13 trixie:&lt;/strong&gt; all four first fixed in linux 6.12.111-1, shipped as DSA-6528-1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debian 12 bookworm:&lt;/strong&gt; DirtyAH6, PPPoEject and DiagSpill first fixed in linux 6.1.187-1 from bookworm-security. TUNderflow is the exception. The Debian tracker still lists bookworm as vulnerable, 6.1.187-1 included, so there is no fix for CVE-2026-81000 on bookworm yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ubuntu 22.04, 24.04 and 26.04:&lt;/strong&gt; at the time of this check the Ubuntu CVE tracker showed no released kernel for any of the four on the main &lt;code&gt;linux&lt;/code&gt; packages. They sit at needed or pending. Watch &lt;a href="https://ubuntu.com/security/notices" rel="noopener noreferrer"&gt;ubuntu.com/security/notices&lt;/a&gt; instead of assuming an upgrade fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox VE:&lt;/strong&gt; the Proxmox kernel changelog names only TUNderflow, first fixed in proxmox-kernel-7.0 version 7.0.14-19, released 18 September 2026. The newest build at the time of checking was 7.0.14-20 from 24 September 2026. The other three CVEs are not named anywhere in that changelog, and the Proxmox 7.0 kernel follows the Ubuntu kernel, which has not released them, so do not assume DirtyAH6, PPPoEject and DiagSpill are closed on a Proxmox host. Run &lt;code&gt;pveversion -v&lt;/code&gt; and compare against the changelog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raspberry Pi OS:&lt;/strong&gt; no Raspberry Pi OS kernel build carrying all four fixes could be confirmed at the time of this check. On the 6.12 branch you need 6.12.109 or newer for the full set, so check the installed package version rather than assuming.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🛡️ What Each Vulnerability Affects
&lt;/h2&gt;

&lt;p&gt;Here is what each bug actually is, taken from the disclosure and the researcher's write-up. Three of the four need &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; in a network namespace the attacker controls, which an ordinary local user obtains by creating an unprivileged user namespace first. DiagSpill is the exception and needs no special privileges at all.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DirtyAH6 (CVE-2026-80844):&lt;/strong&gt; The IPv6 Authentication Header code in the IPsec and XFRM path does not check that a routing header's &lt;code&gt;segments_left&lt;/code&gt; field is no larger than the number of addresses actually present, so pointer arithmetic in &lt;code&gt;ipv6_rearrange_rthdr()&lt;/code&gt; walks out of bounds and an oversized &lt;code&gt;memmove()&lt;/code&gt; corrupts kernel memory. It needs AH6 and XFRM support plus &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; and &lt;code&gt;CAP_NET_RAW&lt;/code&gt; in a namespace the attacker controls. The researcher also reports a remote crash, and in his own lab remote root, against a host acting as an IPv6 router that applies AH in transport mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PPPoEject (CVE-2026-68121):&lt;/strong&gt; A use after free in &lt;code&gt;pppoe_sendmsg()&lt;/code&gt;. The function holds a pointer into the socket buffer head across a &lt;code&gt;dev_hard_header()&lt;/code&gt; callback that can reallocate that head, then writes six bytes through the stale pointer. It needs a lower device whose header callback expands the buffer head, not merely a loadable PPPoE module, which makes it the narrowest of the four in practice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TUNderflow (CVE-2026-81000):&lt;/strong&gt; The TUN/TAP driver stores a receive headroom value without bounding it. A device path that propagates oversized headroom (the proof of concept chains a netkit device through Open vSwitch to a raw TUN port) underflows a size calculation and leaves packet data 64 bytes past the end of the allocation. Simply having a TUN or TAP interface is not enough, you need that headroom propagation path. Worth correcting a common assumption here: Docker's default bridge networking uses veth pairs rather than TUN/TAP, and the in kernel WireGuard module is its own device type, so the usual homelab exposure is QEMU tap interfaces and OpenVPN style TUN devices, not containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DiagSpill (CVE-2026-74469):&lt;/strong&gt; An SCTP association can hold up to 65,536 peer transports but the counter is 16 bits wide, so it wraps to zero at the limit. &lt;code&gt;sctp_diag&lt;/code&gt; then reserves buffer space based on the wrapped count and copies the full transport list anyway, writing roughly 8 MiB past the end of the response buffer. This one needs no capabilities and no user namespace, only SCTP and &lt;code&gt;sctp_diag&lt;/code&gt; support, which is what makes it the priority of the four.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DiagSpill is the one to move fastest on if you are triaging. It is the only one of the four that an ordinary local user can reach without creating a user namespace, which also means the usual advice to restrict unprivileged user namespaces does nothing for it. If you do not need SCTP, keep the module out of the kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Hardening Beyond the Patch
&lt;/h2&gt;

&lt;p&gt;Patching is the fix, but a few additional controls reduce your exposure, which matters more than usual here because some distributions have not shipped all four yet. Restricting unprivileged user namespaces closes the ordinary user path to three of the four. It does nothing for DiagSpill.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restrict unprivileged user namespaces:&lt;/strong&gt; Three of these four bugs need a user namespace for an ordinary local user to reach them. Ubuntu introduced AppArmor based restrictions in 23.10 and enabled them by default from 24.04, controlled by &lt;code&gt;kernel.apparmor_restrict_unprivileged_userns&lt;/code&gt;, so 24.04 and 26.04 are covered out of the box. Debian is not. The Debian and Ubuntu specific &lt;code&gt;kernel.unprivileged_userns_clone&lt;/code&gt; knob has been discussed for deprecation, so confirm it exists on your kernel with &lt;code&gt;sysctl -a | grep userns&lt;/code&gt; before relying on it; the portable upstream equivalent is &lt;code&gt;sysctl -w user.max_user_namespaces=0&lt;/code&gt;. Either way you will break things that legitimately use user namespaces, including Flatpak, rootless containers and browser sandboxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use AppArmor or SELinux profiles:&lt;/strong&gt; Proxmox ships with AppArmor. Make sure it is enforcing for your LXC containers. Unprivileged containers with AppArmor confinement are significantly harder to break out of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit local access:&lt;/strong&gt; Do not run untrusted code or expose shell access to untrusted users on these nodes. Local privesc only matters if someone or something local can trigger it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unload unused kernel modules:&lt;/strong&gt; If you are not using PPPoE or SCTP, keep both out. Add &lt;code&gt;blacklist pppoe&lt;/code&gt; and &lt;code&gt;blacklist sctp&lt;/code&gt; to a file in &lt;code&gt;/etc/modprobe.d/&lt;/code&gt; and run &lt;code&gt;update-initramfs -u&lt;/code&gt;. Blacklisting stops the on demand autoload that a socket call would trigger, and for DiagSpill that is the only mitigation short of patching, since restricting user namespaces does not touch it. Verify with &lt;code&gt;lsmod | grep -E 'pppoe|sctp'&lt;/code&gt; afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Kernel version did not change after reboot:&lt;/strong&gt; Your package manager may have the patched package available but it was not installed. Run &lt;code&gt;apt list --upgradable | grep linux-image&lt;/code&gt; to check, then install explicitly with &lt;code&gt;sudo apt install linux-image-[version]&lt;/code&gt; matching your architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxmox node is stuck on an older kernel:&lt;/strong&gt; Proxmox sometimes pins the kernel for stability. Check &lt;code&gt;/etc/default/grub&lt;/code&gt; and the GRUB menu to ensure the new kernel is selected as default. Run &lt;code&gt;update-grub&lt;/code&gt; after confirming the package is installed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raspberry Pi will not boot after upgrade:&lt;/strong&gt; This is rare but can happen if the bootloader and kernel fall out of sync. Boot from a backup SD card or use &lt;code&gt;rpi-update&lt;/code&gt; with caution, and check the Raspberry Pi OS forums for your specific board and OS version before using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;Four local root bugs in one disclosure batch is a bad day for Linux security. Updating and rebooting is the right move, but as of 29 September 2026 it is not the whole answer: Debian trixie has all four, Debian bookworm is still missing the TUNderflow fix, and Ubuntu has not released kernels for any of them. Prioritize DiagSpill, since it is the one an unprivileged local user can reach without a user namespace, and check the tracker for your distribution instead of assuming a fresh upgrade covered you.&lt;/p&gt;

&lt;p&gt;After you patch, take five minutes to check your other nodes. It is easy to forget a Raspberry Pi tucked behind the TV or a secondary Proxmox node you set up months ago. Cross-reference the patched kernel version against your distro's security tracker to make sure you are actually covered, not just up to date on packages.&lt;/p&gt;

&lt;p&gt;Stay subscribed to &lt;a href="https://oss-security.openwall.org/wiki/mailing-lists/oss-security" rel="noopener noreferrer"&gt;oss-sec&lt;/a&gt; if you want these disclosures in your inbox before they make the rounds on aggregators.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/linux-kernel-security-gaps-why-your-homelab-isnt-safe-until-you-know-this/" rel="noopener noreferrer"&gt;Linux Kernel Security Gaps: Why Your Homelab Isn't Safe Until You Know This&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/patching-dnsmasq-cves-securing-your-pi-hole-homelab-dns/" rel="noopener noreferrer"&gt;Patching Dnsmasq CVEs: Securing Your Pi-hole &amp;amp; Homelab DNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/crowdsec-community-powered-security/" rel="noopener noreferrer"&gt;CrowdSec: Community-Powered Security&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/patch-these-four-linux-kernel-local-root-vulns-now-dirtyah6-pppoeject-tunderflow-and-diagspill/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>WordPress 7.1.2 Patches a Critical Path Traversal to RCE: Update Now</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Wed, 07 Oct 2026 23:00:46 +0000</pubDate>
      <link>https://dev.to/theeromi/wordpress-712-patches-a-critical-path-traversal-to-rce-update-now-24pj</link>
      <guid>https://dev.to/theeromi/wordpress-712-patches-a-critical-path-traversal-to-rce-update-now-24pj</guid>
      <description>&lt;p&gt;A serious vulnerability was disclosed in the WordPress core codebase: an unauthenticated path traversal bug that, under the right server conditions, can escalate to remote code execution. That combination, no login required plus potential RCE, puts this near the top of the priority list for any WordPress site owner or host. Here is what the vulnerability is, who is affected, and exactly what to do about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Path traversal vulnerabilities let an attacker reference files outside the intended directory by injecting sequences like &lt;code&gt;../&lt;/code&gt; into a request. On their own they are dangerous. Chained with a code execution primitive, they become critical.&lt;/p&gt;

&lt;p&gt;The "conditional" part of this disclosure means RCE is not guaranteed on every WordPress install. Server configuration, PHP settings, and installed plugins or themes all influence whether the traversal can be weaponised into execution. But "conditional" does not mean "safe to ignore." Attackers will probe for the conditions that make it work, and many shared hosting and default VPS setups are likely to qualify.&lt;/p&gt;

&lt;p&gt;The advisory is &lt;a href="https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp" rel="noopener noreferrer"&gt;GHSA-7hp8-65ch-5whp&lt;/a&gt; in the WordPress develop repository, and it is CVE-2026-87902, rated Critical at CVSS 4.0 9.2. Every release from 4.7.0 through 7.1.1 is affected. WordPress shipped 7.1.2 on 22 September 2026 along with backports to every branch still eligible for security fixes, down to 4.7.37. Treat this as urgent rather than theoretical: CISA added CVE-2026-87902 to its Known Exploited Vulnerabilities catalog on 25 September 2026, and public scanning tooling plus attempts to write PHP files through &lt;code&gt;pearcmd.php&lt;/code&gt; are already being seen in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 Prerequisites: Who Should Read This
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Anyone running a self-hosted WordPress site on 4.7.0 or later, which in practice means anyone running WordPress&lt;/li&gt;
&lt;li&gt;Hosting providers and managed WordPress hosts&lt;/li&gt;
&lt;li&gt;Homelab builders running WordPress internally (yes, even private sites)&lt;/li&gt;
&lt;li&gt;DevOps teams with WordPress in staging or CI pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If WordPress is running somewhere you control, this post is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 How the Vulnerability Works
&lt;/h2&gt;

&lt;p&gt;The flaw is in one specific thing: page template resolution. An unauthenticated request can steer &lt;code&gt;get_page_template()&lt;/code&gt; into including a chosen readable local &lt;code&gt;.php&lt;/code&gt; file from outside the active theme's directories. That makes it a local file inclusion rather than a generic web root escape, and it is classed as CWE-98, improper control of filename for an include or require statement.&lt;/p&gt;

&lt;p&gt;The conditions are narrower and far more specific than 'a misconfigured server', and they are worth checking against your own install rather than guessing. The file inclusion needs the active theme, parent or child, to contain a top level directory whose name begins with &lt;code&gt;page-&lt;/code&gt;. That is not exotic: &lt;code&gt;page-templates/&lt;/code&gt; ships in Twenty Twelve and Twenty Fourteen and in popular themes including Neve, Hestia and Sydney. Turning that inclusion into code execution needs two more things: a readable &lt;code&gt;.php&lt;/code&gt; file on the server that will act on attacker supplied arguments, in practice &lt;code&gt;pearcmd.php&lt;/code&gt;, and &lt;code&gt;register_argc_argv&lt;/code&gt; set to &lt;code&gt;On&lt;/code&gt;. Official PHP Docker images and default cPanel configurations on PHP before 8.5 both qualify. For the precise code path, refer to the &lt;a href="https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp" rel="noopener noreferrer"&gt;official security advisory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛠️ Step-by-Step: Patching Your WordPress Install
&lt;/h2&gt;

&lt;p&gt;The most important action is updating WordPress core. On the current branch that means 7.1.2. If you are held back on an older branch there is a backport for it, 7.0.6, 6.9.9, 6.8.10, 6.7.9 and so on all the way down to 4.7.37, so being unable to move to 7.1 is not a reason to stay unpatched. Here is how to update cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Back up your database and files before touching anything.&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;# Example using WP-CLI to export the database&lt;/span&gt;
wp db &lt;span class="nb"&gt;export &lt;/span&gt;backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql &lt;span class="nt"&gt;--allow-root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Update WordPress core via WP-CLI (recommended for server installs).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core update &lt;span class="nt"&gt;--allow-root&lt;/span&gt;
wp core version &lt;span class="nt"&gt;--allow-root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Confirm the installed version matches the patched release listed in the advisory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core version &lt;span class="nt"&gt;--allow-root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; If you manage WordPress through a hosting dashboard (cPanel, Plesk, Kinsta, etc.), use the built-in one-click updater or auto-update toggle. Confirm the version number in the WordPress admin under Dashboard, then About.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Enable automatic background updates for minor releases if you have not already. Add this to your &lt;code&gt;wp-config.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_AUTO_UPDATE_CORE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'minor'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures security patches ship automatically without waiting for manual intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Hardening: Reduce the Attack Surface Regardless of Version
&lt;/h2&gt;

&lt;p&gt;Patching fixes the specific bug. Hardening reduces the blast radius of the next one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Block PHP execution in the uploads directory.&lt;/strong&gt; Worth doing, but be clear that it does not mitigate CVE-2026-87902, which includes a &lt;code&gt;.php&lt;/code&gt; file already present on the server rather than one an attacker uploaded. Keep it as general hygiene against the next upload driven bug. For Nginx, add this to your server block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/wp-content/uploads/.*\.php&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Apache, drop a &lt;code&gt;.htaccess&lt;/code&gt; file inside &lt;code&gt;wp-content/uploads/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;Files&lt;/span&gt;&lt;span class="sr"&gt; "*.php"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;Files&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Disable file editing in the WordPress admin.&lt;/strong&gt; Add to &lt;code&gt;wp-config.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISALLOW_FILE_EDIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Break the RCE preconditions if you cannot patch within the hour.&lt;/strong&gt; Set &lt;code&gt;register_argc_argv = Off&lt;/code&gt; in your PHP configuration and confirm it with &lt;code&gt;php -i | grep register_argc_argv&lt;/code&gt;. Then check whether a reachable &lt;code&gt;pearcmd.php&lt;/code&gt; exists at all, with &lt;code&gt;find / -name pearcmd.php 2&amp;gt;/dev/null&lt;/code&gt;, and remove it if nothing on the box needs PEAR. Neither of these closes the file inclusion, but together they remove the documented route from inclusion to code execution. Patching is still the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run WordPress behind a WAF.&lt;/strong&gt; Cloudflare, Wordfence, or a self-hosted option like ModSecurity can catch traversal patterns at the request layer before they reach PHP. Even a free Cloudflare plan adds meaningful filtering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restrict &lt;code&gt;xmlrpc.php&lt;/code&gt; and &lt;code&gt;wp-login.php&lt;/code&gt; by IP if possible.&lt;/strong&gt; Unauthenticated attack surface shrinks dramatically when admin endpoints are IP-locked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Nginx example: restrict wp-login.php&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="s"&gt;[your-admin-IP]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🔧 Troubleshooting Common Update Issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WP-CLI command not found:&lt;/strong&gt; Install it by following the official WP-CLI installation guide at &lt;a href="https://wp-cli.org" rel="noopener noreferrer"&gt;wp-cli.org&lt;/a&gt;. On most Linux servers it is a single &lt;code&gt;curl&lt;/code&gt; download and &lt;code&gt;chmod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update fails due to file permissions:&lt;/strong&gt; WordPress needs write access to its own directory during updates. Check that the web server user (often &lt;code&gt;www-data&lt;/code&gt; on Ubuntu/Debian) owns the WordPress files.&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;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /var/www/html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Site breaks after update:&lt;/strong&gt; Restore from the backup taken in Step 1, then investigate plugin or theme conflicts. Deactivate all plugins and retest. The WordPress support forums and the WP-CLI &lt;code&gt;--debug&lt;/code&gt; flag are your friends here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed host shows no update available:&lt;/strong&gt; Some managed hosts patch at the server level before surfacing a WordPress core update. Contact support to confirm your environment is running the patched version.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;Unauthenticated path traversal leading to conditional RCE is the kind of vulnerability that gets actively exploited fast, especially against WordPress given its enormous install base. The fix is straightforward: update core to the patched release, turn &lt;code&gt;register_argc_argv&lt;/code&gt; off if nothing on the server needs it, and put automatic minor updates on so future patches land without delay.&lt;/p&gt;

&lt;p&gt;The patched version is 7.1.2, or the backport for whichever branch you are on. The &lt;a href="https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-7hp8-65ch-5whp" rel="noopener noreferrer"&gt;official advisory (GHSA-7hp8-65ch-5whp)&lt;/a&gt; has the full backport list and the code path. Patch first, harden second, and since this one is already on CISA's exploited list, check your access logs for requests carrying &lt;code&gt;page-&lt;/code&gt; template names or &lt;code&gt;pearcmd&lt;/code&gt; while you are in there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/crowdsec-community-powered-security/" rel="noopener noreferrer"&gt;CrowdSec: Community-Powered Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/fail2ban-setup-homelab-security-part-2/" rel="noopener noreferrer"&gt;Fail2Ban Setup (Homelab Security Part 2)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/better-homelab-secret-management-with-infisical/" rel="noopener noreferrer"&gt;Better Homelab Secret Management with Infisical&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/wordpress-7-1-2-patches-a-critical-path-traversal-to-rce-update-now/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>php</category>
      <category>linux</category>
    </item>
    <item>
      <title>Miri Was Leaking CI Secrets Through GitHub Actions Caches: What to Clean Up</title>
      <dc:creator>CodeWithRomi</dc:creator>
      <pubDate>Wed, 07 Oct 2026 23:00:44 +0000</pubDate>
      <link>https://dev.to/theeromi/miri-was-leaking-ci-secrets-through-github-actions-caches-what-to-clean-up-6o</link>
      <guid>https://dev.to/theeromi/miri-was-leaking-ci-secrets-through-github-actions-caches-what-to-clean-up-6o</guid>
      <description>&lt;p&gt;CI/CD pipelines are a common attack surface, and most developers focus on the obvious risks: exposed tokens, world-readable repos, overly permissive workflow permissions. But a subtler class of vulnerability comes from caching, and in September 2026 the Rust project published an advisory that is a textbook case. Miri, Rust's interpreter for detecting undefined behavior, was writing every environment variable it saw into the &lt;code&gt;target/&lt;/code&gt; directory. Cache &lt;code&gt;target/&lt;/code&gt; in GitHub Actions, as most Rust projects do, and any secret handed to that job was sitting in the cache where a later pull request run could restore it and read it.&lt;/p&gt;

&lt;p&gt;The good news first, because it changes how you should read the rest: this is fixed. The Rust project published the advisory on 21 September 2026 and the nightly toolchain dated 22 September 2026 stops Miri persisting arbitrary environment variables, keeping only &lt;code&gt;CARGO_*&lt;/code&gt; (excluding &lt;code&gt;CARGO_*_TOKEN&lt;/code&gt;) and &lt;code&gt;OUT_DIR&lt;/code&gt;. No CVE was assigned. What is left for you is cleanup rather than defense: update the toolchain, delete the caches you already have, and rotate anything that was in scope. This post covers the mechanism, that cleanup, and how to stop caching biting you again.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Miri runs Rust programs in an interpreted environment so it can detect undefined behavior at runtime. To notice when something build relevant changed between runs, &lt;code&gt;cargo miri&lt;/code&gt; recorded the environment it was invoked with, and it recorded all of it rather than only the variables it needed. That record lived inside the &lt;code&gt;target/&lt;/code&gt; directory. Note what is not required here: nothing in your code, your test output or your logs had to mention the secret. Passing it to the step was enough for it to land on disk in the directory everyone caches.&lt;/p&gt;

&lt;p&gt;That matters because of how GitHub Actions cache scoping works. A workflow run can restore caches created on its own branch and on the default branch, so a pull request run can pull back a cache that a privileged push to &lt;code&gt;main&lt;/code&gt; created. The advisory spells out the conditions: you are affected if you run &lt;code&gt;cargo miri&lt;/code&gt; in CI, pass secrets as environment variables to that step, cache the &lt;code&gt;target/&lt;/code&gt; directory with something like &lt;code&gt;actions/cache&lt;/code&gt; or &lt;code&gt;Swatinem/rust-cache&lt;/code&gt;, and allow pull requests to read from that cache.&lt;/p&gt;

&lt;p&gt;The broader lesson goes beyond Miri. Any tool that snapshots its invocation environment into a build directory is a candidate for this class of bug, and build directories are exactly what CI caching is pointed at. Rust's toolchain is just where this instance was caught, reported and documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧰 Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A GitHub Actions workflow that uses Miri (typically via &lt;code&gt;cargo miri test&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Familiarity with the &lt;code&gt;actions/cache&lt;/code&gt; action or the built-in Cargo caching patterns&lt;/li&gt;
&lt;li&gt;Repository secrets configured in Settings, such as API keys or tokens passed to your test environment&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔍 How the Leakage Path Works
&lt;/h2&gt;

&lt;p&gt;Here is the setup that creates the risk. A workflow injects a secret as an environment variable, runs Miri, and caches the Cargo &lt;code&gt;target/&lt;/code&gt; directory to speed up future runs. The &lt;code&gt;target/&lt;/code&gt; directory is the part that matters. Caching the registry or the Miri sysroot was never the problem.&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="c1"&gt;# Vulnerable pattern (simplified)&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;Run Miri&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;MY_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_SECRET }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cargo miri test&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;Cache build output&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;target&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;miri-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value of &lt;code&gt;MY_SECRET&lt;/code&gt; does not have to appear in any log or test result. &lt;code&gt;cargo miri&lt;/code&gt; persisted it into &lt;code&gt;target/&lt;/code&gt; as part of the environment snapshot, and the cache step then packaged &lt;code&gt;target/&lt;/code&gt; up. The next job or pull request run that restores this cache has a file containing your secret, without that secret ever being granted to the run.&lt;/p&gt;

&lt;p&gt;No exotic trigger is required. You do not need a panic, a crash, or verbose diagnostics. Passing the secret to the step and caching &lt;code&gt;target/&lt;/code&gt; is the whole bug. The advisory also notes that someone who lifted a secret this way could push further commits to cover their tracks, so a clean-looking workflow log is not evidence you were fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Step-by-Step: Hardening Your Pipeline
&lt;/h2&gt;

&lt;p&gt;Step 1: Never inject production secrets into Miri test runs. If your tests genuinely need a credential, use a scoped, revocable test credential with no real permissions. Treat anything Miri can see as potentially loggable.&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="c1"&gt;# Prefer a dummy or scoped value for Miri runs&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;Run Miri&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;MY_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dummy-value-for-miri"&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cargo miri test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2: Fix your caching scope. The directory that leaked is &lt;code&gt;target/&lt;/code&gt;, so either stop caching it on jobs that see secrets, or stop giving those jobs secrets. If you do cache something for Miri, cache the sysroot, and use a distinct cache key so a Miri cache never collides with a standard build cache.&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="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;Cache Miri sysroot only&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;miri-sysroot-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the exact sysroot path for your toolchain with &lt;code&gt;rustc --print sysroot&lt;/code&gt; rather than hardcoding it. The path above is illustrative; confirm it in your environment.&lt;/p&gt;

&lt;p&gt;Step 3: Restrict workflow permissions explicitly. Add a top-level &lt;code&gt;permissions&lt;/code&gt; block to limit what a workflow can do even if a cache is poisoned.&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4: Isolate Miri jobs from secret-bearing jobs. Use separate jobs so the job running Miri never sees a real secret. GitHub Actions does not share environment variables across jobs unless you pass them explicitly, so inject secrets at the step level in the job that actually needs them. One correction worth making if you copy patterns off the internet: &lt;code&gt;secrets: inherit&lt;/code&gt; is only valid on a job that calls a reusable workflow with &lt;code&gt;uses&lt;/code&gt;, not on a job that runs its own &lt;code&gt;steps&lt;/code&gt;.&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;miri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# No secrets injected here&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cargo miri test&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;miri&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DEPLOY_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_TOKEN }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 5: Audit your cache keys and paths regularly. Run a search across your workflow files for any &lt;code&gt;actions/cache&lt;/code&gt; or &lt;code&gt;cache-dependency-path&lt;/code&gt; usage that overlaps with paths your secret-injected steps write to.&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="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"actions/cache"&lt;/span&gt; .github/workflows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🔧 Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My Miri tests need real environment values to run correctly.&lt;/strong&gt; Refactor the tests to accept injectable fakes, or use a secrets manager that vends short-lived credentials scoped only to the test run. This is a design problem as much as a security one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am not sure what is in my cache.&lt;/strong&gt; Download a cache artifact locally using the GitHub CLI (&lt;code&gt;gh cache list&lt;/code&gt; and &lt;code&gt;gh cache download&lt;/code&gt;) and inspect the contents before assuming it is clean. The official GitHub CLI docs cover the exact flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We use a third-party caching action, not actions/cache.&lt;/strong&gt; The same principles apply. Check whether that action stores artifacts in a location your Miri run can write to, and review the action's own permissions model.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ Wrap Up
&lt;/h2&gt;

&lt;p&gt;Caching is one of the best levers you have for speeding up CI, but it introduces a persistent artifact that outlives a single workflow run. In this case the artifact was the ordinary Cargo &lt;code&gt;target/&lt;/code&gt; directory, and the tool filling it with secrets was doing so as a convenience feature. Be deliberate about what goes into the cache and which jobs hold real secrets.&lt;/p&gt;

&lt;p&gt;Concretely, for this advisory: move to a toolchain from 22 September 2026 or later, delete the existing caches on any repository that ran &lt;code&gt;cargo miri&lt;/code&gt; with secrets in scope, and rotate those secrets. Then, as standing practice, keep Miri jobs secret free, lock down workflow permissions, and audit your cache paths as part of any security review. Treat cached artifacts with the same suspicion you would treat a shared filesystem.&lt;/p&gt;

&lt;p&gt;The primary source is the Rust project advisory &lt;a href="https://blog.rust-lang.org/2026/09/21/github-actions-leaking-secrets-when-miri-output-is-cached/" rel="noopener noreferrer"&gt;GitHub Actions leaking secrets when Miri output is cached&lt;/a&gt;, published 21 September 2026. Read it in full for the exact affected conditions and for any follow-up, and pair it with the GitHub Actions security hardening guide in the GitHub docs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related from the lab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/better-homelab-secret-management-with-infisical/" rel="noopener noreferrer"&gt;Better Homelab Secret Management with Infisical&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/an-ai-agent-racked-up-a-massive-bill-scanning-dn42-heres-how-to-stop-that-from-happening-to-you/" rel="noopener noreferrer"&gt;An AI Agent Ran Up a $6,531 AWS Bill Scanning DN42. Here's How to Cap Yours&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codewithromi.com/the-design-of-everyday-cryptography-a-homelab-primer/" rel="noopener noreferrer"&gt;The Design of Everyday Cryptography: A Homelab Primer&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://codewithromi.com/miri-was-leaking-ci-secrets-through-github-actions-caches/" rel="noopener noreferrer"&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>rust</category>
      <category>githubactions</category>
      <category>cryptography</category>
    </item>
  </channel>
</rss>
