<?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: DevOps Daily</title>
    <description>The latest articles on DEV Community by DevOps Daily (@devopsdaily).</description>
    <link>https://dev.to/devopsdaily</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%2F382434%2F66e04ef9-7e4f-491c-997a-30f4a999d40c.jpg</url>
      <title>DEV Community: DevOps Daily</title>
      <link>https://dev.to/devopsdaily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsdaily"/>
    <language>en</language>
    <item>
      <title>Base64 Explained: 3 Bytes In, 4 Characters Out, Zero Secrecy</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/base64-explained-3-bytes-in-4-characters-out-zero-secrecy-35eh</link>
      <guid>https://dev.to/devopsdaily/base64-explained-3-bytes-in-4-characters-out-zero-secrecy-35eh</guid>
      <description>&lt;p&gt;Base64 is in more of your config than you think. Kubernetes Secrets store their values in it, a signed JWT in an &lt;code&gt;Authorization&lt;/code&gt; header is three base64url strings joined by dots, kubeconfig files carry certificates in it, and CI systems ask you to paste base64 when a file has to fit in a variable. Most of the time it works. When it does not, the bug is usually small, like an extra newline or a value encoded twice, and it takes an afternoon to find because the broken value looks almost the same as the right one.&lt;/p&gt;

&lt;p&gt;This post shows how base64 works, why its output is a third bigger, and what it does not protect. Then it walks through the bugs that keep coming back. Every command and output below comes from a real run.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Base64 maps every &lt;strong&gt;3 bytes to 4 characters&lt;/strong&gt; from a 64-character alphabet. Anyone can reverse it with one command. It is an &lt;strong&gt;encoding, not encryption&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A Kubernetes Secret's &lt;code&gt;data&lt;/code&gt; field is plain base64. &lt;code&gt;kubectl get secret -o yaml&lt;/code&gt; plus &lt;code&gt;base64 -d&lt;/code&gt; shows the value to anyone with read access.&lt;/li&gt;
&lt;li&gt;The output is &lt;strong&gt;33.3% bigger&lt;/strong&gt; than the input. GNU &lt;code&gt;base64&lt;/code&gt; also adds a newline every 76 characters, which makes it 35.1%. Gzip wins most of it back on the wire.&lt;/li&gt;
&lt;li&gt;The common bugs: a &lt;strong&gt;trailing newline&lt;/strong&gt; from &lt;code&gt;echo&lt;/code&gt;, &lt;strong&gt;wrapped output&lt;/strong&gt; pasted into YAML or an env var, &lt;strong&gt;double encoding&lt;/strong&gt; with &lt;code&gt;--from-literal&lt;/code&gt; or &lt;code&gt;stringData&lt;/code&gt;, and the &lt;strong&gt;URL-safe alphabet without padding&lt;/strong&gt; in JWTs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Linux shell with GNU coreutils for &lt;code&gt;base64&lt;/code&gt; and &lt;code&gt;basenc&lt;/code&gt;. We used coreutils 9.1 on Debian 12.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openssl&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; for the JWT and comparison examples.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; for the Secret examples. Every &lt;code&gt;kubectl&lt;/code&gt; command here uses &lt;code&gt;--dry-run=client&lt;/code&gt;, so you do not need a cluster. We used kubectl v1.37.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How base64 turns 3 bytes into 4 characters
&lt;/h2&gt;

&lt;p&gt;Base64 uses 64 symbols, and 64 is 2 to the power of 6, so each output character carries exactly 6 bits. Three bytes are 24 bits, and 24 bits split cleanly into four 6-bit groups. That is the whole algorithm: take 3 bytes, cut them into four 6-bit numbers, and look each number up in the alphabet.&lt;/p&gt;

&lt;p&gt;This script prints each step for the string &lt;code&gt;k8s&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k8s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;bits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6 bits &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;values &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;


&lt;span class="nb"&gt;bytes&lt;/span&gt; &lt;span class="mi"&gt;01101011&lt;/span&gt; &lt;span class="mi"&gt;00111000&lt;/span&gt; &lt;span class="mi"&gt;01110011&lt;/span&gt;
&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="n"&gt;bits&lt;/span&gt; &lt;span class="mi"&gt;011010&lt;/span&gt; &lt;span class="mi"&gt;110011&lt;/span&gt; &lt;span class="mi"&gt;100001&lt;/span&gt; &lt;span class="mi"&gt;110011&lt;/span&gt;
&lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;
&lt;span class="n"&gt;base64&lt;/span&gt; &lt;span class="n"&gt;azhz&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard alphabet from &lt;a href="https://www.rfc-editor.org/rfc/rfc4648" rel="noopener noreferrer"&gt;RFC 4648&lt;/a&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 0-25 A B C ... Z
26-51 a b c ... z
52-61 0 1 2 ... 9
62 +
63 /

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So 26 is &lt;code&gt;a&lt;/code&gt;, 51 is &lt;code&gt;z&lt;/code&gt; and 33 is &lt;code&gt;h&lt;/code&gt;, and &lt;code&gt;k8s&lt;/code&gt; becomes &lt;code&gt;azhz&lt;/code&gt;. Decoding runs the same steps backwards. There is no key and nothing to guess, which is why base64 is not a way to hide anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Padding: what the = signs mean
&lt;/h2&gt;

&lt;p&gt;When the input length is not a multiple of 3, the last group is short. Base64 fills the missing bits with zeros and adds &lt;code&gt;=&lt;/code&gt; so the output length stays a multiple of 4:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'k'&lt;/span&gt; | &lt;span class="nb"&gt;base64
printf&lt;/span&gt; &lt;span class="s1"&gt;'k8'&lt;/span&gt; | &lt;span class="nb"&gt;base64
printf&lt;/span&gt; &lt;span class="s1"&gt;'k8s'&lt;/span&gt; | &lt;span class="nb"&gt;base64


&lt;/span&gt;&lt;span class="nv"&gt;aw&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;
&lt;span class="nv"&gt;azg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
azhz

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One leftover byte gives 2 characters plus &lt;code&gt;==&lt;/code&gt;. Two leftover bytes give 3 characters plus &lt;code&gt;=&lt;/code&gt;. Padding carries no data, which is why some formats, JWT among them, drop it. That saves a few bytes and causes a bug we will get to below.&lt;/p&gt;

&lt;p&gt;Because the mapping is fixed, common inputs have recognizable prefixes. JSON that starts with &lt;code&gt;{"&lt;/code&gt; followed by a letter encodes to a string starting with &lt;code&gt;eyJ&lt;/code&gt;, so &lt;code&gt;eyJ...&lt;/code&gt; is almost always JSON, often a JWT. Base64-encoded PEM text (&lt;code&gt;-----BEGIN ...&lt;/code&gt;) starts with &lt;code&gt;LS0tLS1CRUdJTi&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 33% overhead, measured
&lt;/h2&gt;

&lt;p&gt;Every 3 input bytes become 4 output characters, so the encoded size is &lt;code&gt;4 × ceil(n / 3)&lt;/code&gt;. For large inputs that is 4/3 of the original, an overhead of 33.3%. GNU &lt;code&gt;base64&lt;/code&gt; also wraps its output at 76 characters by default, which adds one newline for every 76 characters.&lt;/p&gt;

&lt;p&gt;We encoded 1 MiB of random data to check the math:&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;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 1048576 /dev/urandom &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; blob.bin
&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; blob.bin
&lt;span class="nb"&gt;base64 &lt;/span&gt;blob.bin | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="c"&gt;# default: wrapped at 76 characters&lt;/span&gt;
&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; blob.bin | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="c"&gt;# -w0: no wrapping&lt;/span&gt;
&lt;span class="nb"&gt;base64 &lt;/span&gt;blob.bin | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;


1048576
1416501
1398104
18397

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The formula gives the same numbers: &lt;code&gt;4 × ceil(1048576 / 3)&lt;/code&gt; is 1,398,104 characters, and &lt;code&gt;ceil(1398104 / 76)&lt;/code&gt; is 18,397 lines, each ending in a newline, for 1,416,501 bytes in total. That is 1.3333 times the input without wrapping and 1.3509 times with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1 MiB of random data, before and after base64&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;1048576 bytes&lt;/td&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base64 -w0&lt;/td&gt;
&lt;td&gt;1398104 bytes&lt;/td&gt;
&lt;td&gt;base64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base64 (wrapped)&lt;/td&gt;
&lt;td&gt;1416501 bytes&lt;/td&gt;
&lt;td&gt;base64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base64 -w0&lt;/td&gt;
&lt;td&gt;gzip -9&lt;/td&gt;
&lt;td&gt;1058004 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Measured with wc -c on Debian 12, GNU coreutils 9.1 and gzip -9. The base64 overhead is 33.3% (35.1% with line wrapping); after gzip it is 0.9% on the wire.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On the wire, compression gets most of it back. Base64 only uses 6 of every 8 bits, and gzip notices:&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;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; blob.bin | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; blob.bin | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;


1048763
1058004

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this sample, gzip cut the overhead from 33.3% to 0.9%. The overhead still counts wherever the encoded size is what matters: memory, environment variables, cookies and request headers. A JWT is sent in a header on every call, and header size limits apply to its encoded form: each claim costs 4/3 of its size, and nginx's default &lt;code&gt;large_client_header_buffers&lt;/code&gt; allows 8 KB per header line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes Secrets are base64, not encryption
&lt;/h2&gt;

&lt;p&gt;A Secret's &lt;code&gt;data&lt;/code&gt; field holds base64 strings. Create one locally to see what the API server would store:&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="s"&gt;kubectl create secret generic db-creds \&lt;/span&gt;
  &lt;span class="s"&gt;--from-literal=username=app \&lt;/span&gt;
  &lt;span class="s"&gt;--from-literal=password='hunter2-prod' \&lt;/span&gt;
  &lt;span class="s"&gt;--dry-run=client -o yaml&lt;/span&gt;


&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aHVudGVyMi1wcm9k&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YXBw&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;db-creds&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting the value back takes one pipe. On a cluster you would use &lt;code&gt;kubectl get secret db-creds -o jsonpath='{.data.password}'&lt;/code&gt;; here we read the same field from the dry run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic db-creds &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo


&lt;/span&gt;hunter2-prod

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64 is there so binary data such as keystores and TLS keys fits inside JSON and YAML. It does nothing for secrecy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anyone who can &lt;code&gt;get&lt;/code&gt; or &lt;code&gt;list&lt;/code&gt; Secrets in a namespace can read every value in it, and so can anyone who can create a pod there that mounts them. A Secret manifest committed to Git is the password in plain sight. What protects Secrets is RBAC, &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/" rel="noopener noreferrer"&gt;encryption at rest&lt;/a&gt; for etcd, and keeping the values out of Git with a tool such as Sealed Secrets, SOPS or an external secret store.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a longer walkthrough, see &lt;a href="https://devops-daily.com/posts/how-to-decode-a-kubernetes-secret" rel="noopener noreferrer"&gt;How to Decode a Kubernetes Secret&lt;/a&gt; and the &lt;a href="https://devops-daily.com/guides/introduction-to-kubernetes/06-configmaps-and-secrets" rel="noopener noreferrer"&gt;ConfigMaps and Secrets part&lt;/a&gt; of our Kubernetes guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: the newline from echo
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;echo&lt;/code&gt; adds a newline, and base64 encodes it like any other byte:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; | &lt;span class="nb"&gt;base64
printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; | &lt;span class="nb"&gt;base64


&lt;/span&gt;&lt;span class="nv"&gt;aHVudGVyMi1wcm9kCg&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;
aHVudGVyMi1wcm9k

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first value ends in &lt;code&gt;Cg==&lt;/code&gt;, which is the encoded &lt;code&gt;\n&lt;/code&gt;. Decode it and look at the bytes:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'aHVudGVyMi1wcm9kCg=='&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;


0000000 h u n t e r 2 - p r o d &lt;span class="se"&gt;\n&lt;/span&gt;
0000015

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that value in a Secret and the password is now &lt;code&gt;hunter2-prod&lt;/code&gt; plus a newline. The app sends 13 bytes, the database expects 12, and you get an authentication error for a password that looks right in every log line.&lt;/p&gt;

&lt;p&gt;The same thing happens with files. A file written with &lt;code&gt;echo&lt;/code&gt; ends in a newline, and &lt;code&gt;--from-file&lt;/code&gt; keeps it:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; password.txt
kubectl create secret generic db-creds &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;password.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo

printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; password.txt
kubectl create secret generic db-creds &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;password.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo


&lt;/span&gt;&lt;span class="nv"&gt;aHVudGVyMi1wcm9kCg&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;
aHVudGVyMi1wcm9k

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;printf '%s'&lt;/code&gt; whenever the exact bytes matter, and pipe a suspect value through &lt;code&gt;base64 -d | od -c&lt;/code&gt; to see the last byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: wrapped output in YAML and env vars
&lt;/h2&gt;

&lt;p&gt;GNU &lt;code&gt;base64&lt;/code&gt; breaks its output into 76-character lines. A database URL is long enough to hit that:&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="nv"&gt;DB_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'postgresql://app:hunter2-prod@db.internal:5432/orders?sslmode=require'&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64


&lt;/span&gt;69
cG9zdGdyZXNxbDovL2FwcDpodW50ZXIyLXByb2RAZGIuaW50ZXJuYWw6NTQzMi9vcmRlcnM/c3Ns
bW9kZT1yZXF1aXJl

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now put it into a manifest the way many scripts do, with a &lt;a href="https://devops-daily.com/posts/how-cat-eof-works-in-bash" rel="noopener noreferrer"&gt;heredoc&lt;/a&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; &amp;gt; secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-url
data:
  url: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;kubectl create &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-f&lt;/span&gt; secret.yaml &lt;span class="nt"&gt;-o&lt;/span&gt; yaml


error: error parsing secret.yaml: error converting YAML to JSON: yaml: line 8: could not find expected &lt;span class="s1"&gt;':'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line of the value landed at column 0, and YAML read it as a new key. In an env var or a CI variable the newline does not fail this loudly: it ends up inside the value. Turn wrapping off with &lt;code&gt;-w0&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; &amp;gt; secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-url
data:
  url: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^ url: //p'&lt;/span&gt; secret.yaml | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;


postgresql://app:hunter2-prod@db.internal:5432/orders?sslmode&lt;span class="o"&gt;=&lt;/span&gt;require

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even simpler: let &lt;code&gt;kubectl create secret generic db-url --from-literal=url="$DB_URL" --dry-run=client -o yaml&lt;/code&gt; write the manifest, and the encoding is never your problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  macOS and openssl
&lt;/h3&gt;

&lt;p&gt;macOS ships a different &lt;code&gt;base64&lt;/code&gt;. According to its &lt;a href="https://keith.github.io/xcode-man-pages/bintrans.1" rel="noopener noreferrer"&gt;man page&lt;/a&gt;, it does not wrap by default (&lt;code&gt;-b&lt;/code&gt; sets a line length, and &lt;code&gt;-w&lt;/code&gt; is accepted for GNU compatibility). Older macOS releases decode only with &lt;code&gt;-D&lt;/code&gt;, and on them &lt;code&gt;-d&lt;/code&gt; is a debug flag. A script that has to run on both should encode with a pipe that works everywhere:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;


cG9zdGdyZXNxbDovL2FwcDpodW50ZXIyLXByb2RAZGIuaW50ZXJuYWw6NTQzMi9vcmRlcnM/c3NsbW9kZT1yZXF1aXJl

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openssl base64&lt;/code&gt; is another option, but it wraps at 64 characters and has a sharper edge when decoding:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64
printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"rc=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[2]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo


&lt;/span&gt;cG9zdGdyZXNxbDovL2FwcDpodW50ZXIyLXByb2RAZGIuaW50ZXJuYWw6NTQzMi9v
cmRlcnM/c3NsbW9kZT1yZXF1aXJl
0
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
postgresql://app:hunter2-prod@db.internal:5432/orders?sslmode&lt;span class="o"&gt;=&lt;/span&gt;require

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;-A&lt;/code&gt;, OpenSSL 3.0 decoded our 92-character single line to &lt;strong&gt;zero bytes and exit code 0&lt;/strong&gt;. No error, just an empty value. If you use &lt;code&gt;openssl base64&lt;/code&gt; for single-line data, pass &lt;code&gt;-A&lt;/code&gt; both ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: double encoding
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--from-literal&lt;/code&gt;, &lt;code&gt;--from-file&lt;/code&gt; and &lt;code&gt;stringData&lt;/code&gt; all take plain text and encode it for you. Give them a value that is already encoded and it gets encoded again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic db-creds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo

&lt;/span&gt;kubectl create secret generic db-creds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'hunter2-prod'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo


&lt;/span&gt;&lt;span class="nv"&gt;YUhWdWRHVnlNaTF3Y205aw&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;
aHVudGVyMi1wcm9k

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod now gets &lt;code&gt;aHVudGVyMi1wcm9k&lt;/code&gt; as its password. The giveaway is a first decode that looks like base64 instead of the value you stored. Decode it again to confirm:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'YUhWdWRHVnlNaTF3Y205aw=='&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo


&lt;/span&gt;hunter2-prod

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: &lt;code&gt;data&lt;/code&gt; takes base64, and &lt;code&gt;stringData&lt;/code&gt;, &lt;code&gt;--from-literal&lt;/code&gt; and &lt;code&gt;--from-file&lt;/code&gt; take the raw value. In a Helm chart, &lt;code&gt;b64enc&lt;/code&gt; belongs in &lt;code&gt;data:&lt;/code&gt;, never in &lt;code&gt;stringData:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not every double encoding is a bug. PEM is itself base64 with header lines, so a TLS Secret's &lt;code&gt;tls.crt&lt;/code&gt; field is encoded twice on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-newkey&lt;/span&gt; ec &lt;span class="nt"&gt;-pkeyopt&lt;/span&gt; ec_paramgen_curve:P-256 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-keyout&lt;/span&gt; tls.key &lt;span class="nt"&gt;-out&lt;/span&gt; tls.crt &lt;span class="nt"&gt;-days&lt;/span&gt; 30 &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s1"&gt;'/CN=orders.internal'&lt;/span&gt; 2&amp;gt;/dev/null
kubectl create secret tls orders-tls &lt;span class="nt"&gt;--cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tls.crt &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tls.key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.tls\.crt}'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-40&lt;/span&gt;
kubectl create secret tls orders-tls &lt;span class="nt"&gt;--cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tls.crt &lt;span class="nt"&gt;--key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tls.key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.tls\.crt}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;


LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1J
&lt;span class="nt"&gt;-----BEGIN&lt;/span&gt; CERTIFICATE-----
MIIBijCCAS+gAwIBAgIUazfeROKqRj7HLheO41+kH88CZagwCgYIKoZIzj0EAwIw

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;LS0tLS1CRUdJTi&lt;/code&gt; prefix is the &lt;code&gt;-----BEGIN&lt;/code&gt; from earlier. The &lt;code&gt;certificate-authority-data&lt;/code&gt; field in a kubeconfig works the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  base64url and JWTs
&lt;/h2&gt;

&lt;p&gt;Two characters of the standard alphabet cause trouble outside of files. &lt;code&gt;+&lt;/code&gt; means a space in a form-encoded query string, and &lt;code&gt;/&lt;/code&gt; is a path separator. RFC 4648 defines a second alphabet, &lt;strong&gt;base64url&lt;/strong&gt; , that uses &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; instead:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'\xfb\xff\xbf'&lt;/span&gt; | &lt;span class="nb"&gt;base64
printf&lt;/span&gt; &lt;span class="s1"&gt;'\xfb\xff\xbf'&lt;/span&gt; | basenc &lt;span class="nt"&gt;--base64url&lt;/span&gt;


+/+/
&lt;span class="nt"&gt;-_-_&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not only about odd bytes. The database URL from earlier encodes to &lt;code&gt;...cnM/c3Ns...&lt;/code&gt; in standard base64 and &lt;code&gt;...cnM_c3Ns...&lt;/code&gt; in base64url. In a URL path, that &lt;code&gt;/&lt;/code&gt; splits the value in two.&lt;/p&gt;

&lt;p&gt;JWTs use base64url and also drop the &lt;code&gt;=&lt;/code&gt; padding (&lt;a href="https://www.rfc-editor.org/rfc/rfc7515#section-2" rel="noopener noreferrer"&gt;RFC 7515&lt;/a&gt;). This script, saved as &lt;code&gt;make-jwt.sh&lt;/code&gt;, builds a real HS256 token with shell tools:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

b64url&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; basenc &lt;span class="nt"&gt;--base64url&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;header&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'{"alg":"HS256","typ":"JWT"}'&lt;/span&gt; | b64url&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s1"&gt;'{"sub":"deploy-bot","role":"admin","exp":1793318400}'&lt;/span&gt; | b64url&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl dgst &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="nt"&gt;-hmac&lt;/span&gt; &lt;span class="s1"&gt;'not-a-real-key'&lt;/span&gt; &lt;span class="nt"&gt;-binary&lt;/span&gt; | b64url&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXBsb3ktYm90Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzkzMzE4NDAwfQ.mirpAmeTzGcS59Fo6JImIz4IBPSFSqpDJy1qltzzzuA

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both JSON parts start with &lt;code&gt;eyJ&lt;/code&gt;. Now try to read the payload the obvious 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="nb"&gt;chmod&lt;/span&gt; +x make-jwt.sh
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./make-jwt.sh&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;" rc=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;


&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt;:&lt;span class="s2"&gt;"deploy-bot"&lt;/span&gt;,&lt;span class="s2"&gt;"role"&lt;/span&gt;:&lt;span class="s2"&gt;"admin"&lt;/span&gt;,&lt;span class="s2"&gt;"exp"&lt;/span&gt;:1793318400&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="nb"&gt;base64&lt;/span&gt;: invalid input
 &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload part is 70 characters, not a multiple of 4, so GNU &lt;code&gt;base64&lt;/code&gt; prints what it could decode and then exits with 1. The JSON looks complete, which makes this easy to miss: under &lt;code&gt;set -e&lt;/code&gt; the script stops here, and without it the script carries on with output that may be cut short. &lt;code&gt;basenc --base64url -d&lt;/code&gt; fails the same way, because it also expects padding. Translate the alphabet and put the padding back first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;b64url_decode&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;s
  &lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'_-'&lt;/span&gt; &lt;span class="s1"&gt;'/+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="c"&gt;# back to the standard alphabet&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="c"&gt;# put the stripped padding back&lt;/span&gt;
    2&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;=="&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    3&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;="&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt; | b64url_decode&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo
echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt; | b64url_decode&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;


&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt;:&lt;span class="s2"&gt;"deploy-bot"&lt;/span&gt;,&lt;span class="s2"&gt;"role"&lt;/span&gt;:&lt;span class="s2"&gt;"admin"&lt;/span&gt;,&lt;span class="s2"&gt;"exp"&lt;/span&gt;:1793318400&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"alg"&lt;/span&gt;:&lt;span class="s2"&gt;"HS256"&lt;/span&gt;,&lt;span class="s2"&gt;"typ"&lt;/span&gt;:&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Python, &lt;code&gt;base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))&lt;/code&gt; does the same in one line.&lt;/p&gt;

&lt;p&gt;Anyone holding a signed JWT like this one can read its claims, because they are only base64url. The signature stops people from changing them, not from reading them, so never put a secret in a JWT payload. (Encrypted JWTs, JWE, are a different format with five parts.) For a quick look at a token, our &lt;a href="https://devops-daily.com/tools/jwt-decoder" rel="noopener noreferrer"&gt;JWT decoder&lt;/a&gt; runs in the browser, and the &lt;a href="https://devops-daily.com/tools/base64" rel="noopener noreferrer"&gt;base64 tool&lt;/a&gt; does the same for plain values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data URIs
&lt;/h2&gt;

&lt;p&gt;A data URI puts a whole file inside a URL: &lt;code&gt;data:&amp;lt;media type&amp;gt;;base64,&amp;lt;data&amp;gt;&lt;/code&gt;. Browsers then skip the extra request. We built one from this site's 180x180 Apple touch icon, saved as &lt;code&gt;icon.png&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;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; icon.png
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'data:image/png;base64,%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; icon.png&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'data:image/png;base64,%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-w0&lt;/span&gt; icon.png&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-60&lt;/span&gt;


1167
1578
data:image/png&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nb"&gt;base64&lt;/span&gt;,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAA

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1,167 bytes became 1,578 characters: the 33% plus the 22-character prefix. Use &lt;code&gt;-w0&lt;/code&gt; again so the URI stays on one line in HTML or CSS. The image also loses its own cache entry and is downloaded again with every page or stylesheet that inlines it. That is fine for a few small icons and bad for anything large or shared across pages.&lt;/p&gt;

&lt;p&gt;For SVG, which is already text, base64 is not always the best choice. Our 110-byte SVG made a 174-character base64 data URI and a 143-character URL-encoded one (&lt;code&gt;data:image/svg+xml,%3Csvg ...&lt;/code&gt;, with single quotes inside the SVG).&lt;/p&gt;

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

&lt;p&gt;Base64 is a fixed mapping from bytes to 64 safe characters. It makes binary data fit in text formats, costs a third more space, and hides nothing. The bugs come from a short list of mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base64 does not protect Secrets.&lt;/strong&gt; RBAC, encryption at rest and keeping values out of Git do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;echo&lt;/code&gt; adds a newline.&lt;/strong&gt; Use &lt;code&gt;printf '%s'&lt;/code&gt; and check suspect values with &lt;code&gt;base64 -d | od -c&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GNU &lt;code&gt;base64&lt;/code&gt; wraps at 76 characters.&lt;/strong&gt; Use &lt;code&gt;-w0&lt;/code&gt;, or &lt;code&gt;| tr -d '\n'&lt;/code&gt; where macOS is involved. With &lt;code&gt;openssl base64&lt;/code&gt;, pass &lt;code&gt;-A&lt;/code&gt; both ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;data&lt;/code&gt; takes base64; &lt;code&gt;stringData&lt;/code&gt;, &lt;code&gt;--from-literal&lt;/code&gt; and &lt;code&gt;--from-file&lt;/code&gt; take raw values.&lt;/strong&gt; If the first decode still looks like base64, check for double encoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWTs use base64url without padding.&lt;/strong&gt; Swap &lt;code&gt;-_&lt;/code&gt; back to &lt;code&gt;+/&lt;/code&gt; and restore the &lt;code&gt;=&lt;/code&gt; before decoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the exit code of &lt;code&gt;base64 -d&lt;/code&gt;.&lt;/strong&gt; GNU prints partial output before it fails, and the Rust coreutils in Ubuntu 26.10 &lt;a href="https://dev.to/devopsdaily/ubuntu-2610-moves-cp-mv-and-rm-to-rust-we-ran-162-shell-commands-through-gnu-and-uutils-to-see-42o2"&gt;behave differently on bad input&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/base64-explained" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>base64</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>15 Minutes to 5: What Happens When You Move iOS CI From M1 to M4</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/15-minutes-to-5-what-happens-when-you-move-ios-ci-from-m1-to-m4-48pb</link>
      <guid>https://dev.to/devopsdaily/15-minutes-to-5-what-happens-when-you-move-ios-ci-from-m1-to-m4-48pb</guid>
      <description>&lt;p&gt;The Wikipedia iOS app's build and its 503 unit tests took a median &lt;strong&gt;922 seconds (15.4 minutes)&lt;/strong&gt; on GitHub's standard &lt;code&gt;macos-26&lt;/code&gt; runner, an Apple M1 with 3 vCPU. The same job, with the same Xcode, simulator and &lt;code&gt;xcodebuild&lt;/code&gt; commands, took a median &lt;strong&gt;337 seconds (5.6 minutes)&lt;/strong&gt; on a Bitrise Build Hub M4 Large with 5 vCPU. That is 2.7 times faster, over 17 runs on each side, every run cold.&lt;/p&gt;

&lt;p&gt;In our &lt;a href="https://dev.to/devopsdaily/why-your-github-actions-bill-keeps-growing-and-what-each-fix-saves-1fkb"&gt;first article&lt;/a&gt; this was one row in a pipeline benchmark. Here we look only at the iOS job: which phase gets faster, what a run costs at list price, whether a bigger Mac helps, and what you change in a workflow to move it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/gha-runner-benchmark" rel="noopener noreferrer"&gt;The-DevOps-Daily/gha-runner-benchmark on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The build gained the most minutes:&lt;/strong&gt; 538 s on the M1 against 145 s on the M4 Large (3.7x). The unit tests went from 370 s to 86 s (4.3x).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per run at list price:&lt;/strong&gt; a median $0.99 on GitHub (16 billed minutes at $0.062) and $0.21 on the M4 Large (7 billed minutes at $0.03).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bigger M4 adds little:&lt;/strong&gt; the 10 vCPU M4 XLarge was 9% faster than the M4 Large and costs twice as much per minute. The 14 vCPU M4 Pro XLarge took 23% less time, and it is on the Enterprise plan only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machines start on demand:&lt;/strong&gt; iOS jobs waited a median 38 seconds for a Build Hub machine, against 8 seconds on GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workflow change is the &lt;code&gt;runs-on&lt;/code&gt; line:&lt;/strong&gt; it points at a Build Hub pool. The setup around it is a GitHub App, a pool and an image with your Xcode. GitHub Actions still schedules the job and keeps the logs and secrets.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;An iOS project that already builds in GitHub Actions with &lt;code&gt;xcodebuild&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A way to read job times: the Actions tab, or the &lt;code&gt;gh&lt;/code&gt; CLI for many runs&lt;/li&gt;
&lt;li&gt;For Build Hub: a Bitrise workspace and the Build Hub GitHub App installed on the repository&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;List price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub-hosted&lt;/td&gt;
&lt;td&gt;&lt;code&gt;macos-26&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apple M1 (virtual), 3 vCPU, 7 GB&lt;/td&gt;
&lt;td&gt;$0.062 a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub, self-serve Pro Tier 1&lt;/td&gt;
&lt;td&gt;M4 Large&lt;/td&gt;
&lt;td&gt;Apple M4 (virtual), 5 vCPU, 14 GB&lt;/td&gt;
&lt;td&gt;$0.03 a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are not same-size machines: the M4 Large has more cores and twice the memory. We compared the standard macOS runner on GitHub with the smallest Mac on Build Hub: the default choice on each side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workload.&lt;/strong&gt; The &lt;a href="https://github.com/wikimedia/wikipedia-ios" rel="noopener noreferrer"&gt;Wikipedia iOS app&lt;/a&gt; at commit &lt;code&gt;ca8aaeb9&lt;/code&gt;, its own "Run Unit Tests" job: resolve Swift packages, &lt;code&gt;xcodebuild build-for-testing&lt;/code&gt;, then &lt;code&gt;test-without-building&lt;/code&gt; on an iPhone 17 simulator with iOS 26.5 and Xcode 26.5. In the separate runs below, all 503 tests passed every time, and in the pipeline runs every iOS job succeeded. Every run started cold, with no DerivedData or package cache on either side. Cold runs compare the whole environment, not only the chip: the images, macOS patch versions, storage and network also differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we ran it.&lt;/strong&gt; Bitrise provided the Build Hub workspace for these tests. The 17 runs per side come from the pipeline benchmark on 16 and 17 October 2026, dispatched in pairs. The phase split and the bigger Macs come from separate runs of the same job on 15 and 16 October: 7 on GitHub and 3 on each Build Hub machine. Those are fewer samples, so compare their rows with each other, not with the 17-run numbers. All runs and the raw results are in the repository above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the minutes go
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wikipedia iOS job by phase, median seconds&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolve packages&lt;/td&gt;
&lt;td&gt;64s&lt;/td&gt;
&lt;td&gt;GitHub M1 3 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolve packages&lt;/td&gt;
&lt;td&gt;27s&lt;/td&gt;
&lt;td&gt;Build Hub M4 Large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build for testing&lt;/td&gt;
&lt;td&gt;538s&lt;/td&gt;
&lt;td&gt;GitHub M1 3 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build for testing&lt;/td&gt;
&lt;td&gt;145s&lt;/td&gt;
&lt;td&gt;Build Hub M4 Large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unit tests&lt;/td&gt;
&lt;td&gt;370s&lt;/td&gt;
&lt;td&gt;GitHub M1 3 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unit tests&lt;/td&gt;
&lt;td&gt;86s&lt;/td&gt;
&lt;td&gt;Build Hub M4 Large&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Separate runs of the iOS job on 15 and 16 October 2026: GitHub macos-26 7 runs, Build Hub M4 Large 3 runs. Cold, no cache. Source: results/ios-wikipedia-unit-tests.json.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;GitHub M1, 3 vCPU&lt;/th&gt;
&lt;th&gt;Build Hub M4 Large, 5 vCPU&lt;/th&gt;
&lt;th&gt;Faster by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolve packages&lt;/td&gt;
&lt;td&gt;64 s&lt;/td&gt;
&lt;td&gt;27 s&lt;/td&gt;
&lt;td&gt;2.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build for testing&lt;/td&gt;
&lt;td&gt;538 s&lt;/td&gt;
&lt;td&gt;145 s&lt;/td&gt;
&lt;td&gt;3.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unit tests&lt;/td&gt;
&lt;td&gt;370 s&lt;/td&gt;
&lt;td&gt;86 s&lt;/td&gt;
&lt;td&gt;4.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The build is where most of the minutes are, and it is where most of them were saved: 393 seconds per run. Compiling Swift is CPU work, and the M4 Large has a newer chip and two more cores. The unit tests gained the most in relative terms. They run in the iOS simulator, which needs CPU and memory too, and the M1 runner has 7 GB for Xcode, the simulator and the test host together. We did not measure memory pressure, so that is a possible factor, not a finding.&lt;/p&gt;

&lt;p&gt;Package resolution gained least. It downloads the same packages on both sides, and we did not time the network part separately. The phases do not add up to the whole job: checkout, Xcode selection and the other steps take the rest, a median 87 seconds per run on GitHub and 69 seconds on the M4 Large.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a run costs
&lt;/h2&gt;

&lt;p&gt;At list price, each job's billed time is rounded up to a whole minute and multiplied by the per-minute price. Build Hub bills per started minute and includes the machine's start-up, which the job's timestamps do not show, so we estimated it from the wait before each job, as in the first article. That is why the M4 Large bills 7 minutes for a 337 second job. Over the 17 pipeline runs per side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;Median job time&lt;/th&gt;
&lt;th&gt;Billed minutes&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub &lt;code&gt;macos-26&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;922 s&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;$0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 Large&lt;/td&gt;
&lt;td&gt;337 s&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;$0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On GitHub, public repositories pay nothing&lt;/strong&gt; for standard runners. In a private repository, standard macOS minutes come out of your plan's included minutes first; the $0.99 is the price after those are used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Hub is sold as a monthly tier.&lt;/strong&gt; If you do not use the budget you pay for, the real price per run is higher than $0.21.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A team that runs the iOS job 20 times a day would, at these list prices, spend about $20 a day on GitHub and about $4 on Build Hub, and use about 3 hours less job time. That is total job time, not the time any one person waits: jobs run in parallel, and other jobs may be slower. Check your own run count before you trust that number: rounding makes short jobs cost more than they look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a bigger Mac worth it?
&lt;/h2&gt;

&lt;p&gt;We ran the same job on the larger Build Hub Macs too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Median job time&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub &lt;code&gt;macos-26&lt;/code&gt;: M1, 3 vCPU, 7 GB&lt;/td&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1,026 s (805 to 1,305)&lt;/td&gt;
&lt;td&gt;$1.12 (18 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 Large: M4, 5 vCPU, 14 GB&lt;/td&gt;
&lt;td&gt;Self-serve&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;323 s (314 to 335)&lt;/td&gt;
&lt;td&gt;$0.18 (6 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 XLarge: M4, 10 vCPU, 28 GB&lt;/td&gt;
&lt;td&gt;Self-serve&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;295 s (293 to 301)&lt;/td&gt;
&lt;td&gt;$0.36 (6 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 Pro XLarge: M4 Pro, 14 vCPU&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;250 s (246 to 251)&lt;/td&gt;
&lt;td&gt;No per-minute list price&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twice the cores and twice the memory, from the M4 Large to the M4 XLarge, saved 28 seconds (9%) and doubled the cost per run. For this app, the Large gave most of the gain at half the XLarge's cost per run. The M4 Pro XLarge, with the M4 Pro chip and 14 vCPU, took 73 seconds less than the Large (23%). It is on the Enterprise plan, which Bitrise sells by concurrency and not by minutes, so we cannot give a list price per run for it. Billed minutes for the Build Hub rows include the estimated machine start-up, as above.&lt;/p&gt;

&lt;p&gt;Your app may be different. A project with many independent modules can use more cores than the Wikipedia app did. Run your own job three times on each size before you pick one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving one job
&lt;/h2&gt;

&lt;p&gt;GitHub Actions still runs the workflow: it schedules the job, streams the logs and passes the secrets. The work is in the setup, and the workflow itself changes little.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the Build Hub GitHub App on the repository, and create a pool in your Bitrise workspace with the machine type and an image that has your Xcode version.&lt;/li&gt;
&lt;li&gt;Point &lt;code&gt;runs-on&lt;/code&gt; at the pool's label. Ours was &lt;code&gt;bitrise-runner-ios-m4-large&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Select the Xcode version in the job, so that both sides use the same one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Images name the Xcode app differently (&lt;code&gt;Xcode_26.5.app&lt;/code&gt;, &lt;code&gt;Xcode-26.5.app&lt;/code&gt; or &lt;code&gt;Xcode.app&lt;/code&gt;), so our job picks the app by the version it reports, and fails if it is not there:&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;ios-tests&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;bitrise-runner-ios-m4-large&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# was: macos-26&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;XCODE_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;26.5'&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Select Xcode&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;for app in /Applications/Xcode*.app; do&lt;/span&gt;
            &lt;span class="s"&gt;if ["$(DEVELOPER_DIR="$app/Contents/Developer" xcodebuild -version 2&amp;gt;/dev/null | head -1)" = "Xcode $XCODE_VERSION"]; then&lt;/span&gt;
              &lt;span class="s"&gt;sudo xcode-select -s "$app/Contents/Developer"&lt;/span&gt;
              &lt;span class="s"&gt;break&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;test "$(xcodebuild -version | head -1)" = "Xcode $XCODE_VERSION"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;xcodebuild&lt;/code&gt; steps did not change at all. Two checks are worth adding before the first real run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The simulator exists.&lt;/strong&gt; &lt;code&gt;xcrun simctl list devices available&lt;/code&gt; should list the device and the iOS version in your &lt;code&gt;-destination&lt;/code&gt;. If the image lacks the runtime, the job fails, so check it before the first real run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect a short wait for a machine.&lt;/strong&gt; A self-serve pool starts a machine when a job arrives. Our iOS jobs waited a median 38 seconds before they started, which is not in the job times above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chip or cores.&lt;/strong&gt; The M1 runner has 3 vCPU and the M4 Large has 5. We did not run a 3 vCPU M4, so we cannot say how much of the 2.7x comes from the chip and how much from the cores and the memory. Going from 5 to 10 M4 cores, with twice the memory, saved only 9%, so beyond 5 cores the size did little for this app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm builds.&lt;/strong&gt; Every run was cold. With a DerivedData or package cache, both sides get faster, and the gap may shrink. We did not measure that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other apps.&lt;/strong&gt; This is one app at one commit. A smaller app saves fewer minutes, and a larger one may save more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub's larger Mac runners.&lt;/strong&gt; GitHub also sells larger macOS runners on paid plans. We compared the standard one, which every team gets.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Moving the Wikipedia iOS job from GitHub's M1 runner to a Build Hub M4 Large took it from 15.4 to 5.6 minutes and from $0.99 to $0.21 per run at list price, with the same workflow steps. Most of the time came out of the build and the simulator tests. A bigger M4 added little; the M4 Pro XLarge took 23% less time than the M4 Large. If your iOS job is the slowest one in your pipeline, measure it on one larger machine before you change anything else, and keep the Xcode version pinned so the comparison stays fair.&lt;/p&gt;

&lt;p&gt;The workflows, the dispatch scripts and every result are in the &lt;a href="https://github.com/The-DevOps-Daily/gha-runner-benchmark" rel="noopener noreferrer"&gt;benchmark repository&lt;/a&gt;. The setup for Build Hub is in &lt;a href="https://bitrise.io/platform/build-hub" rel="noopener noreferrer"&gt;Bitrise's documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/ios-ci-m1-to-m4-15-minutes-to-5" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>ios</category>
      <category>xcode</category>
      <category>macos</category>
    </item>
    <item>
      <title>GitLab.com's 60-Requests-an-Hour Limit Is Live, and the Headers Won't Warn You</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/gitlabcoms-60-requests-an-hour-limit-is-live-and-the-headers-wont-warn-you-4lge</link>
      <guid>https://dev.to/devopsdaily/gitlabcoms-60-requests-an-hour-limit-is-live-and-the-headers-wont-warn-you-4lge</guid>
      <description>&lt;p&gt;GitLab.com now limits requests without credentials to 60 per hour per IP address, at least for part of its traffic. On the evening of October 28 we sent 140 anonymous API requests to GitLab's own &lt;code&gt;gitlab-runner&lt;/code&gt; project in under ten minutes, and every one of them came back &lt;code&gt;200&lt;/code&gt;. Then we pointed the same loop at a public project in a personal namespace. After 58 requests, the 59th came back &lt;code&gt;429 Too Many Requests&lt;/code&gt;, and our IP stayed blocked for more than 40 minutes.&lt;/p&gt;

&lt;p&gt;Three things from that evening matter if you run CI or automation against GitLab.com:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What counts depends on what you ask for.&lt;/strong&gt; While we were blocked, requests to projects in the &lt;code&gt;gitlab-org&lt;/code&gt; and &lt;code&gt;inkscape&lt;/code&gt; groups still worked, and requests to personal projects and to &lt;code&gt;/api/v4/version&lt;/code&gt; did not. If you test against a big open source project, you may conclude that nothing has changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Successful responses do not show the new limit.&lt;/strong&gt; The last &lt;code&gt;200&lt;/code&gt; before the block carried &lt;code&gt;RateLimit-Remaining: 476&lt;/code&gt;. That number belongs to the old 500-per-minute throttle. The new limit only appears in the &lt;code&gt;429&lt;/code&gt; itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; follows the clock, the block does not.&lt;/strong&gt; Every &lt;code&gt;Retry-After&lt;/code&gt; pointed to the end of the current clock hour. The block ended about an hour after the first request that counted. Our first explanation for that was wrong, and a test with predictions written down in advance showed it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below comes from one IP in one overnight session, October 28 to 29 UTC, with the scripts and raw results in a public repository, so you can run the same checks from your own network.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitLab announced the Free and unauthenticated limits for October 19, 2026, and the limits for Premium and Ultimate for January 2027. Anonymous traffic gets 60 requests per hour per IP.&lt;/li&gt;
&lt;li&gt;From our IP, the budget was exactly 60 requests. The count started with the first request and reset about an hour later. Requests to the &lt;code&gt;gitlab-org&lt;/code&gt; group did not count, &lt;code&gt;401&lt;/code&gt; responses did, and blocked requests did not extend the block. The &lt;code&gt;429&lt;/code&gt; carried &lt;code&gt;RateLimit-Name: unauthenticated_traffic_per_ip&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nothing in a successful response warns you. Track your own request counts, and alert on &lt;code&gt;429&lt;/code&gt; responses with that &lt;code&gt;RateLimit-Name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Do not plan around &lt;code&gt;Retry-After&lt;/code&gt; from this throttle. It points to the end of the clock hour, and the block ends about an hour after the first request of the current count.&lt;/li&gt;
&lt;li&gt;A separate limit, most likely at Cloudflare, stopped bursts of &lt;code&gt;401&lt;/code&gt; responses after 25 requests, with a &lt;code&gt;429&lt;/code&gt; that looks nothing like GitLab's.&lt;/li&gt;
&lt;li&gt;Behind one NAT address, every runner shares those 60 requests. Find anonymous &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt; and Dockerfile &lt;code&gt;ADD&lt;/code&gt; calls to gitlab.com and give them a token. &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; covers raw files, releases, packages, artifacts and archives.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A GitLab.com account or group, and CI jobs or scripts that talk to gitlab.com&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; and Python 3.9 or later to run the scripts in the companion repository&lt;/li&gt;
&lt;li&gt;Access to your runners' egress IP addresses, if you want to reason about shared limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What GitLab announced
&lt;/h2&gt;

&lt;p&gt;GitLab's &lt;a href="https://about.gitlab.com/blog/rate-limit-change-2026/" rel="noopener noreferrer"&gt;announcement&lt;/a&gt; ties GitLab.com rate limits to the subscription plan. The numbers are on the &lt;a href="https://docs.gitlab.com/user/gitlab_com/rate_limits/" rel="noopener noreferrer"&gt;rate limits page&lt;/a&gt;, which still called them proposed when we read it on October 28:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Sustained limit&lt;/th&gt;
&lt;th&gt;Burst limit (API)&lt;/th&gt;
&lt;th&gt;Starts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No credentials, per IP&lt;/td&gt;
&lt;td&gt;60 per hour&lt;/td&gt;
&lt;td&gt;none listed&lt;/td&gt;
&lt;td&gt;October 19, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free, per signed-in user&lt;/td&gt;
&lt;td&gt;5,000 per hour&lt;/td&gt;
&lt;td&gt;100 per minute&lt;/td&gt;
&lt;td&gt;October 19, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium, per signed-in user&lt;/td&gt;
&lt;td&gt;15,000 per hour&lt;/td&gt;
&lt;td&gt;1,250 per minute&lt;/td&gt;
&lt;td&gt;January 2027&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultimate, per signed-in user&lt;/td&gt;
&lt;td&gt;25,000 per hour&lt;/td&gt;
&lt;td&gt;2,000 per minute&lt;/td&gt;
&lt;td&gt;January 2027&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Free also has a separate 200-per-minute limit for web traffic. Personal access tokens, OAuth tokens and CI/CD job tokens all move a request from the anonymous limit to your plan's limit. Git over HTTPS is not counted against the anonymous limit. GitLab Self-Managed and GitLab Dedicated are not affected. The old per-minute limits stay in place, and GitLab applies the lower of the two.&lt;/p&gt;

&lt;p&gt;The hourly limit matters more than the burst limit. At full burst speed, you reach the hourly cap long before the hour is over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minutes at full burst speed until the hourly limit&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free (5,000/h, 100/min)&lt;/td&gt;
&lt;td&gt;50 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium (15,000/h, 1,250/min)&lt;/td&gt;
&lt;td&gt;12 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultimate (25,000/h, 2,000/min)&lt;/td&gt;
&lt;td&gt;12.5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Sustained limit divided by burst limit, from GitLab's rate limits page, retrieved 2026-10-28. Per signed-in user, API traffic.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The companion repository holds the scripts and every result file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/gitlab-rate-limit-check" rel="noopener noreferrer"&gt;The-DevOps-Daily/gitlab-rate-limit-check on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;probe.sh&lt;/code&gt; sends requests with a one-second pause between them and writes the status code, the cache status and the rate-limit headers of each response to a TSV file stamped with the script's hash. All requests came from one residential IP address over IPv4, with no token, on October 28 between 22:38 and 23:38 UTC and on October 29 between 01:00 and 04:03 UTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;probe.sh against a project in a personal namespace&lt;/strong&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="nv"&gt;$ PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dwt1%2Fdotfiles ./probe.sh api 70
results/api-anon-20261028T225259Z.tsv
     58 200 BYPASS  throttle_unauthenticated_api    500
     12 429 BYPASS  unauthenticated_traffic_per_ip
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'58,61p'&lt;/span&gt; results/api-anon-20261028T225259Z.tsv
56  22:54:21    200 BYPASS  throttle_unauthenticated_api    500 478 
57  22:54:23    200 BYPASS  throttle_unauthenticated_api    500 477 
58  22:54:24    200 BYPASS  throttle_unauthenticated_api    500 476 
59  22:54:26    429 BYPASS  unauthenticated_traffic_per_ip 334

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns are request number, UTC time, status, Cloudflare cache status, &lt;code&gt;RateLimit-Name&lt;/code&gt;, &lt;code&gt;RateLimit-Limit&lt;/code&gt;, &lt;code&gt;RateLimit-Remaining&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the limit depends on the project you request
&lt;/h2&gt;

&lt;p&gt;Before that run we had already sent 140 anonymous API requests to &lt;code&gt;gitlab-org/gitlab-runner&lt;/code&gt; and 70 cache-busted raw-file requests to the same project, in just over ten minutes. All 210 returned &lt;code&gt;200&lt;/code&gt;, and the only throttle in the headers was the old &lt;code&gt;throttle_unauthenticated_api&lt;/code&gt; or &lt;code&gt;throttle_unauthenticated_web&lt;/code&gt;, at 500 requests per minute.&lt;/p&gt;

&lt;p&gt;The run against &lt;code&gt;dwt1/dotfiles&lt;/code&gt;, a popular public project in a personal namespace, was blocked after 58 requests. That hour we had also made two requests to endpoints outside any project, &lt;code&gt;/api/v4/version&lt;/code&gt; and a &lt;code&gt;/api/v4/projects&lt;/code&gt; search. If those count, the block came after exactly 60 requests, which also suggests nothing else on that address was sending anonymous requests to GitLab.com. While the IP was blocked, we sent one request to each of six targets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Status while blocked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gitlab-org/gitlab-runner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gitlab-org/gitlab&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inkscape/inkscape&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dwt1/dotfiles&lt;/code&gt; (personal namespace)&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CalcProgrammer1/OpenRGB&lt;/code&gt; (personal namespace)&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/api/v4/version&lt;/code&gt; (no project)&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our reading is that the new anonymous limit depends on the namespace you request, most likely on its plan: &lt;code&gt;gitlab-org&lt;/code&gt; is GitLab's own group, Inkscape is a large open source group, and requests to both went through. We cannot see a namespace's plan without credentials, so this is an inference from the pattern, not a fact we could check.&lt;/p&gt;

&lt;p&gt;To test that reading, we waited until the IP had been quiet for more than an hour and ran &lt;code&gt;count-test.sh&lt;/code&gt; at 01:00:45 UTC on October 29. It sends 10 requests to &lt;code&gt;gitlab-org/gitlab-runner&lt;/code&gt;, then 10 to &lt;code&gt;/api/v4/version&lt;/code&gt;, then requests to &lt;code&gt;dwt1/dotfiles&lt;/code&gt; until the first &lt;code&gt;429&lt;/code&gt;. If the &lt;code&gt;gitlab-org&lt;/code&gt; requests do not count and the others do, the last step gets 50 successes before the block. It got exactly 50:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;count-test.sh from a clean start&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./count-test.sh
results/count-test-20261029T010045Z.tsv
10 paid 200
50 personal 200
1 personal 429
10 unscoped 401
personal-namespace successes before the first 429: 50 (prediction: 50)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;paid&lt;/code&gt; is the script's label for the &lt;code&gt;gitlab-org&lt;/code&gt; step, after our guess about why it is not counted. The same total would come out if the &lt;code&gt;gitlab-org&lt;/code&gt; requests counted and the &lt;code&gt;/api/v4/version&lt;/code&gt; requests did not, but the first evening rules that out: more than 200 &lt;code&gt;gitlab-org&lt;/code&gt; requests in the same hour as the 58 personal ones did not trigger a block. The &lt;code&gt;/api/v4/version&lt;/code&gt; requests all returned &lt;code&gt;401 Unauthorized&lt;/code&gt;, so a request that fails still uses up the budget.&lt;/p&gt;

&lt;p&gt;So if you want to know whether your automation will be throttled, test it against the projects it really uses. A quick check against a large open source project tells you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: successful responses do not show the new limit
&lt;/h2&gt;

&lt;p&gt;GitLab's advice is to watch &lt;code&gt;RateLimit-Remaining&lt;/code&gt; and slow down as it drops. For this limit, that does not work. Every successful response in our runs described only the old per-minute throttle. Request 58 got &lt;code&gt;RateLimit-Remaining: 476&lt;/code&gt;, and request 59 got a &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;429&lt;/code&gt; was the first response that mentioned the new limit. It carried &lt;code&gt;RateLimit-Name: unauthenticated_traffic_per_ip&lt;/code&gt; and a &lt;code&gt;Retry-After&lt;/code&gt;, but no &lt;code&gt;RateLimit-Limit&lt;/code&gt; or &lt;code&gt;RateLimit-Remaining&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So you have two options. Count your own anonymous requests, or alert on the first &lt;code&gt;429&lt;/code&gt; with that &lt;code&gt;RateLimit-Name&lt;/code&gt;. The repository's &lt;code&gt;glcurl&lt;/code&gt; wrapper logs one JSON line per request without the token, and &lt;code&gt;budget.py&lt;/code&gt; reads those logs and reports each caller's busiest minute and busiest hour against the plan limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the count resets an hour after it starts, not at the top of the hour
&lt;/h2&gt;

&lt;p&gt;The first &lt;code&gt;429&lt;/code&gt;, at 22:54:26 UTC, said &lt;code&gt;Retry-After: 334&lt;/code&gt;. That points to 23:00:00, the top of the hour. We waited until 23:00:45 and tried again. Requests to &lt;code&gt;gitlab-org&lt;/code&gt; still worked, but the first request that counts was blocked, and this time &lt;code&gt;Retry-After&lt;/code&gt; was 3,540 seconds, pointing to midnight. Then we sent one request every three minutes to see when the block really ended:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UTC&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Retry-After&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;22:54:26&lt;/td&gt;
&lt;td&gt;429 (first)&lt;/td&gt;
&lt;td&gt;334&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:01:01&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;3,540&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:02:07&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;3,473&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:20:10&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;2,390&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:35:12&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;1,488&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:38:12&lt;/td&gt;
&lt;td&gt;not limited&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every &lt;code&gt;Retry-After&lt;/code&gt; was the time until the end of the current clock hour, but the block ended between 23:35 and 23:38. The first request that counted that evening was a one-off &lt;code&gt;/api/v4/version&lt;/code&gt; call. It has no result file, but our terminal log puts it between 22:38:02 and 22:38:03, and an hour after that falls inside the three minutes in which the block ended.&lt;/p&gt;

&lt;p&gt;Our first explanation was a sliding window: count the requests of the last 3,600 seconds, and let the caller back in as the oldest one drops out. We tested it a few hours later. The count test had blocked the IP at 01:02:29, and its first counted request was at 01:01:01. &lt;code&gt;predict-window.py&lt;/code&gt; worked out what three counting models expect, and we committed its predictions before &lt;code&gt;window-test.sh&lt;/code&gt; ran:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Clock hour&lt;/th&gt;
&lt;th&gt;Sliding 60 minutes&lt;/th&gt;
&lt;th&gt;Weighted estimate&lt;/th&gt;
&lt;th&gt;GitLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One at 01:50:00&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;429, &lt;code&gt;Retry-After: 600&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One at 02:00:30&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;429, &lt;code&gt;Retry-After: 3570&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back to back from 02:01:45&lt;/td&gt;
&lt;td&gt;59 allowed&lt;/td&gt;
&lt;td&gt;35 to 46 allowed&lt;/td&gt;
&lt;td&gt;1 allowed&lt;/td&gt;
&lt;td&gt;25 allowed, then a different 429&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The weighted estimate is a common shortcut in rate limiters: the previous hour's count, weighted by the part of it still inside the last 3,600 seconds, plus the current hour's count. Half a minute into the new clock hour we were still blocked, and &lt;code&gt;Retry-After&lt;/code&gt; now pointed to 03:00:00. A minute later, 25 requests went through, and then the burst was stopped by the limit in the next section. The sliding window matched, and we wrote it up that way.&lt;/p&gt;

&lt;p&gt;The second review of this post found the hole. A counter that starts with the first request and resets completely 3,600 seconds later, the way a Redis &lt;code&gt;INCR&lt;/code&gt; with an &lt;code&gt;EXPIRE&lt;/code&gt; on the first increment works, fits the same three rows. The two models only disagree while old requests are leaving a sliding window, and our first test of that was cut off at request 26 by the same limit. So we ran another, with requests that return &lt;code&gt;200&lt;/code&gt; and do not trip that limit. At 03:04:00 the sliding window predicted 25 more requests, and the resetting counter 35. GitLab allowed 35:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;series-test.sh against a personal project, predictions committed at 03:03:46&lt;/strong&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="nv"&gt;$ URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'https://gitlab.com/api/v4/projects/dwt1%2Fdotfiles'&lt;/span&gt; &lt;span class="nv"&gt;START&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 03:04:00 +%s&lt;span class="si"&gt;)&lt;/span&gt; ./series-test.sh
results/series-test-20261029T030400Z.tsv
     35 200 throttle_unauthenticated_api
      1 429 unauthenticated_traffic_per_ip

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the sliding window was wrong. The first test could have told the two apart, but the other limit stopped it after 25 requests. What matched every response from the hourly limit: a count that starts with the first request that counts, allows 60, and resets about an hour after that first request.&lt;/p&gt;

&lt;p&gt;We tested that once more, with the predictions committed first. The count that series ran into started with a request sent at 03:02:00.5, so it should reset at about 04:02:00, not at 04:00:00, where &lt;code&gt;Retry-After&lt;/code&gt; pointed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;reset-test.sh, predictions committed at 03:06:45&lt;/strong&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="nv"&gt;$ TIMES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"04:00:30 04:01:50 04:02:10"&lt;/span&gt; &lt;span class="nv"&gt;BURST_AT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;04:02:15 ./reset-test.sh
results/reset-test-20261029T030649Z.tsv
2026-10-29T04:00:30.666Z 429 unauthenticated_traffic_per_ip 3569
2026-10-29T04:01:50.122Z 429 unauthenticated_traffic_per_ip 3490
2026-10-29T04:02:10.586Z 200 throttle_unauthenticated_api
1 x 429 unauthenticated_traffic_per_ip
59 x 200 throttle_unauthenticated_api

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both blocked responses now pointed &lt;code&gt;Retry-After&lt;/code&gt; to 05:00:00, about 59 and 58 minutes away. Less than 21 seconds later the block was gone, and all 60 came back at once: one at 04:02:10 and 59 in the burst that followed. &lt;code&gt;predict-window.py --compare&lt;/code&gt; checks each response against what each model expects, given the requests GitLab had accepted before it. Only the resetting counter matched all 63 responses in this run. The clock hour matched 61, because it expected the two requests after 04:00:00 to go through, and its forecast before the run had expected 57 in the burst, not 59.&lt;/p&gt;

&lt;p&gt;Two more things follow from the data. Blocked requests did not extend the block: we got 38 blocked responses the first evening, and it still ended about an hour after 22:38. And the budget is exactly 60. After the 25-request burst at 02:01:45, &lt;code&gt;burst-test.sh&lt;/code&gt; sent 25 more back to back, waited 75 seconds, and then sent requests with a 1.5-second pause between them. With 50 used, it should get 10 more, and it got exactly 10:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;burst-test.sh: 25 + 25 + 10 = 60&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./burst-test.sh
results/burst-test-20261029T020328Z.tsv
     25 C   401 throttle_unauthenticated_api
      1 C   429 
     10 D   401 throttle_unauthenticated_api
      1 D   429 unauthenticated_traffic_per_ip

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;Retry-After&lt;/code&gt; from this throttle is wrong in both directions. At 22:54 it pointed about 35 to 38 minutes too early. At 04:01:50 it said 3,490 seconds, and the block ended less than 21 seconds later. If you keep a log of your anonymous requests, the block ends about an hour after the first request of the current count, and then all 60 come back at once. Since blocked requests did not extend the block, checking again every few minutes is safe. The better fix is to stop sending anonymous requests from that address and switch to a token.&lt;/p&gt;

&lt;p&gt;For the other limits, &lt;code&gt;backoff.py&lt;/code&gt; in the repository waits &lt;code&gt;Retry-After&lt;/code&gt;, uses capped exponential backoff with full jitter when a &lt;code&gt;429&lt;/code&gt; has no headers, and slows down when &lt;code&gt;RateLimit-Remaining&lt;/code&gt; gets low.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: a second limit we could not pin down
&lt;/h2&gt;

&lt;p&gt;Every run of requests to &lt;code&gt;/api/v4/version&lt;/code&gt; that got that far stopped at request 26 with a different &lt;code&gt;429&lt;/code&gt;: no &lt;code&gt;RateLimit-Name&lt;/code&gt;, and &lt;code&gt;Retry-After: 60&lt;/code&gt;. Two were back-to-back bursts that took about ten seconds, and one was a slower series that took 48. We saved one of these responses in full. Like every gitlab.com response it carried &lt;code&gt;server: cloudflare&lt;/code&gt;, but none of the headers that GitLab's own &lt;code&gt;429&lt;/code&gt; had, such as &lt;code&gt;gitlab-lb&lt;/code&gt;, &lt;code&gt;gitlab-sv&lt;/code&gt;, &lt;code&gt;x-runtime&lt;/code&gt; and the &lt;code&gt;RateLimit-*&lt;/code&gt; headers. So it most likely came from Cloudflare, not from GitLab. The body said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The service is temporarily unavailable due to elevated error rates. Please retry after a short wait.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request in those runs had returned &lt;code&gt;401&lt;/code&gt;, because &lt;code&gt;/api/v4/version&lt;/code&gt; needs a token. The message suggests a limit on errors, but 60 back-to-back &lt;code&gt;404&lt;/code&gt; responses for a missing file in &lt;code&gt;gitlab-org/gitlab-runner&lt;/code&gt;, at the pace of the fast bursts, did not trip it. Neither did 60 &lt;code&gt;200&lt;/code&gt; responses sent four at a time in about eight seconds. So it does not apply one request rate or one error count to every route. It may count &lt;code&gt;401&lt;/code&gt; responses, or apply only to that endpoint, or leave &lt;code&gt;gitlab-org&lt;/code&gt; alone. Our tests cannot tell these apart.&lt;/p&gt;

&lt;p&gt;It matters for anything that retries quickly on authentication errors. Our runs got this &lt;code&gt;429&lt;/code&gt; after 25 failed requests, and it carries none of the headers your GitLab rate-limit handling looks for. When a &lt;code&gt;429&lt;/code&gt; has no &lt;code&gt;RateLimit-Name&lt;/code&gt;, log its body before you decide how long to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  A method bug: cached raw files never reach the limiter
&lt;/h2&gt;

&lt;p&gt;Our first raw-file run looked strange. All 70 requests returned &lt;code&gt;200&lt;/code&gt;, and &lt;code&gt;RateLimit-Remaining&lt;/code&gt; stayed at 494 for the whole run. The probe did not record cache headers yet, so two minutes later we checked by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;cf-cache-status: HIT
age: 48
cache-control: max-age=60, public, must-revalidate, stale-while-revalidate=60, stale-if-error=300, s-maxage=60
ratelimit-remaining: 494
ratelimit-reset: 1792878180

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an excerpt of one response, saved in the repository. Cloudflare answered from its cache, so the rate-limit headers were a frozen copy: that &lt;code&gt;ratelimit-reset&lt;/code&gt; is 21:43 UTC on October 24, four days before our run. Those requests most likely never reached GitLab's limiter. With a unique query string on each request, all 70 responses were &lt;code&gt;cf-cache-status: MISS&lt;/code&gt; and the counter moved again. &lt;code&gt;probe.sh&lt;/code&gt; now adds that query string by default and records the cache status of every response.&lt;/p&gt;

&lt;p&gt;The API responses all said &lt;code&gt;cf-cache-status: BYPASS&lt;/code&gt;, so API calls reach GitLab every time. Do not count on the cache for raw files either: you cannot tell in advance which requests it will absorb.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it hurts: many runners behind one address
&lt;/h2&gt;

&lt;p&gt;60 requests per hour is plenty for a laptop. It is not much for a fleet of self-hosted runners that leave the network through one NAT gateway, because every anonymous request from every runner counts against the same IP.&lt;/p&gt;

&lt;p&gt;Here is a worked example, not a measurement. Twelve parallel jobs per pipeline, a pipeline every ten minutes, and each job fetches an install script and makes two API calls without a token. &lt;code&gt;examples/make_nat_log.py&lt;/code&gt; writes that hour as a log, and &lt;code&gt;budget.py&lt;/code&gt; compares it with the limits. It groups anonymous requests by the egress address they leave from, and token requests by the GitLab user behind the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 budget.py examples/runner-nat.jsonl
caller requests max/min limit max/hour limit use
anon@runners-nat 216 36 500 216 60 360% &amp;lt;-- over
token:release-bot 90 90 100 90 5000 90% &amp;lt;-- near

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The busiest minute, 36 requests, is far below the old 500-per-minute limit. Each pipeline makes 36 anonymous requests, so if all of them count and the hour starts with an unused budget, the second pipeline takes the total past 60. The release bot with a token is fine on the hourly limit, but 90 requests in 45 seconds is close to the Free burst limit of 100 per minute.&lt;/p&gt;

&lt;p&gt;The usual sources of anonymous traffic in CI are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;install scripts fetched with &lt;code&gt;curl ... | sh&lt;/code&gt; from a &lt;code&gt;/-/raw/&lt;/code&gt; URL&lt;/li&gt;
&lt;li&gt;release binaries downloaded from &lt;code&gt;/-/releases/&lt;/code&gt; links&lt;/li&gt;
&lt;li&gt;source archives from &lt;code&gt;/-/archive/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ADD https://gitlab.com/...&lt;/code&gt; in a Dockerfile, which runs on every uncached build&lt;/li&gt;
&lt;li&gt;API calls through &lt;code&gt;$CI_API_V4_URL&lt;/code&gt; without a &lt;code&gt;JOB-TOKEN&lt;/code&gt; header&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Find the anonymous calls before GitLab does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;find_anon_calls.py&lt;/code&gt; scans CI files, shell and PowerShell scripts, Dockerfiles and Makefiles for these calls and tells you what to use instead. Git over HTTPS is skipped, because it is not counted against the anonymous limit. Run against three of the repository's test fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 find_anon_calls.py tests/fixtures/.gitlab-ci.yml tests/fixtures/Dockerfile tests/fixtures/install.sh
tests/fixtures/.gitlab-ci.yml:8: [raw] anonymous call to https://gitlab.com/acme/tools/-/raw/main/install.sh
    fix: use GET /api/v4/projects/:id/repository/files/:path/raw?ref=:ref with JOB-TOKEN or PRIVATE-TOKEN
tests/fixtures/.gitlab-ci.yml:12: [api] anonymous call to $CI_API_V4_URL/projects/42/releases
    fix: add a token header: JOB-TOKEN: $CI_JOB_TOKEN in CI (packages, releases, raw files, artifacts, archives) or a project/group access token for other endpoints
tests/fixtures/.gitlab-ci.yml:20: [archive] anonymous call to https://gitlab.com/acme/lib/-/archive/v2.0/lib-v2.0.tar.gz
    fix: use GET /api/v4/projects/:id/repository/archive with JOB-TOKEN or PRIVATE-TOKEN
tests/fixtures/Dockerfile:3: [raw] anonymous call to https://gitlab.com/acme/tools/-/raw/main/healthcheck.sh
    fix: use GET /api/v4/projects/:id/repository/files/:path/raw?ref=:ref with JOB-TOKEN or PRIVATE-TOKEN
tests/fixtures/Dockerfile:5: [raw] anonymous call to https://gitlab.com/acme/tools/-/raw/main/motd
    fix: use GET /api/v4/projects/:id/repository/files/:path/raw?ref=:ref with JOB-TOKEN or PRIVATE-TOKEN
tests/fixtures/install.sh:3: [download] anonymous call to https://gitlab.com/acme/cli/-/releases/v1.4.0/downloads/cli-linux-amd64
    fix: download through the API (releases, job artifacts, packages) with a token
6 anonymous GitLab call(s) found

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits with status 1 when it finds anything, so you can add it as a CI job. It decides per command, so a token on one command does not cover the next one on the same line. It is still a pattern matcher, not a parser: it misses URLs built at runtime and calls in languages it does not read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: send a token
&lt;/h2&gt;

&lt;p&gt;In a GitLab CI job you already have one. According to GitLab's &lt;a href="https://docs.gitlab.com/ci/jobs/ci_job_token/" rel="noopener noreferrer"&gt;job token documentation&lt;/a&gt;, &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; can read raw files, releases and release links, packages, job artifacts and repository archives. For a project outside your own, add your project to the target project's job token allowlist. Public and internal projects allow some of these reads without it.&lt;/p&gt;

&lt;p&gt;The most common change is the install script:&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;# Before: anonymous, counts against the runner's IP&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://gitlab.com/acme/tools/-/raw/main/install.sh | sh

&lt;span class="c"&gt;# After: authenticated with the job token, counts against your plan's limits.&lt;/span&gt;
&lt;span class="c"&gt;# 1234 is the numeric ID of the acme/tools project; the file path is URL-encoded.&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"JOB-TOKEN: &lt;/span&gt;&lt;span class="nv"&gt;$CI_JOB_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CI_API_V4_URL&lt;/span&gt;&lt;span class="s2"&gt;/projects/1234/repository/files/install.sh/raw?ref=main"&lt;/span&gt; | sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For endpoints the job token cannot reach, such as reading project or group details, use a project or group access token with the &lt;code&gt;read_api&lt;/code&gt; scope, stored as a masked CI/CD variable. Outside CI, use a personal access token with the narrowest scope that works. And if a third-party tool cannot authenticate at all, GitLab asks you to contact &lt;code&gt;limits@gitlab.com&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One IP, one night.&lt;/strong&gt; GitLab may enforce the limit differently in other regions, at other times, or for other address ranges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace plans.&lt;/strong&gt; We inferred that paid groups are not counted yet. We cannot see a namespace's plan without credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exact rule.&lt;/strong&gt; A counter that resets about an hour after its first request matched every response from the hourly limit, including two runs predicted in advance. Close variants fit the same data, such as a reset an hour after the start of that request's minute, or a few seconds short of an hour. A clock-hour window, a weighted estimate and a sliding window each failed at least one prediction. Other implementations could fit the same data, and GitLab can change it at any time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The second limit.&lt;/strong&gt; We could trigger it only with &lt;code&gt;401&lt;/code&gt; responses from &lt;code&gt;/api/v4/version&lt;/code&gt;. We do not know what it counts, and that it runs at Cloudflare is an inference from the headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticated limits.&lt;/strong&gt; We had no GitLab token on the test machine, so we did not test the per-user Free limits of 5,000 per hour and 100 per minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;January.&lt;/strong&gt; We could not test the Premium and Ultimate limits, which start in January 2027, or whether requests to paid groups start counting against the anonymous limit then.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The new anonymous limit is live on GitLab.com for at least some of the traffic, and it is easy to miss. It did not apply to the projects we tested first. Successful responses carried no sign of it, and the &lt;code&gt;Retry-After&lt;/code&gt; on the &lt;code&gt;429&lt;/code&gt; did not say when the block would end.&lt;/p&gt;

&lt;p&gt;Before it shows up as a failed pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;find_anon_calls.py&lt;/code&gt; over your CI files, scripts and Dockerfiles.&lt;/li&gt;
&lt;li&gt;Give every gitlab.com call a token, starting with &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; where it is allowed.&lt;/li&gt;
&lt;li&gt;Count requests per runner egress IP, not per job.&lt;/li&gt;
&lt;li&gt;Alert on &lt;code&gt;429&lt;/code&gt; responses with &lt;code&gt;RateLimit-Name: unauthenticated_traffic_per_ip&lt;/code&gt;. Do not wait for their &lt;code&gt;Retry-After&lt;/code&gt;: in our tests the block ended about an hour after the first request of the current count from that address.&lt;/li&gt;
&lt;li&gt;Before January, check your busiest hour against the Premium or Ultimate limits with &lt;code&gt;budget.py&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/gitlab-anonymous-rate-limit-60-per-hour" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
      <category>ratelimiting</category>
      <category>api</category>
    </item>
    <item>
      <title>Email Bounce Types Explained: Practice Sorting Hard, Soft and Blocked Bounces in Your Browser</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:03:39 +0000</pubDate>
      <link>https://dev.to/devopsdaily/email-bounce-types-explained-practice-sorting-hard-soft-and-blocked-bounces-in-your-browser-1i74</link>
      <guid>https://dev.to/devopsdaily/email-bounce-types-explained-practice-sorting-hard-soft-and-blocked-bounces-in-your-browser-1i74</guid>
      <description>&lt;p&gt;&lt;code&gt;550 5.7.1 Message rejected due to content restrictions&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Hard bounce? The 5 says permanent, so a bounce handler built on "5xx means dead" files it with the dead addresses and suppresses the recipient forever. But the code is not about the address. It says a content filter at their end objected to your message, maybe over a link shortener, and the same template will likely hit that filter for other readers behind it. The usual rule, "5xx is hard, 4xx is soft", is a hint, not a verdict. This post covers the rest: where a bounce comes from in the SMTP conversation, how to read one, and which ones to suppress. Disclosure: I help build the two simulators below; they are free, run in the browser, and need no signup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/games/smtp-flow-simulator" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwlsj3ztgxjl2s9ytmm1s.jpg" alt="SMTP Flow Simulator on DevOps Daily" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a bounce comes from
&lt;/h2&gt;

&lt;p&gt;Most bounces start as a negative SMTP reply. If the receiving server says no during the session, your relay turns that into a bounce for you. If it accepts the message and delivery fails later, RFC 5321 says it must send a notification of its own, from the null sender &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;, to your envelope &lt;code&gt;MAIL FROM&lt;/code&gt; address. The rest come from your own relay with no reply at all: a domain that does not exist, a connection that never opens, a queue that expires. The &lt;a href="https://devops-daily.com/games/smtp-flow-simulator" rel="noopener noreferrer"&gt;SMTP Flow Simulator&lt;/a&gt; plays an illustrative timeline of the conversation in three scenarios, mixing SMTP commands with the DNS and delivery steps around them. The actors (App, SMTP relay, DNS auth, Recipient MX, Mailbox) light up in turn, and a trace pane prints each step as a &lt;code&gt;C:&lt;/code&gt; and &lt;code&gt;S:&lt;/code&gt; pair:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Successful transactional email&lt;/strong&gt;: nine steps, from a password reset submitted on port 587 through EHLO, STARTTLS, a second EHLO, AUTH, &lt;code&gt;MAIL FROM&lt;/code&gt; and &lt;code&gt;RCPT TO&lt;/code&gt;, DATA, the SPF, DKIM and DMARC checks, and the relay to the recipient's MX, to inbox placement. (The simulator shows the auth checks as one step before the hand-off. In real delivery, your side signs DKIM and the receiving server runs the checks as the message arrives.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporary greylisting retry&lt;/strong&gt;: the recipient's MX answers &lt;code&gt;RCPT TO&lt;/code&gt; with &lt;code&gt;451 4.7.1 Try again later&lt;/code&gt;, the relay backs off ("retry in 10 minutes, then 30, then 2 hours"), and the second attempt is accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication failure&lt;/strong&gt;: &lt;code&gt;535 5.7.8 Authentication credentials invalid&lt;/code&gt; at AUTH, then &lt;code&gt;530 5.7.0 Authentication required&lt;/code&gt; when the app tries &lt;code&gt;MAIL FROM&lt;/code&gt; anyway. Both codes come from RFC 4954. That is a rejection, not a bounce: the message never left.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step through them with Play or Step and read the protocol notes, especially "A 250 after DATA means the next server accepted responsibility" and "Accepted does not always mean inboxed".&lt;/p&gt;

&lt;p&gt;You can watch the start of a real conversation yourself. Receiving servers listen on port 25, and &lt;code&gt;dig +short MX gmail.com&lt;/code&gt; gives &lt;code&gt;gmail-smtp-in.l.google.com&lt;/code&gt; as the most preferred host (preference 5). Many home ISPs and cloud providers block outbound port 25, so this can time out. From my connection it worked, and this is the real output, with my IP swapped for a documentation address:&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="nv"&gt;$ &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
import smtplib
s = smtplib.SMTP("gmail-smtp-in.l.google.com", 25, timeout=10)
print(s.ehlo("test.invalid")[1].decode())
s.quit()'&lt;/span&gt;
mx.google.com at your service, &lt;span class="o"&gt;[&lt;/span&gt;203.0.113.10]
SIZE 157286400
8BITMIME
STARTTLS
ENHANCEDSTATUSCODES
PIPELINING
CHUNKING
SMTPUTF8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line to notice is &lt;code&gt;ENHANCEDSTATUSCODES&lt;/code&gt;. The server announces that its replies carry a second, more precise code, and that code is where bounce triage starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read a bounce
&lt;/h2&gt;

&lt;p&gt;Take &lt;code&gt;550 5.1.1 User unknown&lt;/code&gt;. It has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The reply code, 550&lt;/strong&gt;, defined in &lt;a href="https://www.rfc-editor.org/rfc/rfc5321" rel="noopener noreferrer"&gt;RFC 5321&lt;/a&gt;. The first digit says 4 (try again later) or 5 (do not repeat this as-is).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The enhanced status code, 5.1.1&lt;/strong&gt;, defined in &lt;a href="https://www.rfc-editor.org/rfc/rfc3463" rel="noopener noreferrer"&gt;RFC 3463&lt;/a&gt; as class.subject.detail. The middle number says what the problem is about: 1 addressing, 2 mailbox, 3 mail system, 4 network and routing, 5 protocol, 6 content, 7 security or policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The text&lt;/strong&gt;, free-form and often the most useful part.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The class says how long. The subject says what kind of problem it is. A few lines of Python give you a first look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;SUBJECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addressing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mail system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;routing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protocol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;first_look&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b([45])\.(\d{1,3})\.(\d{1,3})\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no enhanced code, read the text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permanent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cls&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temporary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, about &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SUBJECT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against three replies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;550 5.1.1 User unknown         -&amp;gt; permanent, about addressing
452 4.2.2 The email account th -&amp;gt; temporary, about mailbox
550 5.7.1 Message rejected due -&amp;gt; permanent, about policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 5.1.1, "permanent, about addressing" means a dead address: suppress it. Other addressing codes need the detail digit, since 5.1.7 and 5.1.8 are about your sender address, not theirs. "Permanent, about policy" usually points at your side: content, authentication or sending reputation. Suppressing the recipient fixes none of those. The &lt;a href="https://www.iana.org/assignments/smtp-enhanced-status-codes" rel="noopener noreferrer"&gt;IANA registry&lt;/a&gt; lists every assigned code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four kinds, five actions
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/bounce-triage-simulator" rel="noopener noreferrer"&gt;Bounce Triage Simulator&lt;/a&gt; turns this into practice. It shows twelve bounces one at a time, each with the recipient, the provider (Gmail, Outlook, Postfix, Proofpoint and others) and the raw response. For each one you answer two questions. "What kind of failure is this?": Hard bounce, Soft bounce, Blocked, or Complaint. "What do you do with it?": Suppress permanently, Retry with backoff, Fix the message then retry, Slow down then retry, or Stop sending and fix the cause. The last one is the answer for a relay denial and a blocklist listing. Press "Triage it" to see what the code means, plus a box titled "What getting it wrong costs you".&lt;/p&gt;

&lt;p&gt;Get the kind right but the action wrong and it tells you "Right diagnosis, wrong treatment", the exact mistake it is built to catch. The rounds that teach the most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;550-5.7.26 Unauthenticated email from yourdomain.com is not accepted due to domain's DMARC policy.&lt;/code&gt; A 5xx that is an authentication bug on your side. RFC 7372 named 5.7.26 "Multiple authentication checks failed", and Gmail uses it for DMARC failures. Receivers that enforce your DMARC policy keep rejecting mail that fails this way until you fix SPF or DKIM alignment.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;421 4.7.0 Too many messages from your IP. Please try again later.&lt;/code&gt; A 421 also closes the connection. The right action is to slow down. Retrying at the same rate makes it worse.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;450 4.7.1 ... Greylisted, try again in 300 seconds&lt;/code&gt;. Treat it as a failure and you never reach a whole class of small self-hosted domains, because the retry is the test.&lt;/li&gt;
&lt;li&gt;A Yahoo feedback loop report with &lt;code&gt;Feedback-Type: abuse&lt;/code&gt;. Not a bounce at all: the mail was delivered and the reader pressed "report spam".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Complaints get the hardest line. Suppress them at once and for good. &lt;a href="https://support.google.com/mail/answer/81126" rel="noopener noreferrer"&gt;Google's sender guidelines&lt;/a&gt; require every sender to keep the spam rate in Postmaster Tools below 0.3%, and recommend staying under 0.1%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to suppress
&lt;/h2&gt;

&lt;p&gt;Seven of the twelve rounds are 5xx, and four of those seven should not be suppressed. The policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suppress permanently:&lt;/strong&gt; hard bounces (5.1.1 user unknown, 5.1.2 domain not found, 5.2.1 disabled mailbox) and every complaint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry with backoff:&lt;/strong&gt; soft bounces (4.2.2 over quota, greylisting, 4.3.0 server errors), within your relay's queue lifetime. &lt;a href="https://www.rfc-editor.org/rfc/rfc5321#section-4.5.4" rel="noopener noreferrer"&gt;RFC 5321&lt;/a&gt; says the give-up time generally needs to be at least 4 to 5 days. It also says the retry interval SHOULD generally be at least 30 minutes, with smarter schedules when the sender knows why delivery failed, so the SMTP Flow Simulator's first greylisting retry after 10 minutes is within the rules. One expired message does not prove the address is dead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the recipient, investigate:&lt;/strong&gt; 5.7.x content and authentication blocks, blocklist listings, rate limits and relay errors. Most are fixed on your side (template, DNS, send rate, delisting), some need the recipient's mail admin, and none is fixed by resending the same message unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first digit tells you how long. The middle digit tells you what kind of problem it is. Play the three SMTP scenarios to see where replies come from, then triage the twelve bounces and count how many 5xx codes you would have suppressed by mistake. To type the conversation by hand, one command at a time, read &lt;a href="https://devops-daily.com/posts/send-an-email-by-hand-raw-smtp" rel="noopener noreferrer"&gt;Send an Email by Hand: The Raw SMTP Conversation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which bounce code has fooled your bounce handling? Tell us in the comments.&lt;/p&gt;

</description>
      <category>email</category>
      <category>smtp</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Moving Authorization Out of Your App Code: If-Statements vs Cerbos vs SpiceDB, Measured</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/moving-authorization-out-of-your-app-code-if-statements-vs-cerbos-vs-spicedb-measured-2181</link>
      <guid>https://dev.to/devopsdaily/moving-authorization-out-of-your-app-code-if-statements-vs-cerbos-vs-spicedb-measured-2181</guid>
      <description>&lt;p&gt;Many products rebuild their permissions three times: first roles, then teams, then per-document sharing. Somewhere after that, a rule arrives that nobody planned for. The usual advice is to move authorization out of the application code before that happens, into a policy engine or a Zanzibar-style relationship graph. We wanted to know what that move costs and what it buys, so we built the same permission model three ways and grew it in four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;if-statements&lt;/strong&gt; in the application, with a SQL query for lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos&lt;/strong&gt; policy files, evaluated by a local Cerbos policy decision point (PDP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB&lt;/strong&gt; , an open source implementation of Google's &lt;a href="https://research.google/pubs/zanzibar-googles-consistent-global-authorization-system/" rel="noopener noreferrer"&gt;Zanzibar&lt;/a&gt; design: a schema plus relationships.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A hand-written table of 84 expected decisions checked all three at every step. Then we timed them on a Raspberry Pi 4 with 10,000 documents. The numbers describe this setup: a Pi, one request at a time, the app database in the same process. Both the milliseconds and the ratios between the designs belong to this setup; a network, a different database or other hardware can change either.&lt;/p&gt;

&lt;p&gt;What we found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single check is cheap only in-process.&lt;/strong&gt; The if-statements answered in 0.14 ms. Cerbos took 3.8 ms and SpiceDB 10.8 ms (median p50). On this Pi a call to either server that does no authorization work already costs about 2.4 ms, and Cerbos's own log puts its handling time at 0.63 ms. By the bar we set before measuring (p99 under 5 ms), a check through either engine is not cheap on this machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listing is where they really differ.&lt;/strong&gt;"Which of these 10,000 documents can this member view?" took 4.4 ms as SQL, 11.9 to 31.6 ms through a Cerbos query plan turned into SQL (depending on two lines of our own adapter), and 404 ms through SpiceDB when its cache was cold (77 ms warm). For an admin who can view all 10,000, SpiceDB took 3.3 seconds cold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The late rule cost little code in every design:&lt;/strong&gt; 22 changed lines in one file for the if-statements, 15 in two files for Cerbos, 13 in two files for SpiceDB. It never stayed out of the application: Cerbos needed a new attribute in every request, and SpiceDB needed a new relationship synced from the database. In SpiceDB it also made a cold list about 3.5 times slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave out part of the late rule and every design fails open.&lt;/strong&gt; The table caught each of the three faults we injected, and it also caught SpiceDB returning duplicate documents in a list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Same model, three designs, four steps, one table of 84 hand-written decisions. All three matched it at every step.&lt;/li&gt;
&lt;li&gt;Single check, median p50 and p99: if-statements 0.14 / 0.24 ms, Cerbos 3.8 / 7.9 ms, SpiceDB 10.8 / 19.4 ms on new checks and 2.8 ms p50 on repeated ones, because SpiceDB caches results.&lt;/li&gt;
&lt;li&gt;List for a typical member (1,020 of 10,000 visible): SQL 4.4 ms, Cerbos 11.9 to 31.6 ms, SpiceDB 77 ms warm to 404 ms cold. SpiceDB's own docs suggest a different pattern above about 10,000 results, and for an admin's first page it took 18 ms.&lt;/li&gt;
&lt;li&gt;Lines changed over the four steps: 83 for if-statements, 211 for Cerbos, 187 for SpiceDB. The engines cost more up front; by step 4 a new rule cost them fewer lines than the if-statements (15 and 13 against 22).&lt;/li&gt;
&lt;li&gt;The engines did not remove application changes. Every new rule needed data, and the data lives in your app.&lt;/li&gt;
&lt;li&gt;Keep the if-statements while one app owns the rules and you can test them as a table. Move out when several services need the same answer or the relationships nest deeply.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Node.js 22.13 or later (the demo uses the built-in &lt;code&gt;node:sqlite&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Linux on arm64 or amd64. The demo downloads pinned Cerbos and SpiceDB release binaries and checks their SHA-256. No Docker.&lt;/li&gt;
&lt;li&gt;Basic familiarity with role-based access control and SQL joins&lt;/li&gt;
&lt;li&gt;The demo repository, which contains every commit, script and recorded run in this post:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/authz-three-ways" rel="noopener noreferrer"&gt;The-DevOps-Daily/authz-three-ways on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The model and the four steps
&lt;/h2&gt;

&lt;p&gt;The app has users, teams, documents and shares in SQLite. The actions are &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt;. We wrote the rules for all four steps into &lt;code&gt;CLAIM.md&lt;/code&gt; before writing any code, together with what result would prove our claim wrong:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Roles&lt;/td&gt;
&lt;td&gt;Each user has one org role. &lt;code&gt;viewer&lt;/code&gt; can view every document, &lt;code&gt;editor&lt;/code&gt; can view and edit, &lt;code&gt;admin&lt;/code&gt; can view, edit and delete.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Teams&lt;/td&gt;
&lt;td&gt;Each document belongs to a team. &lt;code&gt;viewer&lt;/code&gt; and &lt;code&gt;editor&lt;/code&gt; now apply only to documents of the user's own teams. &lt;code&gt;admin&lt;/code&gt; is unchanged.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Sharing&lt;/td&gt;
&lt;td&gt;A document can be shared with one user as &lt;code&gt;view&lt;/code&gt; or &lt;code&gt;edit&lt;/code&gt;, whatever that user's teams are. A share never grants delete.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Late rule&lt;/td&gt;
&lt;td&gt;Contractors get no access at all to documents outside their own teams. This overrides every earlier grant, including &lt;code&gt;admin&lt;/code&gt; and shares.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We picked the late rule before implementing anything. It is a realistic one: security asks for it after an audit, and it cuts across every grant that already exists, so it tests whether a design can say "no" over the top of many "yes" paths.&lt;/p&gt;

&lt;p&gt;The expected-decisions table, &lt;code&gt;fixture/expected.csv&lt;/code&gt;, has seven users, two teams, four documents and four shares, and one row per (user, action, document) with the expected answer at each step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;step&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;step&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;step&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;step&lt;/span&gt;&lt;span class="mf"&gt;4&lt;/span&gt;
&lt;span class="k"&gt;cora&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;d&lt;/span&gt;&lt;span class="mf"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;deny&lt;/span&gt;
&lt;span class="k"&gt;kai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;d&lt;/span&gt;&lt;span class="mf"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;deny&lt;/span&gt;
&lt;span class="k"&gt;nell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;d&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;allow&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cora is an editor in &lt;code&gt;eng&lt;/code&gt; and a contractor, with an edit share on &lt;code&gt;d4&lt;/code&gt;, a &lt;code&gt;sales&lt;/code&gt; document. Kai is an admin and a contractor in &lt;code&gt;sales&lt;/code&gt;. Nell is a viewer with no team and a view share on &lt;code&gt;d2&lt;/code&gt;. &lt;code&gt;harness/correctness.js&lt;/code&gt; runs every row against each design at each step, and also asks each design for the full list of documents each user can view, which must match the table's &lt;code&gt;view&lt;/code&gt; rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three designs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;if-statements&lt;/strong&gt; everything in the app

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;check()&lt;/strong&gt; JavaScript, up to 4 queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;list()&lt;/strong&gt; one SQL query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite&lt;/strong&gt; the app's own tables&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos&lt;/strong&gt; stateless policy engine

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App&lt;/strong&gt; reads attributes from SQLite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos PDP&lt;/strong&gt; policy YAML, gRPC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan to SQL&lt;/strong&gt; PlanResources, your adapter&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB&lt;/strong&gt; Zanzibar-style graph

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App&lt;/strong&gt; syncs rows to relationships&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB&lt;/strong&gt; schema, gRPC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relationships&lt;/strong&gt; SpiceDB's own copy&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three designs put the rule and the data in different places, and that difference explains almost every number below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;if-statements&lt;/strong&gt; (&lt;code&gt;approaches/ifs/index.js&lt;/code&gt;): one &lt;code&gt;check()&lt;/code&gt; function that reads the user, the document, the team membership and any share from SQLite, and one &lt;code&gt;list()&lt;/code&gt; function that is a SQL query. The rule is written twice: once in JavaScript and once in SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos&lt;/strong&gt; (&lt;code&gt;approaches/cerbos/&lt;/code&gt;): the rules live in YAML policies, with derived roles such as &lt;code&gt;team_editor&lt;/code&gt; and &lt;code&gt;shared_viewer&lt;/code&gt;. Cerbos stores no application data, so for every check the app reads the same rows from SQLite and sends them as principal and resource attributes. For lists, Cerbos's PlanResources API returns a condition tree, and our &lt;code&gt;plan-to-sql.js&lt;/code&gt; turns it into a SQL &lt;code&gt;WHERE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB&lt;/strong&gt; (&lt;code&gt;approaches/spicedb/&lt;/code&gt;): the rules live in a schema, and the data lives in SpiceDB as relationships such as &lt;code&gt;document:d1#team@team:eng&lt;/code&gt;. &lt;code&gt;sync.js&lt;/code&gt; copies the app's rows into SpiceDB. A check is one CheckPermission call with no database read in the app. A list is LookupResources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both engines ran on 127.0.0.1 and were called over gRPC through their official Node clients, one client each for the whole run, without TLS: Cerbos 0.57.0 with policies on disk, and SpiceDB 1.57.0 with its in-memory datastore, which its &lt;a href="https://authzed.com/docs/spicedb/concepts/datastores" rel="noopener noreferrer"&gt;docs&lt;/a&gt; describe as intended for testing integrations. Each design has one commit per step in the repo, tagged &lt;code&gt;step&amp;lt;N&amp;gt;-ifs&lt;/code&gt;, &lt;code&gt;step&amp;lt;N&amp;gt;-cerbos&lt;/code&gt; and &lt;code&gt;step&amp;lt;N&amp;gt;-spicedb&lt;/code&gt;, so every line count below comes from &lt;code&gt;git diff --numstat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same rule, three ways
&lt;/h2&gt;

&lt;p&gt;Here is the late rule, "contractors get nothing outside their own teams", as each design wrote it. These are excerpts from the step 4 commits; &lt;code&gt;// ...&lt;/code&gt; marks lines left out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: contractors get nothing outside their own teams&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;if-statements: check()&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inTeam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// Contractors get nothing outside their own teams, whatever else grants it.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractor&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inTeam&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// A share grants access whatever the user's role and teams are.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;getShare&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;share&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;SHARE_ACTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Roles other than admin apply only to documents of the user's teams.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inTeam&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;ROLE_ACTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&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;if-statements: list()&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Contractors see only documents of their own teams.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contractorFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`(:contractor = 0 OR d.team_id IN
  (SELECT team_id FROM team_members WHERE user_id = :user))`&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;allDocs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT d.id FROM documents d WHERE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;contractorFilter&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visibleDocs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  SELECT d.id FROM documents d
  JOIN team_members m ON m.team_id = d.team_id
  WHERE m.user_id = :user
  UNION
  SELECT s.document_id FROM shares s
  JOIN documents d ON d.id = s.document_id
  WHERE s.user_id = :user AND &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;contractorFilter&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cerbos: policy&lt;/strong&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="c1"&gt;# Contractors get nothing outside their own teams, whatever else grants it.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EFFECT_DENY&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.principal.attr.contractor == &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!(request.resource.attr.team&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;request.principal.attr.teams)"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cerbos: app&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT id, role, contractor FROM users WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;contractor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractor&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SpiceDB: schema&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;definition&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;relation&lt;/span&gt; &lt;span class="n"&gt;contractor&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;definition&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Contractors&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="n"&gt;nothing&lt;/span&gt; &lt;span class="n"&gt;outside&lt;/span&gt; &lt;span class="n"&gt;their&lt;/span&gt; &lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="n"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;whatever&lt;/span&gt; &lt;span class="ow"&gt;else&lt;/span&gt; &lt;span class="n"&gt;grants&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;outside_contractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contractor&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;member&lt;/span&gt;

    &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;view_docs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;viewer&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;outside_contractor&lt;/span&gt;
    &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;edit_docs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;outside_contractor&lt;/span&gt;
    &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;delete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;delete_docs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;outside_contractor&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;SpiceDB: sync&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT id, role, contractor FROM users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ORG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ORG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contractor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The if-statements wrote the rule more than once:&lt;/strong&gt; in &lt;code&gt;check()&lt;/code&gt;, and again in each list query that can return a document from outside the user's teams (the admin query and the shares branch). Miss one and the list leaks while every check passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos needed no change to the plan adapter.&lt;/strong&gt; The planner folds the principal's side of the condition into constants, so for Cora the deny rule becomes an &lt;code&gt;eq&lt;/code&gt; on &lt;code&gt;request.resource.attr.team&lt;/code&gt; with the value &lt;code&gt;eng&lt;/code&gt;, ANDed with her grants, which the adapter already handled (&lt;code&gt;results/final/plans-step4.json&lt;/code&gt;). It did need one more attribute from the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB's rule is one line of schema,&lt;/strong&gt; an exclusion: &lt;code&gt;org-&amp;gt;contractor - team-&amp;gt;member&lt;/code&gt;. But SpiceDB knows only what the app writes into it, so &lt;code&gt;sync.js&lt;/code&gt; needed a new relationship type too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What each step cost in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lines changed in each design, per step&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;1: roles&lt;/th&gt;
&lt;th&gt;2: teams&lt;/th&gt;
&lt;th&gt;3: sharing&lt;/th&gt;
&lt;th&gt;4: contractors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;if-statements&lt;/td&gt;
&lt;td&gt;29 lines&lt;/td&gt;
&lt;td&gt;12 lines&lt;/td&gt;
&lt;td&gt;20 lines&lt;/td&gt;
&lt;td&gt;22 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cerbos&lt;/td&gt;
&lt;td&gt;73 lines&lt;/td&gt;
&lt;td&gt;82 lines&lt;/td&gt;
&lt;td&gt;41 lines&lt;/td&gt;
&lt;td&gt;15 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpiceDB&lt;/td&gt;
&lt;td&gt;133 lines&lt;/td&gt;
&lt;td&gt;31 lines&lt;/td&gt;
&lt;td&gt;10 lines&lt;/td&gt;
&lt;td&gt;13 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Lines added plus deleted in each design's own commit, from git diff --numstat. The shared data-model commits (29, 26, 9, 5 lines) are the same for all three and not counted. Step 2 for SpiceDB includes the de-duplication fix the table forced (8 of its 31 lines).&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;if-statements&lt;/th&gt;
&lt;th&gt;Cerbos&lt;/th&gt;
&lt;th&gt;SpiceDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Roles&lt;/td&gt;
&lt;td&gt;1 file, 29 lines&lt;/td&gt;
&lt;td&gt;3 files, 73 lines&lt;/td&gt;
&lt;td&gt;3 files, 133 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Teams&lt;/td&gt;
&lt;td&gt;1 file, 12 lines&lt;/td&gt;
&lt;td&gt;4 files, 82 lines&lt;/td&gt;
&lt;td&gt;3 files, 31 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Sharing&lt;/td&gt;
&lt;td&gt;1 file, 20 lines&lt;/td&gt;
&lt;td&gt;4 files, 41 lines&lt;/td&gt;
&lt;td&gt;2 files, 10 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Late rule&lt;/td&gt;
&lt;td&gt;1 file, 22 lines&lt;/td&gt;
&lt;td&gt;2 files, 15 lines&lt;/td&gt;
&lt;td&gt;2 files, 13 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;83 lines&lt;/td&gt;
&lt;td&gt;211 lines&lt;/td&gt;
&lt;td&gt;187 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shapes differ more than the totals. The if-statements were cheapest to start, and from step 2 on each rule cost more than the last (12, 20, 22 lines), because every rule touched both &lt;code&gt;check()&lt;/code&gt; and the list query. Cerbos paid most at step 2, when the first condition appeared and we had to write the 43-line plan-to-SQL adapter. SpiceDB paid most at step 1: client setup, paging for LookupResources and the sync code. After that, sharing cost SpiceDB 10 lines.&lt;/p&gt;

&lt;p&gt;Two caveats keep these numbers honest. Line counts depend on style, so read the diffs in the repo rather than trusting a total. And our if-statements are the best case: one module with one &lt;code&gt;check()&lt;/code&gt;. If your checks are spread over many handlers, the late rule is many edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  A single check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One permission check, 10,000 documents (median p50, tick at p99)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;if-statements, in-process&lt;/td&gt;
&lt;td&gt;0.14ms&lt;/td&gt;
&lt;td&gt;0.24ms&lt;/td&gt;
&lt;td&gt;if-statements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cerbos over gRPC&lt;/td&gt;
&lt;td&gt;3.83ms&lt;/td&gt;
&lt;td&gt;7.91ms&lt;/td&gt;
&lt;td&gt;Cerbos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpiceDB over gRPC, new checks&lt;/td&gt;
&lt;td&gt;10.78ms&lt;/td&gt;
&lt;td&gt;19.37ms&lt;/td&gt;
&lt;td&gt;SpiceDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpiceDB over gRPC, repeated checks&lt;/td&gt;
&lt;td&gt;2.83ms&lt;/td&gt;
&lt;td&gt;15.62ms&lt;/td&gt;
&lt;td&gt;SpiceDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-op call to Cerbos&lt;/td&gt;
&lt;td&gt;2.4ms&lt;/td&gt;
&lt;td&gt;6ms&lt;/td&gt;
&lt;td&gt;No-op call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-op call to SpiceDB&lt;/td&gt;
&lt;td&gt;2.44ms&lt;/td&gt;
&lt;td&gt;6.31ms&lt;/td&gt;
&lt;td&gt;No-op call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Raspberry Pi 4, step 4 model, concurrency 1. Checks: median across 3 runs of 10,000 each. No-op calls: one run of 5,000 each. "New checks" are random (user, action, document) triples; "repeated checks" draw from 100 triples, so SpiceDB's cache answers most of them. A no-op call does no authorization work. Absolute numbers are from a Pi: compare the bars with each other.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We timed 10,000 random (user, action, document) checks per run, three runs, one at a time, with the three designs interleaved on every sample so that anything else on the Pi slowed all of them equally. All three gave the same answer on every sample that completed.&lt;/p&gt;

&lt;p&gt;At the median, the in-process check (0.144 ms) was about 27 times faster than Cerbos (3.83 ms) and about 75 times faster than SpiceDB (10.8 ms). Where that time goes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A request to either server that does no authorization work (Cerbos &lt;code&gt;ServerInfo&lt;/code&gt;, SpiceDB &lt;code&gt;ReadSchema&lt;/code&gt;) took 2.40 and 2.44 ms at the median on this Pi. A Node-to-Node TCP echo between two processes took 0.33 ms, so most of that is request handling in the gRPC client and server, not the loopback network.&lt;/li&gt;
&lt;li&gt;Cerbos logs its own handling time for each request. Its median was 0.63 ms, against 3.8 ms seen by the client.&lt;/li&gt;
&lt;li&gt;So for Cerbos, a request that does nothing already costs most of what a check costs. For SpiceDB on new checks, it is about a quarter of the 10.8 ms. We did not measure where the rest goes inside SpiceDB, and a &lt;code&gt;ReadSchema&lt;/code&gt; call is only a reference point, not the same work as a check minus the graph.&lt;/li&gt;
&lt;li&gt;SpiceDB's cache removes most of that difference. When the same 100 checks repeated, as they do when people reopen the same documents, its p50 dropped to 2.8 ms, close to the no-op call, while Cerbos stayed at 3.8 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing the chart hides: the if-statements' check runs up to four queries against SQLite in the same process. With Postgres across a network, each of those queries is a network round trip. The Cerbos design pays the same queries and the PDP call on top. The SpiceDB design reads nothing from the app database for a check. In that layout the gap between the in-process design and the engines could be smaller than it is here. We did not measure it.&lt;/p&gt;

&lt;p&gt;One of 60,000 SpiceDB checks failed with &lt;code&gt;UNAVAILABLE: Stream closed with error code NGHTTP2_REFUSED_STREAM&lt;/code&gt;. SpiceDB closes client connections after 30 seconds by default (&lt;code&gt;--grpc-max-conn-age&lt;/code&gt;), which is our best explanation, and the Node client did not retry it. We counted the failure and left it out of the timings. In production, give the client a retry policy for &lt;code&gt;UNAVAILABLE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Listing what a user can see
&lt;/h2&gt;

&lt;p&gt;This is the question every list page asks, and the designs answer it in completely different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;if-statements:&lt;/strong&gt; a SQL query with a &lt;code&gt;UNION&lt;/code&gt; of team documents and shared documents, filtered for contractors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos:&lt;/strong&gt; PlanResources returns a condition tree for this user, our adapter turns it into a &lt;code&gt;WHERE&lt;/code&gt;, and SQLite runs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB:&lt;/strong&gt; LookupResources walks the graph backwards from the user and streams document IDs. The app then fetches those rows from its own database, a step we did not time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;List the documents a typical member can view (1,020 of 10,000)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;if-statements: SQL&lt;/td&gt;
&lt;td&gt;4.4ms&lt;/td&gt;
&lt;td&gt;if-statements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cerbos: plan to SQL, IN subquery&lt;/td&gt;
&lt;td&gt;11.9ms&lt;/td&gt;
&lt;td&gt;Cerbos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cerbos: plan to SQL, as committed&lt;/td&gt;
&lt;td&gt;31.6ms&lt;/td&gt;
&lt;td&gt;Cerbos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpiceDB: LookupResources, warm&lt;/td&gt;
&lt;td&gt;77.2ms&lt;/td&gt;
&lt;td&gt;SpiceDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpiceDB: LookupResources, cold&lt;/td&gt;
&lt;td&gt;403.9ms&lt;/td&gt;
&lt;td&gt;SpiceDB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median ms on a Raspberry Pi 4, step 4 model. "As committed" turns each share condition into a correlated EXISTS; the IN-subquery variant changes only those two lines. SpiceDB bars come from a separate cache experiment with one unlimited LookupResources call: "cold" is the first call in a new 5 s revision window, "warm" repeats it at the same revision.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;10,000 documents&lt;/th&gt;
&lt;th&gt;Visible&lt;/th&gt;
&lt;th&gt;if-statements&lt;/th&gt;
&lt;th&gt;Cerbos (as committed)&lt;/th&gt;
&lt;th&gt;SpiceDB, warm&lt;/th&gt;
&lt;th&gt;SpiceDB, cold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;25.3 ms&lt;/td&gt;
&lt;td&gt;29.9 ms&lt;/td&gt;
&lt;td&gt;691 ms&lt;/td&gt;
&lt;td&gt;3,279 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Member&lt;/td&gt;
&lt;td&gt;1,020&lt;/td&gt;
&lt;td&gt;4.4 ms&lt;/td&gt;
&lt;td&gt;31.6 ms&lt;/td&gt;
&lt;td&gt;77 ms&lt;/td&gt;
&lt;td&gt;404 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contractor&lt;/td&gt;
&lt;td&gt;510&lt;/td&gt;
&lt;td&gt;2.4 ms&lt;/td&gt;
&lt;td&gt;9.6 ms&lt;/td&gt;
&lt;td&gt;41 ms&lt;/td&gt;
&lt;td&gt;247 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The SQL and Cerbos columns are medians of the run medians from &lt;code&gt;results/final/list-*.json&lt;/code&gt;. The SpiceDB columns come from a separate cache experiment, &lt;code&gt;harness/spicedb-list-cache.js&lt;/code&gt;, which separates the two cases on purpose, because SpiceDB's list time depends on its cache more than on anything else. It differs from our adapter's list call in two ways: it makes one LookupResources call with no limit instead of pages of 1,000, and it reads with &lt;code&gt;minimize_latency&lt;/code&gt; and then &lt;code&gt;at_exact_snapshot&lt;/code&gt; instead of &lt;code&gt;at_least_as_fresh&lt;/code&gt;. Timed through the adapter itself, the member's list had a median of 75 ms and the admin's 803 ms, mostly warm calls; see below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SpiceDB LookupResources, step 4, 10,000 documents&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ nice -n 19 node harness/spicedb-list-cache.js --tree=.steps/step4 --out=results/final/spicedb-list-cache.json
admin visible 10000 first in a new 5 s window: median 3278.544 ms same revision again: median 691.44 ms
member visible 1020 first in a new 5 s window: median 403.896 ms same revision again: median 77.2 ms
contractor visible 510 first in a new 5 s window: median 246.668 ms same revision again: median 41.139 ms
problems (wrong answers or a reused revision): 0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SpiceDB caches results per revision. By default it reads at a revision rounded to a 5-second window (&lt;code&gt;--datastore-revision-quantization-interval&lt;/code&gt;), and the in-memory datastore's revisions are timestamps, so every 5 seconds the read revision moves and the cache starts cold, even with no writes. Which case a request gets depends on timing. In our runs of 20 back-to-back admin lists (&lt;code&gt;results/final/list-*.json&lt;/code&gt;), most found a warm cache, with a median of about 800 ms, and the slowest took about 3.7 s.&lt;/p&gt;

&lt;p&gt;Four more things we measured about lists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Our Cerbos adapter was the slow part, not Cerbos.&lt;/strong&gt; The committed adapter turns "this user is in the document's &lt;code&gt;viewers&lt;/code&gt;" into a correlated &lt;code&gt;EXISTS&lt;/code&gt;, which SQLite runs once per document. Changing those two adapter lines to an uncorrelated &lt;code&gt;d.id IN (SELECT document_id FROM shares WHERE ...)&lt;/code&gt; cut the member list from 31.6 ms to 11.9 ms, with the same answers (&lt;code&gt;scripts/variant.js&lt;/code&gt;). The plan is only as fast as the SQL you turn it into, and that SQL is yours to write. Cerbos publishes &lt;a href="https://docs.cerbos.dev/cerbos/latest/api/" rel="noopener noreferrer"&gt;reference adapters&lt;/a&gt; for Prisma, Drizzle, SQLAlchemy and others; we used none, because we used plain SQLite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page size matters for LookupResources.&lt;/strong&gt; Our list code asks LookupResources for pages of 1,000, the default of SpiceDB's &lt;code&gt;--max-lookup-resources-limit&lt;/code&gt; flag, and follows the cursor. For the admin that took 0.8 seconds over 11 calls. Pages of 100 took 7.2 seconds over 106 calls, and a single call with no limit took 0.7 seconds (&lt;code&gt;results/final/lookup-pages.json&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB's docs already say this.&lt;/strong&gt; Its guide to &lt;a href="https://authzed.com/docs/spicedb/modeling/protecting-a-list-endpoint" rel="noopener noreferrer"&gt;protecting a list endpoint&lt;/a&gt; suggests LookupResources when a user can see fewer than about 10,000 resources, and otherwise fetching a page of candidates from your own database and filtering them with CheckBulkPermissions. We timed that for "the first 50 documents in ID order", against our adapter listing every visible ID with LookupResources, sorting them and keeping 50 (&lt;code&gt;harness/spicedb-page.js&lt;/code&gt;). For the admin the bulk checks took 18 ms with one call, against 786 ms for the full LookupResources list. For the contractor, who can see 5% of the documents, it took 167 ms over 11 bulk calls, against 37 ms. So the right pattern depends on how much of the data each user can see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The late rule made SpiceDB's cold lists about 3.5 times slower.&lt;/strong&gt; We ran the cold and warm list test against the code of every step (&lt;code&gt;results/steps/step&amp;lt;N&amp;gt;-spicedb-list-cache.json&lt;/code&gt;). From step 3 to step 4, the admin's cold list went from 928 to 3,204 ms, the member's from 108 to 390 ms and the contractor's from 64 to 227 ms. The warm lists did not move (686 to 696, 73 to 70, 42 to 38 ms). Thirteen lines of schema and sync were cheap to write; the exclusion they added is not cheap to evaluate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the table caught
&lt;/h2&gt;

&lt;p&gt;The 84-row table was the most useful file in the repo. It caught two things we would otherwise have shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SpiceDB returns duplicates.&lt;/strong&gt; At step 2, Kai, an admin who is also a member of &lt;code&gt;sales&lt;/code&gt;, got &lt;code&gt;d3&lt;/code&gt; and &lt;code&gt;d4&lt;/code&gt; twice from our adapter's paged LookupResources calls: once through the admin path and once through the team path. This is expected behaviour: an &lt;a href="https://github.com/authzed/spicedb/issues/3190" rel="noopener noreferrer"&gt;issue in the SpiceDB repository&lt;/a&gt; explains that two paths to the same resource can produce two results, calls duplicates a known limitation of cursored calls, and recommends de-duplicating on the client. We added a &lt;code&gt;Set&lt;/code&gt; and recorded the failing run in &lt;code&gt;results/caught/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every design fails open when part of the late rule is missing.&lt;/strong&gt; &lt;code&gt;scripts/break.js&lt;/code&gt; leaves out one part of the step 4 change in each design and runs the table again:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;leave out part of the late rule&lt;/strong&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="nv"&gt;$ &lt;/span&gt;node scripts/break.js &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; results/breaks.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^ '&lt;/span&gt; results/breaks.txt
ifs-forgot-the-list-query mismatches 2 &lt;span class="o"&gt;(&lt;/span&gt;list&lt;span class="o"&gt;)&lt;/span&gt; caught
cerbos-forgot-the-attribute mismatches 10 &lt;span class="o"&gt;(&lt;/span&gt;check, list&lt;span class="o"&gt;)&lt;/span&gt; caught
spicedb-forgot-the-sync mismatches 10 &lt;span class="o"&gt;(&lt;/span&gt;check, list&lt;span class="o"&gt;)&lt;/span&gt; caught

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;if-statements:&lt;/strong&gt; &lt;code&gt;check()&lt;/code&gt; has the rule, but &lt;code&gt;list()&lt;/code&gt; kept its step 3 query. Every check passes, and Cora's list still shows the &lt;code&gt;sales&lt;/code&gt; document shared with her.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos:&lt;/strong&gt; the policy has the deny rule, but the app does not send &lt;code&gt;contractor&lt;/code&gt;. The condition cannot match a missing attribute, so the deny never fires. Cerbos can &lt;a href="https://docs.cerbos.dev/cerbos/latest/policies/schemas" rel="noopener noreferrer"&gt;validate attributes&lt;/a&gt; against a JSON schema; with &lt;code&gt;reject&lt;/code&gt; enforcement, its docs say every action of an invalid request is denied. With &lt;code&gt;contractor&lt;/code&gt; marked as required, that would turn this failure from open to closed. We did not test it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SpiceDB:&lt;/strong&gt; the schema has the exclusion, but &lt;code&gt;sync.js&lt;/code&gt; does not write &lt;code&gt;org#contractor&lt;/code&gt;. With no relationship there is nothing to exclude.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the three designs made these omissions safe. The table of expected decisions caught all three, which is why we would run it against every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The costs this benchmark leaves out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeping SpiceDB in sync.&lt;/strong&gt; &lt;code&gt;sync.js&lt;/code&gt; copies the whole database at start. A real app must write every change, such as a new share or a new contractor, to both its database and SpiceDB, and a crash between the two writes leaves them disagreeing. AuthZed calls this &lt;a href="https://dev.to/authzd/the-dual-write-problem-l33-temp-slug-1665033"&gt;the dual-write problem&lt;/a&gt; and lists ways to handle it, from a transactional outbox to making SpiceDB the source of truth. Cerbos and the if-statements read the app's data at request time, so they do not have this problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running a server.&lt;/strong&gt; Both engines are another service to deploy, monitor, upgrade and keep available, or a hosted product to pay for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real datastore.&lt;/strong&gt; SpiceDB on Postgres or CockroachDB will have different numbers from the in-memory datastore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetching the rows.&lt;/strong&gt; SpiceDB's list returns IDs. A list page then needs a second query for titles and dates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When the if-statements are the right answer
&lt;/h2&gt;

&lt;p&gt;In this repo the if-statements were the fastest design on every timing we took for all three, and the smallest in total code. They are the right answer more often than vendor pages suggest. Keep them when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One application owns the rules.&lt;/strong&gt; Everything lives in one codebase and one database, and one team changes both. A policy engine earns its keep when several services must answer the same question the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your rules are mostly filters on your own tables.&lt;/strong&gt; Teams, owners and shares are joins. SQL already plans them, and your list endpoint keeps pagination, sorting and search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can keep the checks in one module.&lt;/strong&gt; Our &lt;code&gt;ifs&lt;/code&gt; design is one file with one &lt;code&gt;check()&lt;/code&gt; and one &lt;code&gt;list()&lt;/code&gt;. That is the best case. If your checks are spread over fifty handlers, the late rule is fifty edits, and our line counts do not apply to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You test the rules as data.&lt;/strong&gt; The expected-decisions table is what made each step safe, and it works for if-statements as well as for policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The if-statements stop being enough when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More than one service or language needs the same answer.&lt;/strong&gt; Copying the rules into each one is how they drift apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People outside the team need to read or change the rules,&lt;/strong&gt; for example a security review of what contractors can see. A separate policy file or schema may be easier for them to review than a SQL &lt;code&gt;UNION&lt;/code&gt; inside a JavaScript function, depending on what they already read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a record of decisions.&lt;/strong&gt; Cerbos can write &lt;a href="https://docs.cerbos.dev/cerbos/latest/configuration/audit" rel="noopener noreferrer"&gt;decision logs&lt;/a&gt; (off by default, and off in our runs). Homemade code has none unless you build it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The relationships nest,&lt;/strong&gt; such as folders inside folders or groups inside groups. Walking a graph is what SpiceDB is built for, and it is awkward to write and to index in SQL. Our model has no nesting, so this test does not cover it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hosted options
&lt;/h2&gt;

&lt;p&gt;Several companies offer managed authorization services or control planes, some built on these engines and some on others. Some run the decision service for you; others manage policies while evaluation stays in your environment. We did not test any of them, so this is only what their own documentation says they offer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cerbos Hub&lt;/strong&gt; is a control plane for the open source Cerbos PDP. Per its &lt;a href="https://docs.cerbos.dev/cerbos-hub/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;, it tests, signs and distributes policy bundles to your PDPs, which keep running in your own network, and it offers an embedded PDP compiled to WebAssembly for evaluation in browsers, edge functions and other JavaScript environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AuthZed&lt;/strong&gt; sells hosted SpiceDB. Per its &lt;a href="https://authzed.com/docs/authzed/guides/picking-a-product" rel="noopener noreferrer"&gt;docs&lt;/a&gt;, AuthZed Cloud is a self-service platform with usage-based pricing, and AuthZed Dedicated is a private deployment in the cloud provider and regions you choose. For lists too large for LookupResources, its &lt;a href="https://authzed.com/docs/spicedb/modeling/protecting-a-list-endpoint" rel="noopener noreferrer"&gt;list guide&lt;/a&gt; describes AuthZed Materialize: its Event Streams feature, in early access for Dedicated customers at the time of writing, lets your service keep a local table of permissions so that filtering becomes a JOIN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oso Cloud&lt;/strong&gt; is Oso's authorization service, with policies written in the Polar language. Its &lt;a href="https://www.osohq.com/docs/authorization-data/local-authorization" rel="noopener noreferrer"&gt;local authorization&lt;/a&gt; mode returns SQL, including conditions that filter a list query, which your application runs against its own PostgreSQL or MySQL database; Oso does not read that data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permit.io&lt;/strong&gt; offers a managed cloud PDP, or a PDP container that you run next to your services and that evaluates policies with OPA or Cedar, per its &lt;a href="https://docs.permit.io/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;. It lists RBAC, ABAC and ReBAC models, and bulk checks for lists and tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WorkOS FGA&lt;/strong&gt; extends WorkOS RBAC with roles scoped to resources in a parent-child hierarchy, per its &lt;a href="https://workos.com/docs/fga" rel="noopener noreferrer"&gt;docs&lt;/a&gt;. Your app registers resources with WorkOS, and the API checks access and lists the resources a user can access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade we measured carries over to each of them: wherever the rule lives, it has to meet the data it needs. Either the data travels with every request, or someone keeps a copy in sync, or, as Oso's local authorization does, the rule becomes a query that runs where the data already is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production latency.&lt;/strong&gt; Everything ran on one Raspberry Pi 4, at concurrency 1, with the app database in the same process. Neither the milliseconds nor the ratios predict a production system; they show how the designs differ under the same conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tuned servers.&lt;/strong&gt; We did not tune either engine. Both ran with their defaults, except that we turned off telemetry and SpiceDB's metrics endpoint and set SpiceDB's log level to &lt;code&gt;warn&lt;/code&gt;. Cerbos, at its default &lt;code&gt;info&lt;/code&gt; level, writes two log lines per request; we did not measure what that costs it. Everything, servers included, ran at the lowest CPU priority (&lt;code&gt;nice -n 19&lt;/code&gt;) on a Pi shared with other work, and each timed run first waited for a quiet machine: a 1-minute load average under 1.25 for the step 4 runs and under 1.5 for the per-step runs, giving up after 10 and 15 minutes. Each result file records the load average it started at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput.&lt;/strong&gt; We measured one request at a time. We did not measure how many checks per second each design sustains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep hierarchies.&lt;/strong&gt; Our model has no nested folders or groups, which is where a relationship graph should do best and recursive SQL is hardest. This test does not cover it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the exclusion costs SpiceDB so much on a cold list.&lt;/strong&gt; We measured that it does, not where the time goes inside SpiceDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cause of the one failed SpiceDB call.&lt;/strong&gt; The 30-second connection age is our best explanation; we did not confirm it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;We set out to show that a single check is cheap in all three designs and that listing and the late rule separate them. By the rules we wrote in &lt;code&gt;CLAIM.md&lt;/code&gt; before measuring, that claim is wrong, because its first half failed. A single check is cheap only in-process: through either engine it cost a network call, and on this Pi that call alone was about 2.4 ms. The second half held. For the member, a Cerbos plan turned into SQL took 2.7 to 7 times as long as the hand-written SQL, and SpiceDB's LookupResources 18 to 92 times as long, warm to cold. The cache, the page size and our own SQL adapter moved those numbers as much as the engines did. The late rule cost every design 13 to 22 lines and still needed a change in the application, because every rule needs data and the data lives in your app.&lt;/p&gt;

&lt;p&gt;If one app owns the rules, keep them in code, keep them in one module, and test them with a table like &lt;code&gt;fixture/expected.csv&lt;/code&gt;. When you do move them out, plan the data path first: what each request must carry for Cerbos, what must be kept in sync for SpiceDB, and how your list pages will get their answers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/authorization-if-statements-vs-cerbos-vs-spicedb" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>authorization</category>
      <category>security</category>
      <category>architecture</category>
      <category>cerbos</category>
    </item>
    <item>
      <title>LiteLLM 1.107.0 Was Built From a Lookalike Fork: What PyPI Provenance Can and Cannot Tell You</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/litellm-11070-was-built-from-a-lookalike-fork-what-pypi-provenance-can-and-cannot-tell-you-5eme</link>
      <guid>https://dev.to/devopsdaily/litellm-11070-was-built-from-a-lookalike-fork-what-pypi-provenance-can-and-cannot-tell-you-5eme</guid>
      <description>&lt;p&gt;On October 21, 2026, &lt;code&gt;litellm==1.107.0&lt;/code&gt; was on PyPI for about 1 hour 43 minutes, with a credential stealer and a remote-code loader in all seven wheels. Reading &lt;code&gt;github.com/BerriAI/litellm&lt;/code&gt; would not have shown you anything: LiteLLM's own release pipeline built the release from a lookalike fork, &lt;code&gt;Berri-AI/litellm&lt;/code&gt;, one hyphen away from the real name.&lt;/p&gt;

&lt;p&gt;So we asked a narrower question: which record could have told you where the artifact came from? We checked PyPI, the container registries and the 1,000 most downloaded PyPI projects. The short answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Docker images' build provenance&lt;/strong&gt; named the fork. It was there to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI had nothing to check.&lt;/strong&gt; litellm published PEP 740 attestations on 15 releases in March and April 2026 and on none of the 214 releases since. Those 15 name the private release repository, &lt;code&gt;BerriAI/project-releaser&lt;/code&gt;, not the source repository. That is the repository whose release workflows the attacker ran, so an attestation on 1.107.0 would most likely have named the same publisher as every legitimate release.&lt;/li&gt;
&lt;li&gt;Across the 1,000 most downloaded PyPI projects, &lt;strong&gt;39.0% of latest releases carry an attestation&lt;/strong&gt; , and &lt;strong&gt;33.4%&lt;/strong&gt; carry one from the repository their own metadata names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post covers what happened, how to check your environments, lock files, caches and images, what to rotate, the provenance gap with the numbers, and what you can enforce today. Every number we measured comes from a file in the companion repo. Incident facts, such as the timeline, come from the sources we link, and the repo's &lt;code&gt;SOURCES.md&lt;/code&gt; lists each one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/pypi-provenance-check" rel="noopener noreferrer"&gt;The-DevOps-Daily/pypi-provenance-check on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What shipped:&lt;/strong&gt; 7 wheels (&lt;code&gt;cp310-abi3&lt;/code&gt;, Linux, macOS, Windows) with &lt;code&gt;cook.pth&lt;/code&gt;, &lt;code&gt;cook.py&lt;/code&gt; and &lt;code&gt;sitecustomize.py&lt;/code&gt;, plus container images under &lt;code&gt;v1.107.0&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;main-latest&lt;/code&gt; and &lt;code&gt;main-stable&lt;/code&gt; on GHCR and Docker Hub. Clean versions: 1.106.2, 1.107.0rc1 and 1.107.0rc2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check without starting the suspect Python.&lt;/strong&gt; The payload runs whenever an interpreter starts with that site-packages on its path, and that includes &lt;code&gt;pip&lt;/code&gt;. Our checker runs under &lt;code&gt;python3 -I -S&lt;/code&gt; and reads files only. It finds the payload in venvs, lock files, pip and uv caches and image tarballs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate&lt;/strong&gt; everything the host could reach: cloud role credentials, every provider key stored in the LiteLLM proxy database, the master and virtual keys, &lt;code&gt;DATABASE_URL&lt;/code&gt;, CI and registry tokens, SSH and TLS keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provenance gap:&lt;/strong&gt; a PyPI publish attestation names the workflow that uploaded the file, not the source it built. &lt;code&gt;pypi-attestations verify&lt;/code&gt; against &lt;code&gt;BerriAI/litellm&lt;/code&gt; fails even on a legitimate litellm release. In the files we sampled from the 396 attested projects in our survey, we found no SLSA provenance attestation, the PyPI type that can record a source repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;23 projects stopped attesting.&lt;/strong&gt; They attested an earlier release and not their latest one. Today, 12 of them upload with &lt;code&gt;uv publish&lt;/code&gt;, which uploads attestations but does not create them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What works today:&lt;/strong&gt; a release cooldown (&lt;code&gt;exclude-newer = "7 days"&lt;/code&gt; in uv, &lt;code&gt;--uploaded-prior-to P7D&lt;/code&gt; in pip 26.1+), hash-locked installs, image digests plus a provenance source check, and publisher pins for packages that attest. As of October 2026, neither pip nor uv verifies attestations at install time.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Python 3.11 or later on the hosts you want to check (the scripts use only the standard library)&lt;/li&gt;
&lt;li&gt;Read access to your virtual environments, lock files, pip and uv caches, CI logs and container images&lt;/li&gt;
&lt;li&gt;Optional: &lt;code&gt;uv&lt;/code&gt;, to run the &lt;code&gt;pypi-attestations&lt;/code&gt; verifier, and &lt;code&gt;docker&lt;/code&gt; or &lt;code&gt;podman&lt;/code&gt;, to export images for scanning&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;LiteLLM is a Python SDK and proxy that puts many LLM providers behind one OpenAI-style API. The proxy usually stores every upstream provider key in its database, which made it a valuable target. The timeline below comes from the maintainers' &lt;a href="https://docs.litellm.ai/blog/security-incident-oct-21-2026" rel="noopener noreferrer"&gt;incident post&lt;/a&gt; and their &lt;a href="https://docs.litellm.ai/blog/security-incident-oct-21-2026-attack-chain" rel="noopener noreferrer"&gt;attack-chain post&lt;/a&gt;, which they call the canonical account.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC, Oct 21, 2026)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10:08&lt;/td&gt;
&lt;td&gt;Branch protection on the private release repo &lt;code&gt;BerriAI/project-releaser&lt;/code&gt; rejects commit &lt;code&gt;9cf97e9&lt;/code&gt; ("ci: release from a fork source repository"). A stolen token of a GitHub organization owner disables the protection and force-pushes it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10:46:39&lt;/td&gt;
&lt;td&gt;A staging run starts with source &lt;code&gt;Berri-AI/litellm&lt;/code&gt; at commit &lt;code&gt;20535d8f&lt;/code&gt;, which "exists only on the attacker's fork".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:11:18&lt;/td&gt;
&lt;td&gt;A second stolen maintainer token approves the production gate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:39:51 to 11:40:13&lt;/td&gt;
&lt;td&gt;Seven &lt;code&gt;litellm==1.107.0&lt;/code&gt; wheels are uploaded to PyPI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:46:56&lt;/td&gt;
&lt;td&gt;Images are pushed under &lt;code&gt;v1.107.0&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;main-latest&lt;/code&gt; and &lt;code&gt;main-stable&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:53:49 to 11:54:01&lt;/td&gt;
&lt;td&gt;A third stolen token deletes the workflow run records.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12:06:41&lt;/td&gt;
&lt;td&gt;Upwind opens &lt;a href="https://github.com/BerriAI/litellm/issues/47826" rel="noopener noreferrer"&gt;issue #47826&lt;/a&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13:23&lt;/td&gt;
&lt;td&gt;PyPI removes 1.107.0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13:51&lt;/td&gt;
&lt;td&gt;The GHCR tags are gone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14:20&lt;/td&gt;
&lt;td&gt;The Docker Hub tags are removed or repointed to clean images.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The way in was ordinary. On May 27, a config file with the master key of the LiteLLM Agent Platform was committed to a public repository. The commit was force-pushed over, but the key was not revoked. On October 10, the attacker used it to read a GitHub CLI OAuth token that belonged to an organization owner. More maintainer tokens were taken from repository secrets the same day. A maintainer summed it up in the issue as "use of insufficiently scoped keys combined with failure to revoke after accidental exposure."&lt;/p&gt;

&lt;p&gt;The payload sits in &lt;code&gt;litellm-1.107.0.data/purelib/&lt;/code&gt;, so the installer puts it in the root of site-packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cook.pth&lt;/code&gt; contains &lt;code&gt;import cook&lt;/code&gt;, so &lt;code&gt;cook.py&lt;/code&gt; runs every time that interpreter starts. We covered this startup trick in detail in our post on the &lt;a href="https://devops-daily.com/posts/shai-hulud-hades-pypi-wave-june-2026" rel="noopener noreferrer"&gt;Shai-Hulud Hades wave on PyPI&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cook.py&lt;/code&gt; is a stealer that calls itself &lt;code&gt;privcollect&lt;/code&gt;. It encrypts what it collects and POSTs it to &lt;code&gt;http://www.litellm.dev/healthcheck&lt;/code&gt;, a lookalike domain registered on October 13.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sitecustomize.py&lt;/code&gt; forks a daemon that holds &lt;code&gt;/tmp/remote_code_loader.lock&lt;/code&gt;. Every hour, it reads a domain from &lt;code&gt;raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt&lt;/code&gt; and runs the code at &lt;code&gt;http://&amp;lt;domain&amp;gt;/codepy&lt;/code&gt;. The issue reports that the file has been empty since October 20, so this stage was dormant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Early reports said the &lt;code&gt;v1.107.0&lt;/code&gt; git tag was clean. The maintainers later wrote that "there is no official v.1.107.0 source tag". The tag list we pulled from &lt;code&gt;BerriAI/litellm&lt;/code&gt; on October 26 has &lt;code&gt;v1.107.0-dev.1&lt;/code&gt; to &lt;code&gt;-dev.3&lt;/code&gt; and &lt;code&gt;v1.107.0-rc.1&lt;/code&gt; and &lt;code&gt;-rc.2&lt;/code&gt;, and no &lt;code&gt;v1.107.0&lt;/code&gt;. Either way, the malicious lines were never in &lt;code&gt;BerriAI/litellm&lt;/code&gt;. The lookalike repository now returns 404.&lt;/p&gt;

&lt;p&gt;This is the second LiteLLM poisoning this year. In March, versions 1.82.7 and 1.82.8 were published with a stolen PyPI token, which &lt;a href="https://www.chainguard.dev/unchained/litellm-attacked-again-malicious-1-107-0-wheels-on-pypi-and-docker-hub-october-2026" rel="noopener noreferrer"&gt;Chainguard&lt;/a&gt; attributes to TeamPCP, the group in our &lt;a href="https://devops-daily.com/posts/mini-shai-hulud-pytorch-lightning-supply-chain-attack" rel="noopener noreferrer"&gt;PyTorch Lightning post&lt;/a&gt;. There is no evidence that links the two incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check if you were hit
&lt;/h2&gt;

&lt;p&gt;You are exposed if any of these happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;pip install&lt;/code&gt; or &lt;code&gt;uv&lt;/code&gt; resolve picked &lt;code&gt;litellm==1.107.0&lt;/code&gt; between 11:39:51 and 13:23 UTC. This includes unpinned &lt;code&gt;litellm&lt;/code&gt; and &lt;code&gt;litellm[proxy]&lt;/code&gt; requirements, and lock files that were refreshed in that window.&lt;/li&gt;
&lt;li&gt;You ran an image pulled by one of the four tags between 11:46:56 and 13:51 UTC (GHCR) or 14:20 UTC (Docker Hub), or you built an image &lt;code&gt;FROM&lt;/code&gt; one of those tags in that window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pulling or unpacking an image does not start Python, so it does not run the payload. Any start of the image's Python with normal site initialization does: a container whose entrypoint runs it, a &lt;code&gt;RUN&lt;/code&gt; step in a derived build that runs it (&lt;code&gt;pip install&lt;/code&gt; counts), or &lt;code&gt;docker exec ... python&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most important rule comes from the issue: &lt;strong&gt;do not start with &lt;code&gt;pip install&lt;/code&gt;.&lt;/strong&gt; Any Python process that starts in the affected environment runs &lt;code&gt;cook.pth&lt;/code&gt; and &lt;code&gt;sitecustomize.py&lt;/code&gt; again, and pip is a Python process.&lt;/p&gt;

&lt;h3&gt;
  
  
  With nothing but a shell
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The payload files in any site-packages or dist-packages on this host (all mounted filesystems)&lt;/span&gt;
find / &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; /proc &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; /sys &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/site-packages/*'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/dist-packages/*'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'cook.pth'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'cook.py'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'litellm-1.107.0.dist-info'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Host markers. The first one is written only after a successful exfiltration POST.&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock /tmp/.pc_sq_&lt;span class="k"&gt;*&lt;/span&gt; /tmp/.pc_e_&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Literal 1.107.0 pins in requirements-style files (skips rc, dev, post and local versions)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rniE&lt;/span&gt; &lt;span class="s1"&gt;'^[[:space:]]*litellm(\[[^]]*\])?[[:space:]]*===?[[:space:]]*1\.107\.0([[:space:];,#\\]|$)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.txt'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.in'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.lock'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing marker file proves nothing. Containers and &lt;code&gt;/tmp&lt;/code&gt; cleanup remove it. The &lt;code&gt;find&lt;/code&gt; runs without &lt;code&gt;-xdev&lt;/code&gt; so it covers separately mounted &lt;code&gt;/home&lt;/code&gt;, &lt;code&gt;/srv&lt;/code&gt; and volume paths; errors are hidden, so run it as root. The &lt;code&gt;grep&lt;/code&gt; only sees the literal spelling &lt;code&gt;1.107.0&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  With the checker
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ioc_check.py&lt;/code&gt; is one standard-library file. Run it with &lt;code&gt;-I -S&lt;/code&gt; so that the interpreter running it does not process &lt;code&gt;.pth&lt;/code&gt; files or import &lt;code&gt;sitecustomize&lt;/code&gt; from its own site-packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/The-DevOps-Daily/pypi-provenance-check
&lt;span class="nb"&gt;cd &lt;/span&gt;pypi-provenance-check

python3 &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; ioc_check.py &lt;span class="c"&gt;# envs, pip/uv caches, host markers&lt;/span&gt;
python3 &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; ioc_check.py lock requirements.txt uv.lock pylock.toml
docker save litellm/litellm:main-stable &lt;span class="nt"&gt;-o&lt;/span&gt; img.tar &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; ioc_check.py scan img.tar

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no arguments, it searches &lt;code&gt;/usr/lib&lt;/code&gt;, &lt;code&gt;/usr/local/lib&lt;/code&gt;, &lt;code&gt;/opt&lt;/code&gt;, &lt;code&gt;/srv&lt;/code&gt;, &lt;code&gt;/app&lt;/code&gt; and your home directory for site-packages, scans the pip, uv, Poetry and PDM caches, and checks the host markers. On Linux it also looks for processes with &lt;code&gt;PRIVCOLLECT_ACTIVE=1&lt;/code&gt; in their environment or holding the loader's lock file. Run it as root. As a normal user, a directory, file or archive it cannot read is reported as incomplete, so a run that finds nothing else exits 4 instead of 0. Processes it cannot inspect are only listed as a note.&lt;/p&gt;

&lt;p&gt;We did not download or run the real wheel. The tests use a harmless stand-in built at test time: its &lt;code&gt;cook.py&lt;/code&gt; and &lt;code&gt;sitecustomize.py&lt;/code&gt; are comments, and its &lt;code&gt;cook.pth&lt;/code&gt; is &lt;code&gt;import cook&lt;/code&gt;. That 12-byte file has the same sha256 as the real &lt;code&gt;cook.pth&lt;/code&gt; from the wheel's RECORD, which confirms the published hash. Here is the checker on a venv with the stand-in installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -I -S ioc_check.py env fx/infected-venv
HIT installed fx/infected-venv/lib/python3.12/site-packages/litellm-1.107.0.dist-info
                           litellm 1.107.0 is installed
HIT payload-file fx/infected-venv/lib/python3.12/site-packages/cook.pth
                           cook.pth in site-packages root (sha256 matches the published payload); owner: litellm-1.107.0
HIT payload-file fx/infected-venv/lib/python3.12/site-packages/cook.py
                           cook.py in site-packages root; owner: litellm-1.107.0
HIT payload-file fx/infected-venv/lib/python3.12/site-packages/sitecustomize.py
                           sitecustomize.py installed by litellm-1.107.0
INFO searched fx/infected-venv
                           1 site-packages directories checked

INDICATORS FOUND: treat this host as compromised and rotate what it could reach (exit 1)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit codes: &lt;code&gt;0&lt;/code&gt; nothing found, &lt;code&gt;1&lt;/code&gt; an indicator of the payload, &lt;code&gt;3&lt;/code&gt; something suspicious that is not the payload, &lt;code&gt;4&lt;/code&gt; incomplete, because a path, directory or archive could not be read. The &lt;code&gt;lock&lt;/code&gt; check also catches equivalent spellings of the version, such as &lt;code&gt;==1.107&lt;/code&gt; and &lt;code&gt;==1.107.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;sitecustomize.py&lt;/code&gt; or a &lt;code&gt;.pth&lt;/code&gt; file with &lt;code&gt;import&lt;/code&gt; lines is suspicious only when nothing owns it, so the checker reads every &lt;code&gt;RECORD&lt;/code&gt; in the site-packages directory. That was not enough on our Debian 12 based test machine: &lt;code&gt;/usr/lib/python3/dist-packages&lt;/code&gt; holds &lt;code&gt;distutils-precedence.pth&lt;/code&gt; and a &lt;code&gt;logilab_common&lt;/code&gt; namespace file, both with &lt;code&gt;import&lt;/code&gt; lines, and no &lt;code&gt;RECORD&lt;/code&gt; lists them, because Debian packages installed them. The checker also reads dpkg's file lists, so it reports them as installed by &lt;code&gt;python3-setuptools&lt;/code&gt; and &lt;code&gt;python3-logilab-common&lt;/code&gt; and exits 0 (&lt;code&gt;examples/debian-dist-packages.txt&lt;/code&gt;). Without that, any Debian or Ubuntu host with such packages installed would get exit 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  pip and uv caches
&lt;/h3&gt;

&lt;p&gt;A cache can hold the wheel long after the venv is gone, and a CI cache restores it into the next job. We put the stand-in wheel through uv 0.13.0, pip 23.0.1 and pip 26.2.2 (served from a local TLS server, because pip only caches responses from secure origins) and recorded what each one left behind:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it left behind (path in the cache)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;uv 0.13.0&lt;/td&gt;
&lt;td&gt;the unpacked wheel, &lt;code&gt;archive-v0/&amp;lt;id&amp;gt;/litellm-1.107.0.data/purelib/cook.pth&lt;/code&gt;, and an index entry, &lt;code&gt;wheels-v7/index/&amp;lt;hash&amp;gt;/litellm/1.107.0-py3-none-any&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pip 23.0.1&lt;/td&gt;
&lt;td&gt;one file under &lt;code&gt;http/&lt;/code&gt;, the HTTP response with the wheel inside it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pip 26.2.2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;http-v2/.../&amp;lt;hash&amp;gt;.body&lt;/code&gt;, the raw wheel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;scan&lt;/code&gt; found the payload in all three caches (&lt;code&gt;examples/cache-layout-demo.txt&lt;/code&gt; in the repo). Wheel member names are stored uncompressed in a zip file, so the checker also searches raw bytes for &lt;code&gt;litellm-1.107.0.data/purelib/cook&lt;/code&gt;, which catches the older pip format. pip 26's &lt;code&gt;.body&lt;/code&gt; file is the wheel byte for byte (same sha256 in our run), so on a real cache the checker also matches it against the seven published wheel hashes.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI runners
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosted runners&lt;/strong&gt; are gone after the job. What remains is the job log. Search the logs from October 21 for &lt;code&gt;litellm-1.107.0&lt;/code&gt; (pip prints &lt;code&gt;Downloading litellm-1.107.0-...&lt;/code&gt; and &lt;code&gt;Successfully installed ... litellm-1.107.0&lt;/code&gt;) and &lt;code&gt;litellm==1.107.0&lt;/code&gt; (uv prints &lt;code&gt;+ litellm==1.107.0&lt;/code&gt;). We checked these lines with pip 23.0.1, pip 26.2.2 and uv 0.13.0 on the stand-in wheel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restored dependency caches&lt;/strong&gt; can carry the wheel into later jobs. Scan the cache directory, or delete the cache entries created that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent and self-hosted runners&lt;/strong&gt; are hosts. Run the full check on them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Images
&lt;/h3&gt;

&lt;p&gt;Scan a saved image: &lt;code&gt;docker save&lt;/code&gt; or &lt;code&gt;podman save&lt;/code&gt; output, an OCI archive, or a flat &lt;code&gt;docker export&lt;/code&gt; tar of a container's filesystem. The checker reads gzip layers and reports zstd layers as unreadable. To compare digests, the platform manifests of the bad &lt;code&gt;litellm&lt;/code&gt; image are &lt;code&gt;sha256:bd1aeee4...&lt;/code&gt; (amd64) and &lt;code&gt;sha256:56b74573...&lt;/code&gt; (arm64), under index &lt;code&gt;sha256:1bbcb144...&lt;/code&gt;. The checker carries the full list, including the &lt;code&gt;-database&lt;/code&gt; and &lt;code&gt;-non_root&lt;/code&gt; variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotate what
&lt;/h2&gt;

&lt;p&gt;The stealer does not need the remote loader: it runs in a background thread as soon as the interpreter starts. The &lt;a href="https://github.com/BerriAI/litellm/issues/47826" rel="noopener noreferrer"&gt;issue&lt;/a&gt; lists what it collects. Rotate in roughly this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clean up first, from the shell.&lt;/strong&gt; Delete &lt;code&gt;cook.pth&lt;/code&gt;, &lt;code&gt;cook.py&lt;/code&gt;, &lt;code&gt;sitecustomize.py&lt;/code&gt; and the matching &lt;code&gt;__pycache__ /cook*.pyc&lt;/code&gt; and &lt;code&gt;sitecustomize*.pyc&lt;/code&gt; from every affected site-packages. Kill the detached Python daemon that holds &lt;code&gt;/tmp/remote_code_loader.lock&lt;/code&gt;. Then rebuild the venv or image from scratch with &lt;code&gt;litellm==1.106.2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud credentials.&lt;/strong&gt; The stealer reads AWS, Azure and gcloud files and instance metadata (AWS IMDSv2, ECS task credentials, GCP and Azure metadata), and validates AWS credentials with STS &lt;code&gt;GetCallerIdentity&lt;/code&gt;. Rotate the instance or task role, then search CloudTrail for &lt;code&gt;GetCallerIdentity&lt;/code&gt; from that role around install time and for use of its credentials from outside your network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The LiteLLM proxy itself.&lt;/strong&gt; If &lt;code&gt;DATABASE_URL&lt;/code&gt; is set, the stealer dumps &lt;code&gt;LiteLLM_CredentialsTable&lt;/code&gt; and &lt;code&gt;LiteLLM_ProxyModelTable&lt;/code&gt;, which hold every upstream provider key. Rotate all of them, the master key, the virtual keys and the database password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build and publish credentials.&lt;/strong&gt; Kubernetes service account tokens and kubeconfigs, &lt;code&gt;/run/secrets&lt;/code&gt;, CI secrets (GitLab Runner, Jenkins), Vault and Terraform credentials, &lt;code&gt;.git-credentials&lt;/code&gt;, &lt;code&gt;.npmrc&lt;/code&gt;, &lt;code&gt;.pypirc&lt;/code&gt;, Maven, Gradle, Cargo, RubyGems and Composer tokens, &lt;code&gt;.env&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys and the rest.&lt;/strong&gt; SSH, GPG and TLS private keys (including Let's Encrypt), Hugging Face tokens, AI coding tool configs (Codex, Gemini, Cursor, Windsurf, VS Code MCP settings and others), browser-saved passwords and cookies, and anything in the environment of any process on the host. The stealer reads &lt;code&gt;/proc/*/environ&lt;/code&gt; for every process it can.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If 1.107.0 ran on a host with outbound internet access, assume the exfiltration succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provenance gap
&lt;/h2&gt;

&lt;p&gt;The release passed a source review because it was not built from the reviewed source. The question is which record names the source an artifact was actually built from.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BerriAI/litellm&lt;/strong&gt; the repo you review&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Berri-AI/litellm&lt;/strong&gt; lookalike fork&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BerriAI/project-releaser&lt;/strong&gt; private release workflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI wheels&lt;/strong&gt; publish attestation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container images&lt;/strong&gt; BuildKit provenance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BerriAI/litellm -&amp;gt; BerriAI/project-releaser (normal release)&lt;/li&gt;
&lt;li&gt;Berri-AI/litellm -&amp;gt; BerriAI/project-releaser (Oct 21)&lt;/li&gt;
&lt;li&gt;BerriAI/project-releaser -&amp;gt; PyPI wheels (upload)&lt;/li&gt;
&lt;li&gt;BerriAI/project-releaser -&amp;gt; Container images (push)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What PyPI records
&lt;/h3&gt;

&lt;p&gt;A PEP 740 publish attestation is a Sigstore signature made with the identity of the CI workflow that uploads the file: repository, workflow file and environment. Trusted Publishing accepts an upload only from a publisher registered for the project on PyPI. PyPI then &lt;a href="https://github.com/pypi/warehouse/blob/main/warehouse/attestations/services.py" rel="noopener noreferrer"&gt;verifies each attestation&lt;/a&gt; against the identity of the publisher that authenticated the upload, and records that publisher in the file's provenance. So the attestation answers "which workflow uploaded this file". It does not say which source that workflow built.&lt;/p&gt;

&lt;p&gt;We pulled litellm's whole history from PyPI's Simple API and read the attestations through the &lt;a href="https://docs.pypi.org/api/integrity/" rel="noopener noreferrer"&gt;Integrity API&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;litellm on PyPI, checked 2026-10-26 23:17 UTC&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -I scripts/litellm_history.py
litellm: 956 releases on PyPI, checked 2026-10-26T23:17:57+00:00
  no attest. 727 releases 0.1.0 (2023-07-27) .. 1.82.6 (2026-03-22)
  attested 15 releases 1.83.0 (2026-03-31) .. 1.83.14 (2026-04-26)
  no attest. 214 releases 1.84.0.dev1 (2026-04-29) .. 1.106.4 (2026-10-25)
  publisher on 15 of 15 attested releases (1.83.0 .. 1.83.14): GitHub repository=BerriAI/project-releaser workflow=publish-litellm.yml environment=pypi-publish
  1.107.0: not on PyPI (removed on 21 Oct 2026)
wrote results/litellm-attestation-history-20261026T231757Z.json
# verify the last attested wheel against the repo litellm's metadata names, then against its publisher
$ pypi-attestations verify pypi --repository https://github.com/BerriAI/litellm pypi:litellm-1.83.14-py3-none-any.whl
Verification failed: provenance was signed by repository "BerriAI/project-releaser", expected "BerriAI/litellm"
$ pypi-attestations verify pypi --repository https://github.com/BerriAI/project-releaser pypi:litellm-1.83.14-py3-none-any.whl
OK: litellm-1.83.14-py3-none-any.whl

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things follow from that output.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;There was nothing to check on PyPI.&lt;/strong&gt; 1.107.0 is gone from PyPI now (the JSON and Integrity APIs return 404), so we cannot read its record directly. The reporters wrote that litellm "has never published attestations". That is not quite right, because 15 releases had them. But 1.106.2, which the same pipeline built and uploaded an hour before 1.107.0, has none, and neither do the 213 other releases since April 29. The attested run lasted four weeks. It began with 1.83.0 on March 31, after the March compromise, when the maintainers moved releases to the private repo, and ended with 1.83.14 on April 26.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check against the source repository fails on the good releases too.&lt;/strong&gt; The last legitimate attested wheel fails &lt;code&gt;--repository https://github.com/BerriAI/litellm&lt;/code&gt;, the repository that litellm's metadata names. A policy that compares the attestation with the metadata would have flagged every attested litellm release and taught everyone to ignore it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check against the real publisher would most likely have passed 1.107.0.&lt;/strong&gt; The maintainers write that the compromised workflow "signed and published the poisoned artifacts", and an upload through Trusted Publishing can only come from a publisher registered for the project. So an attestation on 1.107.0 would have named a registered publisher. The one on all 15 attested releases is &lt;code&gt;BerriAI/project-releaser&lt;/code&gt;, &lt;code&gt;publish-litellm.yml&lt;/code&gt;, &lt;code&gt;pypi-publish&lt;/code&gt;. If 1.107.0 went out through that identity, a check pinned to it would have passed. If it went out through another registered identity, a pin on the full identity would have flagged the change. A check on the repository alone would have flagged it only if that identity used a different repository. The signing certificate also records the release repository's commit, but that repository is private, so nobody outside could have told a good commit from a bad one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is an inference: we do not have the 1.107.0 upload record. But the publish attestation has no field for the repository that a workflow checked out. PyPI does support a second attestation type, &lt;a href="https://docs.pypi.org/attestations/" rel="noopener noreferrer"&gt;SLSA provenance&lt;/a&gt;, which can record a source. We come back to it in the survey.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the image recorded
&lt;/h3&gt;

&lt;p&gt;BuildKit, the builder behind &lt;code&gt;docker buildx&lt;/code&gt;, can attach a SLSA provenance document to an image. In it, &lt;code&gt;vcs:source&lt;/code&gt; is the git remote of the build context, so it names the repository that the build checked out. We read it for the clean v1.106.2 images from the registry API on October 26 at 23:08 UTC, without pulling a layer (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -I scripts/image_provenance.py ghcr.io/berriai/litellm:v1.106.2 ... ghcr.io/berriai/litellm:v1.107.0 ...
ghcr.io/berriai/litellm:v1.106.2 index sha256:7294b90f868c...
  linux/arm64 vcs:source https://github.com/BerriAI/litellm
               vcs:revision 49943cd52ea46aacf8db2e5cb2b890d580fe9bb4
               builder https://github.com/BerriAI/project-releaser/actions/runs/39574962801/attempts/1
               workflow BerriAI/project-releaser/.github/workflows/stage.yml@refs/heads/main (d687b69)
               inputs allow_republish=false, dry_run=false, git_ref=49943cd52ea46aacf8db2e5cb2b890d580fe9bb4, release_type=stable, repo=litellm
  ...
ghcr.io/berriai/litellm:v1.107.0 HTTP 404
ghcr.io/berriai/litellm@sha256:bd1aeee419ea36da9aa2b657288da4101887be468f020ac63d22ba6ed84a3ea5 HTTP 404

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit &lt;code&gt;49943cd&lt;/code&gt; is the v1.106.2 tag. The same source, revision and workflow appear on every v1.106.2 image we read: three variants on GHCR with two platforms each, and Docker Hub's &lt;code&gt;litellm/litellm:v1.106.2&lt;/code&gt;, which has the same index digest as GHCR's. The 1.107.0 tag and the bad platform digests return 404 on GHCR. The 1.107.0 images are deleted, so we could not read theirs. Upwind and &lt;a href="https://socket.dev/blog/litellm-compromise" rel="noopener noreferrer"&gt;Socket&lt;/a&gt; read them before removal and recorded &lt;code&gt;vcs:source https://github.com/Berri-AI/litellm&lt;/code&gt; at &lt;code&gt;20535d8f&lt;/code&gt;, with a dispatch input that set &lt;code&gt;source_owner&lt;/code&gt; to &lt;code&gt;Berri-AI&lt;/code&gt;. With Docker, &lt;code&gt;docker buildx imagetools inspect &amp;lt;image&amp;gt; --format '{{ json .Provenance.SLSA }}'&lt;/code&gt; shows the same document.&lt;/p&gt;

&lt;p&gt;This record named the fork, and the reason is a little uncomfortable. BuildKit writes &lt;code&gt;vcs:source&lt;/code&gt; inside the build, and the attacker controlled the build. Nothing outside the build vouches for the field: the maintainers say the same release workflow signed the images, and it signed the bad ones too. It named the fork because the build left the remote URL as it was. It is good evidence, but it is not a guarantee.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many PyPI projects publish an attestation at all
&lt;/h3&gt;

&lt;p&gt;A provenance check needs a record to read. We surveyed the latest release of the 1,000 most downloaded projects (30-day downloads to October 1, 2026, from &lt;a href="https://github.com/hugovk/top-pypi-packages" rel="noopener noreferrer"&gt;hugovk/top-pypi-packages&lt;/a&gt;, saved in the repo so the set is fixed) plus a list of 44 AI-stack projects. For each project, the survey records how many files of the latest release have provenance, and who published them. The run started on October 26 at 22:36 UTC: 1,022 projects, 2,925 requests, no errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latest PyPI releases with an attestation&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;repo match&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top 1-100&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;56%&lt;/td&gt;
&lt;td&gt;Top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top 101-500&lt;/td&gt;
&lt;td&gt;40.5%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;Top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top 501-1000&lt;/td&gt;
&lt;td&gt;33.6%&lt;/td&gt;
&lt;td&gt;27.6%&lt;/td&gt;
&lt;td&gt;Top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All top 1,000&lt;/td&gt;
&lt;td&gt;39%&lt;/td&gt;
&lt;td&gt;33.4%&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI-stack list (44)&lt;/td&gt;
&lt;td&gt;29.5%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Bar: latest release has an attestation on at least one file. Tick: attested by the repository the release's metadata names, counting 5 renamed or transferred repositories as matches. Top projects by 30-day downloads to Oct 1, 2026; surveyed Oct 26, 2026, 22:36 UTC. Source: results/summary-20261026T223625Z.md in the demo repo.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 390 attested top-1,000 projects break down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attested by&lt;/th&gt;
&lt;th&gt;Projects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The repository the metadata names&lt;/td&gt;
&lt;td&gt;329&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same repository after a rename or transfer (filelock, pendulum, dulwich, cramjam, dbt-extractor)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A different public repository (numpy, scipy, scikit-learn and matplotlib publish from &lt;code&gt;*-release&lt;/code&gt; repos)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A repository that is not publicly visible (mlflow, spaCy and seven other Explosion packages, semgrep, modal, dbt-protos)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A GitHub repository, but the metadata names none (numba, selenium, thrift and 4 more)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Google Cloud service account, with no repository (30 Google client libraries)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No attestation on the latest release&lt;/td&gt;
&lt;td&gt;610&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some limits on these numbers. A project counts as attested if any file of its latest release is attested (3 were partial, such as sqlalchemy with 64 of 72 files). Restricting to the 864 projects whose latest release was uploaded on or after November 14, 2024, the day PyPI announced attestations, gives 45.0% attested and 38.5% repository matches. To check the method, a second script asked the Integrity API file by file (up to 80 files per release) about 40 randomly chosen projects and the 23 projects described below. All 63 agreed with the survey.&lt;/p&gt;

&lt;p&gt;Three findings stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The publisher is often not the source repository, on purpose.&lt;/strong&gt; numpy, scipy, scikit-learn, matplotlib, mlflow and spaCy publish from a separate release repository, like litellm. That can be a reasonable design: it keeps the publishing rights in a repository that fewer people can change. But it means a "publisher equals metadata repository" rule fails for some of the most installed packages in Python. What you can pin is the publisher that you observe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No sampled file carries the attestation that records a source.&lt;/strong&gt; We read up to three attested files per project. For all 396 attested projects in the survey (390 from the top 1,000 and 6 more from the AI list), every attestation in those files is a PyPI publish attestation (&lt;code&gt;https://docs.pypi.org/attestations/publish/v1&lt;/code&gt;). None is the SLSA provenance type, which PyPI describes as recording "a package's original source location".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;23 projects stopped attesting.&lt;/strong&gt; They attested an earlier release and not their latest one: litellm, fastapi, typer, sqlmodel, langchain-core, fastmcp, langfuse, watchfiles, databricks-sdk, the Supabase client family and others. We read their publish workflows as they are today. 12 of them upload with &lt;code&gt;uv publish&lt;/code&gt;. The &lt;a href="https://docs.astral.sh/uv/guides/package/" rel="noopener noreferrer"&gt;uv docs&lt;/a&gt; say it "does not currently generate attestations; attestations must be created separately before publishing". 2 (langchain-core and dspy) set &lt;code&gt;attestations: false&lt;/code&gt; on the PyPA publish action. For 7, we found no upload step in a public workflow, and 2 (databricks-sdk and rich-toolkit) name no GitHub repository in their metadata, so we read nothing. Several packages share one repository (typer and typer-slim, httpx2 and httpcore2, the Supabase family), so this is evidence about repositories more than about each package. This shows how those projects publish now, not why they stopped.&lt;/p&gt;

&lt;p&gt;In the AI-stack list, 13 of 44 latest releases are attested. openai, mcp, transformers, huggingface-hub, tokenizers, mistralai, ollama, pinecone, pydantic-ai, openai-agents and haystack-ai are attested by the repository their metadata names. google-genai is attested by a Google service account, and autogen-agentchat by &lt;code&gt;microsoft/autogen&lt;/code&gt;, which its metadata does not name. Without an attestation: anthropic, langchain, langgraph, llama-index, torch, vllm, tiktoken, chromadb, crewai and 22 more. No attestation does not mean a project publishes carelessly. It means that a provenance check has nothing to read.&lt;/p&gt;

&lt;p&gt;The same pattern holds inside one dependency tree. We locked &lt;code&gt;litellm==1.106.2&lt;/code&gt; with hashes for Linux x86_64 and Python 3.12 and ran the per-file check on the 58 packages, which reads the provenance of every hashed file (about 3 minutes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -I -S provenance_check.py --lock examples/litellm-1.106.2-lock/requirements.txt (excerpt, 5 of 58 package lines)
aiohttp==3.14.5 attested-match GitHub repository=aio-libs/aiohttp workflow=ci-cd.yml environment=pypi
...
filelock==3.32.7 attested-other-repo GitHub repository=tox-dev/filelock workflow=release.yaml environment=release (metadata: github.com/tox-dev/py-filelock)
...
litellm==1.106.2 no-attestation
openai==2.54.0 attested-match GitHub repository=openai/openai-python workflow=create-releases.yml environment=publish
pydantic-core==2.50.1 no-attestation
...

attested-match: 33, attested-other-repo: 1, no-attestation: 24

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one &lt;code&gt;attested-other-repo&lt;/code&gt; is filelock, whose repository was renamed. The per-file checker does not resolve renames; the survey does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can enforce today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A release cooldown.&lt;/strong&gt; 1.107.0 was on PyPI for about 1 hour 43 minutes. Any rule that ignores files younger than two hours would have skipped it. Malware scanners flagged it within that window: Socket reports 7 minutes after upload, Chainguard 16. A cooldown gives detection time to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip releases younger than 7 days&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;uv (pyproject.toml)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="k"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uv&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;exclude-newer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"7 days"&lt;/span&gt;

&lt;span class="c"&gt;# let a security fix through on purpose&lt;/span&gt;
&lt;span class="n"&gt;exclude-newer-package&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cryptography&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;uv pip&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--exclude-newer&lt;/span&gt; &lt;span class="s2"&gt;"7 days"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;pip 26.1+&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--uploaded-prior-to&lt;/span&gt; P7D &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;uv compares each file's upload time and accepts friendly durations (&lt;code&gt;24 hours&lt;/code&gt;, &lt;code&gt;1 week&lt;/code&gt;, &lt;code&gt;30 days&lt;/code&gt;) and ISO 8601 durations (&lt;code&gt;P7D&lt;/code&gt;). pip added &lt;code&gt;--uploaded-prior-to&lt;/code&gt; in 26.0 and the &lt;code&gt;P3D&lt;/code&gt; day form in 26.1. It works only with indexes that publish upload times, and PyPI does. The cost is real: a cooldown delays security fixes by the same amount, so exempt the packages you patch urgently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Hash-locked installs, relocked on purpose.&lt;/strong&gt; A &lt;code&gt;uv.lock&lt;/code&gt;, a &lt;code&gt;pylock.toml&lt;/code&gt; or a &lt;code&gt;pip-compile --generate-hashes&lt;/code&gt; file made before 11:39 UTC on October 21 kept you on 1.106.2 or earlier. Install with &lt;code&gt;uv sync --locked&lt;/code&gt; or &lt;code&gt;pip install --require-hashes&lt;/code&gt;, and treat a lock refresh as a change to review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Image digests plus a source check.&lt;/strong&gt; Pin images by digest, and before you promote a new digest, check that its BuildKit provenance names the repository you expect. A digest pin alone would have pinned the bad image if you had updated it during the window. Remember that the source field is written by the build, so it can be forged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Publisher pins for packages that attest.&lt;/strong&gt; Verify against the publisher you observed, not the repository in the metadata:&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;# One file, cryptographically, with the PyPA verifier (pypi-attestations 0.0.30)&lt;/span&gt;
uvx &lt;span class="nt"&gt;--from&lt;/span&gt; pypi-attestations&lt;span class="o"&gt;==&lt;/span&gt;0.0.30 pypi-attestations verify pypi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt; https://github.com/psf/requests pypi:requests-2.34.2-py3-none-any.whl

&lt;span class="c"&gt;# Every installed or locked file: record publishers once, alert when one changes or disappears&lt;/span&gt;
python3 &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; provenance_check.py &lt;span class="nt"&gt;--env&lt;/span&gt; .venv &lt;span class="nt"&gt;--write-pins&lt;/span&gt; pins.json
python3 &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; provenance_check.py &lt;span class="nt"&gt;--lock&lt;/span&gt; uv.lock &lt;span class="nt"&gt;--pins&lt;/span&gt; pins.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;provenance_check.py&lt;/code&gt; reads PyPI's record of the attestation and does not repeat the Sigstore verification. PyPI checks the signature at upload, and &lt;code&gt;pypi-attestations&lt;/code&gt; checks it again on your side. For a lock, every hash must match a file of the release. For an installed wheel, the checker matches the wheel tags and compares the installed &lt;code&gt;METADATA&lt;/code&gt; with the sha256 that PyPI publishes for that wheel's metadata (pip and uv both install it unchanged). That ties the install to a PyPI wheel's metadata, not to every installed byte. Be honest about what a pin buys you. Pinned in April, litellm's publisher would have "disappeared" on every release from April 29 on. That is 206 alerts before the one that mattered, and that is how checks like this get turned off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What pip and uv do not do.&lt;/strong&gt; As of October 2026, neither installer verifies PEP 740 attestations. The pip changelog up to 26.2.2 (October 20) and the uv changelog up to 0.13.5 (October 21) list no such option. pip checks hashes (&lt;code&gt;--require-hashes&lt;/code&gt;) and upload times. uv checks hashes and upload times, and &lt;code&gt;uv publish&lt;/code&gt; uploads attestation files that another step created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. If you maintain a package.&lt;/strong&gt; Keep attestations on. The PyPA publish action creates them by default when you use Trusted Publishing. If you switch to &lt;code&gt;uv publish&lt;/code&gt;, add a step that creates them: uv's &lt;a href="https://docs.astral.sh/uv/guides/integration/github/" rel="noopener noreferrer"&gt;GitHub Actions guide&lt;/a&gt; runs &lt;code&gt;astral-sh/attest-action&lt;/code&gt; before &lt;code&gt;uv publish&lt;/code&gt;. If you publish from a separate release repository, say so in your README, so that users can pin the right publisher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. If you run the LiteLLM proxy.&lt;/strong&gt; In the issue thread, a LiteLLM maintainer called default-deny egress "a very good way to run LiteLLM". A proxy that can reach only its model providers could not have sent anything to &lt;code&gt;www.litellm.dev&lt;/code&gt; or fetched a second stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We could not see 1.107.0 on PyPI.&lt;/strong&gt; It was removed before we started, and we found no archived copy of its JSON or provenance. The statement that it had no attestation comes from the reporters and from its neighbors: 1.106.2, from the same pipeline an hour earlier, has none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What a 1.107.0 attestation would have said is an inference&lt;/strong&gt; from the Trusted Publishing rules and the 15 attested releases. We do not know which workflow file uploaded 1.107.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not read the 1.107.0 image provenance ourselves.&lt;/strong&gt; The images return 404 by tag and by digest. &lt;code&gt;Berri-AI/litellm&lt;/code&gt; at &lt;code&gt;20535d8f&lt;/code&gt; comes from Upwind and Socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We do not know why litellm and the 22 other projects stopped attesting.&lt;/strong&gt; PyPI's JSON API does not say which client uploaded a file, and the workflows we read are today's versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The survey counts projects, not installs.&lt;/strong&gt; It looks at the latest release only, and your pins may point to older releases with different records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not test forging BuildKit provenance.&lt;/strong&gt; We say it can be forged because whoever runs the build writes it, not because we did it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing here catches an attacker who runs your real pipeline on your real source.&lt;/strong&gt; That is what happened to &lt;a href="https://devops-daily.com/posts/tanstack-npm-worm-dead-mans-switch" rel="noopener noreferrer"&gt;TanStack on npm&lt;/a&gt;, where the malicious versions carried valid provenance.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;LiteLLM 1.107.0 came from the real release pipeline, fed from a lookalike fork. Reviewing &lt;code&gt;BerriAI/litellm&lt;/code&gt; could not have caught it.&lt;/li&gt;
&lt;li&gt;Check without starting the suspect Python: &lt;code&gt;python3 -I -S ioc_check.py&lt;/code&gt; covers venvs, lock files, pip and uv caches, image tarballs and host markers. Then rotate everything the host could reach, starting with cloud roles and the proxy's provider keys.&lt;/li&gt;
&lt;li&gt;The Docker image's BuildKit provenance named the fork. PyPI had no attestation to check, and a PyPI publish attestation names the uploading workflow, not the source it built.&lt;/li&gt;
&lt;li&gt;39.0% of the top 1,000 PyPI projects attest their latest release. 33.4% are attested by the repository their metadata names. 23 projects in our set stopped attesting, and we found no SLSA provenance attestation on any of the 396 attested projects.&lt;/li&gt;
&lt;li&gt;What works today is a cooldown, hash locks, digest pins with a source check, and publisher pins where they exist. As of October 2026, pip and uv do not verify attestations for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/litellm-1-107-0-compromise-pypi-provenance" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>supplychain</category>
      <category>pypi</category>
    </item>
    <item>
      <title>Why Your GitHub Actions Bill Keeps Growing, and What Each Fix Saves</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 26 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/why-your-github-actions-bill-keeps-growing-and-what-each-fix-saves-1fkb</link>
      <guid>https://dev.to/devopsdaily/why-your-github-actions-bill-keeps-growing-and-what-each-fix-saves-1fkb</guid>
      <description>&lt;p&gt;A GitHub Actions bill rarely grows because of one decision. It grows a minute at a time: every job is rounded up to a whole minute, every matrix leg pays for its own checkout and setup, macOS minutes cost about ten times as much as Linux minutes, and artifacts stay in storage for 90 days unless you say otherwise. For a mobile team, the most expensive job is often the iOS build. In the pipeline we tested, it was a median 57% of GitHub's cost per run, although the Android build took longer.&lt;/p&gt;

&lt;p&gt;We measured both halves of the problem. First, a read-only script that shows where one repository's minutes went in the last 30 days, and the free changes that act on what it finds. Then the runner question. We ran one mobile pull request pipeline (the iOS build and unit tests, the Android build and the Android unit tests, all started together) 17 times on GitHub-hosted runners and 17 times on &lt;a href="https://bitrise.io/platform/build-hub" rel="noopener noreferrer"&gt;Bitrise Build Hub&lt;/a&gt;, which runs GitHub Actions jobs on its own M4 Macs and AMD Linux machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the same cost at list price, less than half the wait.&lt;/strong&gt; With the Build Hub Linux pool allowed to run two machines at once, the pipeline finished in a median 18.5 minutes on Build Hub over 7 runs, against 43.6 minutes on GitHub over 17 runs. At list price, a run cost a median $1.77 on Build Hub and $1.79 on GitHub, over 17 runs each, and the ranges overlap. The iOS job is where the money moves: it ran 2.7 times faster on a 5 vCPU M4 and cost about a fifth as much per job. The two Android jobs ran about twice as fast on a 16 vCPU Linux machine and cost about twice as much per job.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run the audit script in this post on your busiest repository first. On ours, rounding each job up to a whole minute added a quarter to the billed minutes, and re-runs added almost nothing. Your mix will differ, and the script tells you which fix matters.&lt;/li&gt;
&lt;li&gt;Free fixes inside GitHub Actions: cancel superseded runs, filter by path inside the job (not at the workflow level, or required checks hang), set &lt;code&gt;timeout-minutes&lt;/code&gt;, merge tiny jobs, and set &lt;code&gt;retention-days&lt;/code&gt; on artifacts.&lt;/li&gt;
&lt;li&gt;macOS jobs are the clearest case for a different runner. The Wikipedia iOS app's build and tests took a median 922 s on GitHub's &lt;code&gt;macos-26&lt;/code&gt; (3 vCPU M1) and 337 s on a Build Hub M4 Large (5 vCPU M4): $0.99 against $0.21 per job at list price.&lt;/li&gt;
&lt;li&gt;Heavy Linux jobs got faster on a bigger machine, not cheaper. The Thunderbird Android build took 2,604 s on GitHub's 4 vCPU runner and 1,244 s on Build Hub's 16 vCPU Linux XLarge, at $0.53 against $1.10 per job.&lt;/li&gt;
&lt;li&gt;Set the machine pool maximum to at least the number of jobs that start together. With a Linux pool maximum of 1, our second Android job waited a median 15 minutes for the first one, and the pipeline took a median 35.3 minutes.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A GitHub repository with some Actions history, and the &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt; logged in with read access to it.&lt;/li&gt;
&lt;li&gt;Node.js 22 or later for the audit script.&lt;/li&gt;
&lt;li&gt;To repeat the runner test: a Bitrise Build Hub workspace, and an organization owner who can install the Build Hub GitHub App.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the minutes go
&lt;/h2&gt;

&lt;p&gt;GitHub bills hosted runners per job, per minute, at a rate set by the machine. These are the mechanisms that make a bill grow without anyone deciding to spend more. Prices are GitHub's list prices, &lt;a href="https://docs.github.com/en/billing/reference/actions-runner-pricing" rel="noopener noreferrer"&gt;checked on 18 October 2026&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;What it does to the bill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS rate&lt;/td&gt;
&lt;td&gt;The standard macOS runner (3 vCPU M1) costs $0.062 a minute. A 2-core Linux runner costs $0.006, so a macOS minute costs about 10 times as much. The 5 vCPU M2 Pro (&lt;code&gt;-xlarge&lt;/code&gt;) costs $0.102.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-job rounding&lt;/td&gt;
&lt;td&gt;"GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute." A 20-leg matrix of 35-second jobs bills 20 minutes for 11.7 minutes of work.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matrix fan-out&lt;/td&gt;
&lt;td&gt;Each leg repeats checkout, toolchain setup and cache restore, and pays its own rounding.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Larger runners&lt;/td&gt;
&lt;td&gt;Billed from the first minute. Included minutes do not apply to them, and they are not free in public repositories.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-runs&lt;/td&gt;
&lt;td&gt;"Re-run all jobs" pays for every job again. "Re-run failed jobs" pays only for the failed jobs and the jobs that depend on them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artifact storage&lt;/td&gt;
&lt;td&gt;$0.25 per GB-month, accrued hourly, with a 90-day default retention. Deleting an artifact does not refund what it has already accrued.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;10 GB per repository is free, and entries not used for 7 days are evicted. Since &lt;a href="https://github.blog/changelog/2025-11-20-github-actions-cache-size-can-now-exceed-10-gb-per-repository/" rel="noopener noreferrer"&gt;20 November 2025&lt;/a&gt; you can pay for more at $0.07 per GB-month. A cache that is too small keeps evicting itself, and every miss is a cold build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI review&lt;/td&gt;
&lt;td&gt;Copilot code review &lt;a href="https://github.blog/changelog/2026-04-27-github-copilot-code-review-will-start-consuming-github-actions-minutes-on-june-1-2026/" rel="noopener noreferrer"&gt;uses Actions minutes&lt;/a&gt; in private repositories since 1 June 2026.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queueing&lt;/td&gt;
&lt;td&gt;Free, Pro and Team plans run at most 5 macOS jobs at the same time. Waiting does not cost minutes, but it costs the people who wait.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flaky tests make re-runs a habit. GitHub's own engineering team &lt;a href="https://github.blog/engineering/engineering-principles/reducing-flaky-builds-by-18x/" rel="noopener noreferrer"&gt;reported in 2020&lt;/a&gt; that "1 in 11 commits had at least one red build caused by a flaky test". Google &lt;a href="https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html" rel="noopener noreferrer"&gt;reported in 2016&lt;/a&gt; "about 1.5% of all test runs reporting a 'flaky' result". Every one of those red builds is a full re-run if someone presses the wrong button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own bill
&lt;/h2&gt;

&lt;p&gt;The list above says what can happen. Your run history says what did. This script reads the last 30 days of runs and jobs for one repository with &lt;code&gt;gh api&lt;/code&gt;, rounds each job the way GitHub does, and prints where the minutes went. It only reads, and it runs fine on a laptop or a Raspberry Pi.&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="cp"&gt;#!/usr/bin/env node
&lt;/span&gt;&lt;span class="c1"&gt;// Where did this repository's GitHub Actions minutes go? Reads the run and job&lt;/span&gt;
&lt;span class="c1"&gt;// history with the gh CLI (read-only) and prints the billed minutes, the&lt;/span&gt;
&lt;span class="c1"&gt;// minutes added by per-job rounding, the minutes spent on re-runs, queue time,&lt;/span&gt;
&lt;span class="c1"&gt;// and the workflows that bill the most.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// node audit.mjs owner/repo [days] # default: the 30 full days before today&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;execFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:child_process&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;promisify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:util&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usage: node audit.mjs owner/repo [days]&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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;promisify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;execFile&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;gh&lt;/span&gt; &lt;span class="o"&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;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--paginate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--slurp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// One query per day: the runs endpoint returns at most 1,000 runs per search.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;864&lt;/span&gt;&lt;span class="nx"&gt;e5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;gh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`repos/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/actions/runs?created=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;per_page=100`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workflow_runs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Jobs of every attempt (filter=all), eight requests at a time.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;gh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`repos/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/actions/runs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/jobs?filter=all&amp;amp;per_page=100`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jobs&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;js&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;j&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&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;ran&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conclusion&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;billed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;rerun&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run_attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;os&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;macos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;macOS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: no jobs ran in the last &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&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;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;queues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&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;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&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;billed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billed&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;rerun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rerun&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billed&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;short&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, last &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; runs, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; jobs that ran`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`minutes used &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`minutes billed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;billed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (+&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;billed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; from rounding each job up, &lt;/span&gt;&lt;span class="p"&gt;${((&lt;/span&gt;&lt;span class="nx"&gt;billed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%)`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`jobs under 1 minute: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;short&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${((&lt;/span&gt;&lt;span class="nx"&gt;short&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%), each billed as 1`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`re-run attempts: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rerun&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; jobs, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rerun&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; billed minutes (&lt;/span&gt;&lt;span class="p"&gt;${((&lt;/span&gt;&lt;span class="nx"&gt;rerun&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;billed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%)`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`queue: median &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; s, p90 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queues&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`macOS jobs: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;macOS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byWorkflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top workflows by billed minutes:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byWorkflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;` &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; jobs)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We ran it on the repository behind this site on 18 October 2026:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;audit.mjs&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node audit.mjs The-DevOps-Daily/devops-daily 30
The-DevOps-Daily/devops-daily, last 30 days: 1120 runs, 906 jobs that ran
minutes used 1796
minutes billed 2234 (+438 from rounding each job up, 24%)
jobs under 1 minute: 125 of 906 (14%), each billed as 1
re-run attempts: 2 jobs, 6 billed minutes (0.3%)
queue: median 3 s, p90 4 s
macOS jobs: 0
top workflows by billed minutes:
    1077 Tests (377 jobs)
     813 Build Test (376 jobs)
     110 IndexNow Submission (93 jobs)
      26 npm_and_yarn in / for @next/third-partie (2 jobs)
      22 npm_and_yarn in / for @next/third-partie (2 jobs)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository is public and runs only Linux jobs, so all of this was free, but the shape is common. Rounding added 438 minutes, a 24% markup on the 1,796 minutes the jobs used. The jobs are short: just under 2 minutes each on average, and 125 of them finished in under a minute and billed a full one. Re-runs were 0.3% of the billed minutes, and jobs waited 3 seconds for a runner. Both workflows that bill the most already cancel superseded runs. So of the things this script measures, rounding is the one worth fixing here, with fewer and longer jobs. Billed at the 2-core private-repository rate of $0.006, the rounding would be $2.63 of a $13.40 month. That is small money, but a repository with the same mix of short jobs and a bill a hundred times larger pays the same 24%.&lt;/p&gt;

&lt;p&gt;Some limits of the script. It uses the job's start and end times, which is what GitHub bills, but it cannot see your plan's included minutes or discounts, so treat its numbers as list-price minutes. It selects runs by the day they were created, so a re-run attempt counts on the day of the original run, and a day with more than 1,000 runs is cut at 1,000. It does not look at artifact storage or cache hit rates. And it counts runs on self-hosted runners as if they were billed; filter them out by label if you have any.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free fixes inside GitHub Actions
&lt;/h2&gt;

&lt;p&gt;None of these needs a new vendor. Start with the one your audit points at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancel runs that a newer push makes useless.&lt;/strong&gt; Every push to a pull request starts a full run. Without a concurrency group, the old run keeps going and bills to the end.&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;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.workflow }}-${{ github.ref }}&lt;/span&gt;
  &lt;span class="c1"&gt;# Cancel superseded pull request runs, but let runs on main finish.&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event_name == 'pull_request' }}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Filter by path inside the job, not at the workflow level.&lt;/strong&gt; A workflow-level &lt;code&gt;paths:&lt;/code&gt; filter skips the whole workflow, and then &lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax" rel="noopener noreferrer"&gt;its checks stay "Pending"&lt;/a&gt;, which blocks a pull request that requires them. A job skipped by its &lt;code&gt;if:&lt;/code&gt; condition reports success instead, so put the decision in a small first job:&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;# In a workflow that runs on pull_request.&lt;/span&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;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;changes&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-24.04&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.diff.outputs.ios }}&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;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;diff&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;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;# List the files this pull request changes, without a checkout.&lt;/span&gt;
          &lt;span class="s"&gt;# A failed API call fails this job, so make it a required check too.&lt;/span&gt;
          &lt;span class="s"&gt;files=$(gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} --name-only)&lt;/span&gt;
          &lt;span class="s"&gt;if grep -q '^ios/' &amp;lt;&amp;lt;&amp;lt; "$files"; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "ios=true" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

  &lt;span class="na"&gt;ios&lt;/span&gt;&lt;span class="pi"&gt;:&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;changes&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.changes.outputs.ios == 'true'&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;macos-26&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;45&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@v7&lt;/span&gt;
      &lt;span class="c1"&gt;# ... build and test&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make both &lt;code&gt;changes&lt;/code&gt; and &lt;code&gt;ios&lt;/code&gt; required checks. If &lt;code&gt;changes&lt;/code&gt; fails, &lt;code&gt;ios&lt;/code&gt; is skipped, and a skipped job counts as passed. The &lt;code&gt;changes&lt;/code&gt; job bills one Linux minute. Skipping one standard macOS job of 15 minutes saves $0.93.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;timeout-minutes&lt;/code&gt; on every job.&lt;/strong&gt; The default limit is 6 hours. One hung macOS job that runs to the limit bills 360 minutes, which is $22.32 at $0.062.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merge tiny jobs.&lt;/strong&gt; Three 20-second lint jobs bill 3 minutes. One job that runs all three bills 1. The same applies to matrix legs that only differ in a flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put light automation on the smallest runner.&lt;/strong&gt; &lt;code&gt;ubuntu-slim&lt;/code&gt; is a 1 vCPU runner in a container at $0.002 a minute, with a 15-minute job limit and no Docker. It suits labelers, triage bots and other jobs that wait on an API. arm64 Linux runners are also cheaper than x64 at the same size: $0.005 against $0.006 for 2 cores, and $0.008 against $0.012 for 4 cores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep artifacts for days, not months.&lt;/strong&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="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/upload-artifact@v7&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-results&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;build/test-results&lt;/span&gt;
    &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the 90-day default, an artifact accrues 18 times the GB-hours it accrues at 5 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-run failed jobs, not all jobs.&lt;/strong&gt; And fix the flaky test that made you re-run. The audit's re-run line tells you whether this is worth your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving the slow jobs to a faster runner
&lt;/h2&gt;

&lt;p&gt;Some jobs are slow because the machine is small, and no workflow change fixes that. GitHub's standard macOS runner is a 3 vCPU M1 with 7 GB of memory. Its largest Apple silicon runner is a 5 vCPU M2 Pro with 14 GB.&lt;/p&gt;

&lt;p&gt;Bitrise Build Hub keeps GitHub Actions as the orchestrator and supplies the machines. The jobs still appear in the Actions tab, with the same logs, secrets and triggers. Setup has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An organization owner installs the Bitrise Build Hub GitHub App, preferably only on the repositories that need it.&lt;/li&gt;
&lt;li&gt;In the Bitrise workspace, you create a machine pool: an image, a machine type, and a maximum number of machines. Bitrise registers the machines with GitHub as runners in a runner group.&lt;/li&gt;
&lt;li&gt;You change &lt;code&gt;runs-on&lt;/code&gt; for the jobs you want to move. Bitrise adds a &lt;code&gt;bitrise-runner-&amp;lt;pool name&amp;gt;&lt;/code&gt; label to each pool:
&lt;/li&gt;
&lt;/ol&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;ios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Was: runs-on: macos-26&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;bitrise-runner-ios-m4-large&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runs-on&lt;/code&gt; is set per job, so you can move the iOS job and keep everything else where it is.&lt;/p&gt;

&lt;p&gt;Three things are different from GitHub-hosted runners, and all three mattered in our test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The images are Bitrise stacks&lt;/strong&gt; , not GitHub's runner images. &lt;a href="https://bitrise.io/stacks" rel="noopener noreferrer"&gt;bitrise.io/stacks&lt;/a&gt; lists what each macOS and Linux stack includes, so check it before you move a job. The Xcode 26.5 stack had the same Xcode build (17F42) and the same iPhone 17 simulator that we used on GitHub. The Ubuntu 24.04 Linux image did not have the Android SDK command-line tools or the OpenGL and X11 libraries that Thunderbird's Compose UI tests load, so our workflow installs them when they are missing: 2 to 7 seconds for the SDK tools and 9 to 14 seconds for four packages. Both steps run inside the job, so their time is in our results and in the cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pool maximum is a setting you own.&lt;/strong&gt; A pool starts machines on demand, up to its maximum. With the Android pool at a maximum of 1, the two Android jobs of each pipeline ran one after the other, and the second one waited a median 15 minutes for the first (more on this below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing is a monthly budget.&lt;/strong&gt; A Build Hub tier fee becomes your compute budget, and jobs use it at a per-minute rate per machine type. Build Hub bills per started minute, and the billed time includes the machine's start-up. By default, jobs stop running on Build Hub when the budget is used up. You can &lt;a href="https://bitrise.io/changelog/build-hub-pay-as-you-go-limit-setting/26370" rel="noopener noreferrer"&gt;turn on pay-as-you-go&lt;/a&gt; to keep them running, at twice the standard rate per minute and with a spending cap you set. Size the tier from your run count, and watch the balance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The machines register like self-hosted runners, so the usual hygiene applies. Restrict the runner group to the repositories that need it, and require approval before workflows from forks run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark: one mobile pull request, 34 runs
&lt;/h2&gt;

&lt;p&gt;We wanted a test that looks like a real team's pull request, not a synthetic compile.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pull request&lt;/strong&gt; one pipeline run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS build + tests&lt;/strong&gt; Wikipedia app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android build&lt;/strong&gt; Thunderbird&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android tests&lt;/strong&gt; Thunderbird&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline done&lt;/strong&gt; last job finished&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull request -&amp;gt; iOS build + tests&lt;/li&gt;
&lt;li&gt;Pull request -&amp;gt; Android build&lt;/li&gt;
&lt;li&gt;Pull request -&amp;gt; Android tests&lt;/li&gt;
&lt;li&gt;iOS build + tests -&amp;gt; Pipeline done&lt;/li&gt;
&lt;li&gt;Android build -&amp;gt; Pipeline done&lt;/li&gt;
&lt;li&gt;Android tests -&amp;gt; Pipeline done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The workloads.&lt;/strong&gt; Both apps are open source and pinned by full commit SHA, and every run starts cold, with no cache on either side.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS: the &lt;a href="https://github.com/wikimedia/wikipedia-ios" rel="noopener noreferrer"&gt;Wikipedia iOS app&lt;/a&gt; at &lt;code&gt;ca8aaeb9&lt;/code&gt;, its "Run Unit Tests" job: resolve packages, &lt;code&gt;xcodebuild build-for-testing&lt;/code&gt;, then &lt;code&gt;test-without-building&lt;/code&gt; on an iPhone 17 simulator with iOS 26.5 and Xcode 26.5. All 503 unit tests pass on both sides.&lt;/li&gt;
&lt;li&gt;Android: &lt;a href="https://github.com/thunderbird/thunderbird-android" rel="noopener noreferrer"&gt;Thunderbird for Android&lt;/a&gt; at &lt;code&gt;cf54b1fd&lt;/code&gt;, with the commands of its own CI. The build job runs &lt;code&gt;./gradlew :app-thunderbird:assemble -Pci=true&lt;/code&gt;. The test job runs &lt;code&gt;./gradlew testsOnCi --parallel&lt;/code&gt;. Both use the project's CI Gradle settings, with one change: Gradle gets one worker per CPU (upstream pins 4, the size of a GitHub standard runner), and machines with more than 20 GB of memory give the Gradle daemon half of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The machines.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Side&lt;/th&gt;
&lt;th&gt;iOS job&lt;/th&gt;
&lt;th&gt;Android jobs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub-hosted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;macos-26&lt;/code&gt;: Apple M1 (virtual), 3 vCPU, 7 GB. $0.062 a minute&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ubuntu-24.04&lt;/code&gt;: 4 vCPU, 16 GB. $0.012 a minute (the 4-core rate in a private repository)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub, self-serve Pro Tier 1&lt;/td&gt;
&lt;td&gt;M4 Large: Apple M4 (virtual), 5 vCPU, 14 GB. $0.03 a minute&lt;/td&gt;
&lt;td&gt;Linux XLarge: AMD EPYC Genoa, 16 vCPU, 64 GB. $0.05 a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are not same-size pairs. On GitHub we used the standard macOS runner and a 4 vCPU Linux runner, which a team gets without a larger-runner plan. On Build Hub we used the smallest M4 and the largest self-serve Linux machine. The Build Hub machines have more CPU and memory, and the results hold for these configurations only. Cost per run shows what the size difference costs. Build Hub prices are the rates at which jobs use the plan budget, read from &lt;a href="https://bitrise.io/pricing" rel="noopener noreferrer"&gt;bitrise.io/pricing&lt;/a&gt; on 18 October 2026, unchanged since we first read them on 10 October.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How we ran it.&lt;/strong&gt; Bitrise provided the Build Hub workspace for these tests. The workflows and every run are public in the benchmark repository. One workflow, &lt;code&gt;bench-mobile-pipeline.yml&lt;/code&gt;, starts the three jobs together. We dispatched the GitHub and Build Hub runs in pairs, most of them seconds apart: 10 pairs from 16 October 15:24 to 17 October 12:15 UTC, then 7 more from 17 October 15:44 to 21:25 UTC. Every iOS job ran all 503 tests with no failures, and every Android build succeeded. One Android test task on GitHub (run 38985488530) failed on a network error: Robolectric could not download a dependency, and Gradle stopped after about 10 minutes. Actions still shows that job as green, because our workflow records a failed Gradle task in its result file instead of failing the job. So 16 of 17 GitHub test jobs and all 17 Build Hub test jobs ran the full suite. We kept that run. Leaving it out makes GitHub's medians slightly worse (45.1 minutes and $1.80 a run), so keeping it is the conservative choice. Pipeline time is from the moment the run is created to the end of its last job, so queueing and machine start-up count, as they do for the developer who waits. Cost is each job's billed time, rounded up to a whole minute, times the list price, added up per run. On GitHub, the billed time is the job's own run time. Build Hub also bills the machine's start-up, which the job's timestamps do not show, so on that side we counted from the moment the job was queued (or, when it waited for a busy pool, from the moment the pool's machine was free) to the end of the job. That adds a median 38 seconds per job on macOS and 34 seconds on Linux, and it may include a few seconds of scheduling, so if anything it makes Build Hub's cost slightly high. Three tiny planning jobs run on GitHub's runner on both sides and are left out of the cost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/gha-runner-benchmark" rel="noopener noreferrer"&gt;The-DevOps-Daily/gha-runner-benchmark on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline time
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Mobile pipeline, run created to last job done&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub (17 runs)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;32.8 min&lt;/td&gt;
&lt;td&gt;43.6 min&lt;/td&gt;
&lt;td&gt;60.8 min&lt;/td&gt;
&lt;td&gt;60.8 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub, pool max 2 (7 runs)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;17 min&lt;/td&gt;
&lt;td&gt;18.5 min&lt;/td&gt;
&lt;td&gt;29.4 min&lt;/td&gt;
&lt;td&gt;29.4 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub, pool max 1 (10 runs)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;26.2 min&lt;/td&gt;
&lt;td&gt;35.3 min&lt;/td&gt;
&lt;td&gt;52.1 min&lt;/td&gt;
&lt;td&gt;52.1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Every run. GitHub: macos-26 + ubuntu-24.04, 17 runs, 16 to 17 Oct 2026. Build Hub: M4 Large + Linux XLarge, 10 runs with the Android pool maximum at 1, then 7 at 2. Source: results/mobile-pipeline.json.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the Android pool at a maximum of 2, the pipeline took a median 18.5 minutes (17.0 to 29.4) on Build Hub, against 43.6 minutes (32.8 to 60.8) on GitHub. That is about 2.4 times faster. We compare with all 17 GitHub runs because it is the conservative choice: the 7 GitHub runs paired with the pool-maximum-2 runs had a median of 49.8 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pool maximum mistake
&lt;/h3&gt;

&lt;p&gt;Our first 10 Build Hub runs had the Android pool maximum at 1, a setting we chose and did not check. The first Android job started after a median 34 seconds. The second waited for the first to finish: a median 886 seconds, and up to 2,646 seconds. The pipeline took a median 35.3 minutes. After we set the maximum to 2, both Android jobs started after 32 to 34 seconds in every run. Not all of the drop to 18.5 minutes came from the setting: the Android builds were also faster in the later runs, a median 1,070 seconds against 1,592.&lt;/p&gt;

&lt;p&gt;The rule: set each pool's maximum to at least the number of its jobs that start at the same time, and remember that a matrix starts all of its legs at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Each job
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;GitHub, median (range), 17 runs&lt;/th&gt;
&lt;th&gt;Build Hub, median (range), 17 runs&lt;/th&gt;
&lt;th&gt;Faster by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS build + tests&lt;/td&gt;
&lt;td&gt;922 s (597 to 1,173)&lt;/td&gt;
&lt;td&gt;337 s (314 to 471)&lt;/td&gt;
&lt;td&gt;2.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android build&lt;/td&gt;
&lt;td&gt;2,604 s (1,956 to 3,638)&lt;/td&gt;
&lt;td&gt;1,244 s (981 to 2,581)&lt;/td&gt;
&lt;td&gt;2.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android tests&lt;/td&gt;
&lt;td&gt;967 s (710 to 1,186)&lt;/td&gt;
&lt;td&gt;467 s (400 to 587)&lt;/td&gt;
&lt;td&gt;2.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The job times are from all 17 runs per side. The pool maximum changes how long a job waits, not how long it runs, so it does not affect these numbers. Queue time is separate: on GitHub, jobs started after a median 3 seconds on Linux and 8 seconds on macOS. On Build Hub, where each pool starts a machine when a job arrives, they started after a median 33 to 34 seconds on Linux (at pool maximum 2) and 38 seconds on macOS.&lt;/p&gt;

&lt;p&gt;The GitHub Android times move a lot. In the 17 runs, GitHub's 4 vCPU runner reported three different CPU models for the build and five for the tests. The two builds on an AMD EPYC 9V45 host took 1,780 and 1,892 seconds for the Gradle task, and the twelve on an EPYC 7763 took 1,979 to 3,426 seconds, so the host explains part of the spread but not all of it. Build Hub's Android build varied too, from 981 to 2,581 seconds for the job, on machines that all reported the same CPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  More Mac sizes, including Enterprise
&lt;/h3&gt;

&lt;p&gt;Before the pipeline runs, on 15 and 16 October, we ran the same iOS job on its own (&lt;code&gt;bench-ios-app.yml&lt;/code&gt;) on bigger Macs too. These are separate runs with fewer samples than the pipeline, so compare the rows with each other, not with the tables above.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Job time, median (range)&lt;/th&gt;
&lt;th&gt;Faster by&lt;/th&gt;
&lt;th&gt;List price per job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub &lt;code&gt;macos-26&lt;/code&gt;: M1, 3 vCPU, 7 GB&lt;/td&gt;
&lt;td&gt;GitHub-hosted&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1,026 s (805 to 1,305)&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;$1.12 (18 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 Large: M4, 5 vCPU, 14 GB&lt;/td&gt;
&lt;td&gt;Self-serve&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;323 s (314 to 335)&lt;/td&gt;
&lt;td&gt;3.2x&lt;/td&gt;
&lt;td&gt;$0.18 (6 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 XLarge: M4, 10 vCPU, 28 GB&lt;/td&gt;
&lt;td&gt;Self-serve&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;295 s (293 to 301)&lt;/td&gt;
&lt;td&gt;3.5x&lt;/td&gt;
&lt;td&gt;$0.36 (6 billed minutes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub M4 Pro XLarge: M4 Pro, 14 vCPU, 54 GB&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;250 s (246 to 251)&lt;/td&gt;
&lt;td&gt;4.1x&lt;/td&gt;
&lt;td&gt;None: Enterprise is not billed per minute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 503 tests passed in every run. The M4 Pro XLarge was the fastest Mac we tested, with 23% less job time than the M4 Large. It is on the Enterprise plan only, which Bitrise sells at an "All-You-Can-Eat" price limited by concurrency, not by minutes, so there is no cost per job to compare. Enterprise pools can also keep idle machines running, and ours did: the two jobs we started after the pool was set up began within 2 seconds. On the self-serve plans, the M4 Large stays the value pick: the M4 XLarge took 9% less time at twice the price per minute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost per run
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;List-price cost of each pipeline run&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1.41 USD&lt;/td&gt;
&lt;td&gt;1.79 USD&lt;/td&gt;
&lt;td&gt;2.03 USD&lt;/td&gt;
&lt;td&gt;2.03 USD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build Hub&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1.51 USD&lt;/td&gt;
&lt;td&gt;1.77 USD&lt;/td&gt;
&lt;td&gt;2.83 USD&lt;/td&gt;
&lt;td&gt;2.83 USD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Each job's billed time rounded up to a whole minute, on Build Hub including machine start-up, times the list price: GitHub $0.062 macOS, $0.012 Linux 4-core; Build Hub $0.03 M4 Large, $0.05 Linux XLarge (budget rates, 18 Oct 2026). All 17 runs per side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A run cost a median $1.77 on Build Hub (range $1.51 to $2.83) and $1.79 on GitHub ($1.41 to $2.03). The means point the other way, $1.91 on Build Hub against $1.74 on GitHub, because of a few slow Android builds on Build Hub: the $2.83 run had a 2,581-second build. So "about the same" is the right summary, not "cheaper".&lt;/p&gt;

&lt;p&gt;These are list prices for the compute each run used, not two invoices. Build Hub is sold as a monthly tier, so budget you do not use makes the real price per run higher. Our GitHub runs cost nothing, because the repository is public. In a private repository, standard macOS minutes can come out of your plan's included minutes, while the 4-core Linux runner, a larger runner, is billed from the first minute.&lt;/p&gt;

&lt;p&gt;We used all 17 Build Hub runs for cost because the pool maximum changes queue time, not billed minutes. The 7 runs at pool maximum 2 alone had a median cost of $1.54, mostly because their Android builds were faster (a median 1,070 seconds, against 1,592 seconds in the first 10 runs). Using that number would flatter Build Hub.&lt;/p&gt;

&lt;p&gt;The cost per job shows where the money goes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;GitHub, median per job&lt;/th&gt;
&lt;th&gt;Build Hub, median per job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS build + tests&lt;/td&gt;
&lt;td&gt;$0.99 (16 billed minutes at $0.062)&lt;/td&gt;
&lt;td&gt;$0.21 (7 billed minutes at $0.03)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android build&lt;/td&gt;
&lt;td&gt;$0.53 (44 at $0.012)&lt;/td&gt;
&lt;td&gt;$1.10 (22 at $0.05)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android tests&lt;/td&gt;
&lt;td&gt;$0.20 (17 at $0.012)&lt;/td&gt;
&lt;td&gt;$0.45 (9 at $0.05)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Macs save money and the 16 vCPU Linux machines cost more per job, and in this pipeline the two roughly cancel out. Each column is a median of its own, so the rows do not add up to the per-run medians above. A run used a median 79 billed minutes on GitHub and 39 on Build Hub: fewer minutes, at a higher price per Linux minute.&lt;/p&gt;

&lt;p&gt;For budget sizing, use the mean, not the median: at $1.91 a run, Pro Tier 1's $210 monthly budget covers about 110 runs of this pipeline. Leave headroom: by default, jobs stop running on Build Hub when the budget is used up, and pay-as-you-go minutes cost twice the standard rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The macOS pair is not equal in size.&lt;/strong&gt; We did not run GitHub's 5 vCPU M2 Pro (the &lt;code&gt;-xlarge&lt;/code&gt; macOS runner), because larger runners cost money even in public repositories. Its price makes the cost question simple, though: at $0.102 a minute it would have to finish this job within two billed minutes to cost less than Build Hub's $0.21.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Android pair is not equal in size either.&lt;/strong&gt; We did not run GitHub's 16-core Linux larger runner. Per vCPU, its $0.042 a minute ($0.0026 per vCPU-minute) is cheaper than Build Hub's Linux XLarge at $0.05 ($0.0031). We do not know how fast it is on this build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An 8 vCPU machine might be the better buy for Android.&lt;/strong&gt; Build Hub's Linux Large costs half the XLarge rate. We did not test it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold builds only.&lt;/strong&gt; Both sides ran without a Gradle or Xcode cache. Teams with a warm cache will see shorter jobs on both sides, and we did not measure how the gap changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our repository is public.&lt;/strong&gt; GitHub runs in public repositories are free, and its standard Linux runner there has 4 vCPU. In a private repository, &lt;code&gt;ubuntu-latest&lt;/code&gt; has 2 vCPU at $0.006, and a 4 vCPU runner is a larger runner at $0.012 with no included minutes. We priced the GitHub side at the 4-core rate, the same size we ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Hub start-up time is our estimate.&lt;/strong&gt; Bitrise bills per started minute and includes the machine's start-up, which the job's timestamps do not show. We used the wait before each job started, minus any time it waited for another job to free the pool's machine, as the start-up time, so the Build Hub costs may be slightly high. Self-serve pools do not keep idle machines between jobs; that option is on the Enterprise plan, which is not billed per minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seven runs at pool maximum 2, in one evening.&lt;/strong&gt; The 18.5-minute median comes from 7 runs between 15:44 and 21:25 UTC on 17 October. The job times come from all 17 runs, spread over two days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One app per platform.&lt;/strong&gt; Wikipedia and Thunderbird are real, busy projects, but two apps are not every app. Run the benchmark on your own slowest job before you move it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which jobs to move
&lt;/h2&gt;

&lt;p&gt;The decision is per job, and &lt;code&gt;runs-on&lt;/code&gt; makes it a one-line change.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS and other macOS jobs: move them first.&lt;/strong&gt; This is where the test was both faster and cheaper: 2.7 times faster and about a fifth of the cost per job, on a newer and larger Mac than GitHub's standard runner. Start with the M4 Large. On the Enterprise plan, the M4 Pro XLarge was faster still.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy Linux jobs, such as Android Gradle builds: move them if waiting costs you more than the minutes.&lt;/strong&gt; On a 16 vCPU machine they ran about twice as fast at about twice the cost per job. Measure an 8 vCPU machine too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light Linux jobs: keep them on GitHub.&lt;/strong&gt; Build Hub's smallest Linux machine has 4 vCPU at $0.0125 a minute. A lint or labeler job is cheaper on &lt;code&gt;ubuntu-slim&lt;/code&gt; at $0.002 or a 2-core runner at $0.006.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before any of that, run the audit.&lt;/strong&gt; Rounding, re-runs and storage are free to fix, and they shrink whatever runner bill you end up with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full feature-by-feature view, including concurrency limits, images, caching and what happens when the budget runs out, see the &lt;a href="https://devops-daily.com/comparisons/bitrise-build-hub-vs-github-hosted-runners" rel="noopener noreferrer"&gt;Bitrise Build Hub vs GitHub-hosted runners comparison&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;A GitHub Actions bill grows from small, measurable things: whole-minute rounding, runs nobody needed, artifacts kept for 90 days, and macOS minutes at about ten times the Linux price. The audit script shows what rounding and re-runs cost your repository, and most of the fixes are a few lines of YAML.&lt;/p&gt;

&lt;p&gt;When the slow job is slow because the machine is small, a faster runner changes the picture. In our mobile pipeline, Build Hub cut the wait from a median 43.6 minutes to 18.5 minutes at about the same list-price cost per run. The iOS job was cheaper and faster. The Android jobs were faster and more expensive. Set the pool maximum to match the jobs that start together, measure your own slowest job on both sides, and move the jobs where the numbers say so.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/github-actions-bill-keeps-growing" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>finops</category>
      <category>ios</category>
    </item>
    <item>
      <title>Log Aggregation Pipeline Explained: Follow One Log Line and Run the Incident in Your Browser</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:50:09 +0000</pubDate>
      <link>https://dev.to/devopsdaily/log-aggregation-pipeline-explained-follow-one-log-line-and-run-the-incident-in-your-browser-3h91</link>
      <guid>https://dev.to/devopsdaily/log-aggregation-pipeline-explained-follow-one-log-line-and-run-the-incident-in-your-browser-3h91</guid>
      <description>&lt;p&gt;Checkout says payments are timing out. You open the log search, filter on &lt;code&gt;ERROR&lt;/code&gt;, type &lt;code&gt;payment provider timeout&lt;/code&gt;, and get one hit. Is that one timeout, or the one timeout that survived the trip?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/games/log-aggregation-pipeline-simulator" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3s25wzz1ncddbswobcgx.jpg" alt="Log Aggregation Pipeline Simulator on DevOps Daily" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A log search shows what reached the index, not what the application wrote. Between the two sit an agent, a parser, a filter, a buffer and a search cluster, and every one of them can delay a line, reject it or drop it without the app knowing. That is the whole point of having a log aggregation pipeline explained stage by stage: when a search comes back empty during an incident, you need to know whether the system is fine or the evidence never arrived.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/log-aggregation-pipeline-simulator" rel="noopener noreferrer"&gt;Log Aggregation Pipeline Simulator&lt;/a&gt; lets you watch that trip one stage at a time and then break it in three different ways. It moves logs in batches and counts them at every stage, rather than tracing one line. Disclosure: I help build it; it is free, runs in the browser, no signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six stages, one line
&lt;/h2&gt;

&lt;p&gt;The simulator draws the pipeline as six cards. Each &lt;strong&gt;Step&lt;/strong&gt; advances one stage; &lt;strong&gt;Run flow&lt;/strong&gt; steps automatically about once a second. Six steps make one cycle.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sources&lt;/strong&gt; (Apps &amp;amp; hosts). Raw events, and how many are waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect&lt;/strong&gt; (Fluent Bit). An agent tails files and forwards events. If it falls behind, the source queue grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process&lt;/strong&gt; (Parse &amp;amp; filter). Extracts fields and removes noise. Its two controls sit in the stage inspector: a Parser picker (JSON parser or Grok pattern) and a "Drop health-check noise" switch. Changing either starts a fresh run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffer&lt;/strong&gt; (Durable buffer). A bounded queue between parsing and indexing, shown as used over capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index&lt;/strong&gt; (Search cluster). Accepted events land on three primary shards, and a "Shard health" tab shows the balance. The simulator deals events to the shards in turn, so the bars stay level in every scenario.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search&lt;/strong&gt; (Explore logs). A "Search logs" tab with a text box and level filters. It keeps up to four sample events from each indexing step, and only the newest 24, so judge completeness by the counters, not by the list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Under the cards, five counters keep the books: &lt;strong&gt;Generated&lt;/strong&gt;, &lt;strong&gt;Buffered&lt;/strong&gt;, &lt;strong&gt;Indexed&lt;/strong&gt;, &lt;strong&gt;Rejected&lt;/strong&gt; and &lt;strong&gt;Dropped&lt;/strong&gt;. The engine behind them conserves every event: anything generated is waiting in a queue, filtered, rejected, indexed or dropped. Filtered noise has no counter of its own; the Process card's "removed" figure adds filtered and rejected together. Carry that habit into real systems: if you cannot account for the difference between "sent" and "searchable", you cannot trust an empty search.&lt;/p&gt;

&lt;p&gt;Click any card and the stage inspector shows its "Operator signal", the one thing to watch there. For the Search stage it is the line that matters most in an incident: "Only indexed logs are searchable; filtered, rejected, and dropped logs never arrive here."&lt;/p&gt;

&lt;h2&gt;
  
  
  Healthy first
&lt;/h2&gt;

&lt;p&gt;Start on &lt;strong&gt;Healthy flow&lt;/strong&gt; and step through a few cycles. Each cycle, the sources emit 24 logs, the noise filter removes 4, and 20 are indexed. Queues drain, the badge says "Pipeline is healthy", and the shard bars grow evenly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break it three ways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Traffic spike.&lt;/strong&gt; Sources emit 84 logs per cycle, but the collector forwards 38. The badge changes to "Backpressure is building" on the very first step. In the third cycle: "Sources emitted 84 logs; 26 were dropped before collection." Look at the buffer while that happens: it is empty. The durable buffer cannot save logs that are lost before they reach it. By the end of cycle five, 420 logs were generated, 140 indexed and 118 dropped. The Collect card's "for parsing" count also climbs by 4 per cycle: the collector sends 38, the processor takes 34.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parser mismatch.&lt;/strong&gt; A deployment changes the log format. Of 30 logs per cycle, 4 are filtered as noise, 11 are rejected by the parser and only 15 are indexed. The badge says "Parser rejects detected" and nothing is dropped, so a dashboard that only watches for drops stays green. Try the Grok parser. It does not help: it handles only 27 of the 30 logs per cycle, so 3 more wait in the processor queue every cycle, and of those 27 it filters 4, rejects 10 and indexes 13. A slower parser that still does not match the format makes things worse. The simulator has no parser fix; for real, you update the parser to the new format or roll the change back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow indexing.&lt;/strong&gt; Switch the parser back to JSON first: the simulator keeps your parser choice when you change scenario. The search cluster indexes 9 logs per cycle while 32 reach the buffer (36 arrive, and the noise filter removes 4). The buffer climbs by 23 each cycle, the warning appears in the third cycle, and in the fifth: "The buffer reached capacity and dropped 19 oldest logs." Now turn off "Drop health-check noise" and run it again. The first drop comes a cycle earlier. Filtering noise before the buffer did not fix the slow indexer, but it bought time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now run the incident
&lt;/h2&gt;

&lt;p&gt;Take each scenario as an on-call drill. Pretend you only have the search tab, then use the rest of the screen to answer four questions in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is input normal?&lt;/strong&gt; Compare Generated per cycle against the healthy 24. A spike is often the first sign of the real incident, and it also strains the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where is a queue growing?&lt;/strong&gt; A growing source queue points at the collector. A growing buffer points at the indexer. Name the stage before you touch anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is anything rejected?&lt;/strong&gt; Rejects mean the logs exist but no longer match the parser. If the deploy that changed the format also broke checkout, the error lines you need are among the rejects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long until loss?&lt;/strong&gt; A filling buffer is a countdown. Space left divided by net growth per cycle is a rough, optimistic estimate: here the buffer overflows when a batch lands, before indexing drains it. With the JSON parser and the noise filter on, after cycle four it holds 92 with 13 free, and the next batch of 32 drops 19.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Match the mitigation to the signal: collector capacity for the spike, the parser for the mismatch, index capacity or noise shedding for the slow index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the parser mismatch for real
&lt;/h2&gt;

&lt;p&gt;You can reproduce the parser mismatch with &lt;code&gt;jq&lt;/code&gt; and three lines of log:&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; app.log &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"level":"INFO","service":"api","msg":"request completed status=200"}
{"level":"ERROR","service":"checkout","msg":"payment provider timeout after=3s"}
ERROR checkout payment provider timeout after=3s
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# what a JSON-only pipeline makes searchable&lt;/span&gt;
jq &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'fromjson? | select(.level == "ERROR")'&lt;/span&gt; app.log

&lt;span class="c"&gt;# what it rejected on the way&lt;/span&gt;
jq &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'(try fromjson catch null) as $j | select($j == null)'&lt;/span&gt; app.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With jq 1.6, the first command prints only the JSON error line. The second prints the plain-text &lt;code&gt;ERROR checkout payment provider timeout after=3s&lt;/code&gt;. Two timeouts were logged, and search finds one. Count the second output and alert on it, and you have a parser-reject metric. Real tools do not always throw a reject away. When parsing fails in Vector's &lt;code&gt;remap&lt;/code&gt; transform, "the original, unmodified event is sent downstream" by default, so the line reaches the index as raw text without your fields.&lt;/p&gt;

&lt;p&gt;Real agents have their own version of the other two scenarios. The &lt;a href="https://docs.fluentbit.io/manual/administration/backpressure" rel="noopener noreferrer"&gt;Fluent Bit backpressure docs&lt;/a&gt; explain that when an input reaches its &lt;code&gt;mem_buf_limit&lt;/code&gt;, Fluent Bit (with memory-only buffering) "will stop buffering data from that source plugin until enough buffered chunks are flushed", and most plugins log &lt;code&gt;paused (mem buf overlimit)&lt;/code&gt; when that happens. The same page warns that some inputs can lose data under memory-only buffering and suggests filesystem buffering if you need to avoid that. Search your agent's own logs for that warning before you assume the app went quiet. With filesystem buffering, Fluent Bit drops the oldest data, as the Slow indexing buffer does: at an output's &lt;code&gt;storage.total_limit_size&lt;/code&gt;, "the oldest chunk from its queue will be discarded". Vector's buffers block by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to alert on
&lt;/h2&gt;

&lt;p&gt;The simulator's page lists the signals to watch together: input rate, queue age and depth, parser rejection rate, buffer utilization, dropped events, indexing latency and shard balance. In real tools, the loss signals are counters you can scrape: Fluent Bit's &lt;code&gt;fluentbit_output_retries_failed_total&lt;/code&gt; and &lt;code&gt;fluentbit_output_dropped_records_total&lt;/code&gt;, Vector's &lt;code&gt;component_discarded_events_total&lt;/code&gt;, and Loki's &lt;code&gt;loki_discarded_samples_total&lt;/code&gt;, labelled by &lt;code&gt;reason&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The simulator is one of &lt;a href="https://devops-daily.com/games" rel="noopener noreferrer"&gt;60+ free DevOps games and simulators&lt;/a&gt;. Run the three scenarios until you can predict the next step's numbers, then again as drills with only the search tab open.&lt;/p&gt;

&lt;p&gt;Where has a log pipeline dropped lines on you without anyone noticing? Tell us in the comments.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>sre</category>
      <category>logging</category>
    </item>
    <item>
      <title>Node.js October 2026 Security Release: Finding Every Node You Actually Run</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/nodejs-october-2026-security-release-finding-every-node-you-actually-run-1ag4</link>
      <guid>https://dev.to/devopsdaily/nodejs-october-2026-security-release-finding-every-node-you-actually-run-1ag4</guid>
      <description>&lt;p&gt;On 21 October 2026 the Node.js project released 22.23.4, 24.22.1 and 26.12.1. The headline fix is CVE-2026-111497, an out-of-bounds write in the Brotli decoder that the advisory says "permits remote code execution". The same release fixes two TLS certificate hostname-check bypasses, a DNS bug that truncates names at a NUL character, and updates the bundled CA store. End-of-life lines get no fix.&lt;/p&gt;

&lt;p&gt;Patching Node is a one-line version bump. Finding every Node your servers actually run is the work. When we patched a small production box on release day, six Node apps under pm2 installed with nvm, installing the new version was the easy part. Then we found pm2 entries with the old binary written into them, a systemd unit with the old &lt;code&gt;PATH&lt;/code&gt; hard-coded, child processes we had not thought about, and cron jobs on two other boxes that pointed at the old install by absolute path. None of them moved when the default version changed.&lt;/p&gt;

&lt;p&gt;This post covers what to do now, who is exposed (including services that call &lt;code&gt;fetch()&lt;/code&gt; on a URL a user controls), the upgrade steps for nvm, pm2, systemd and containers with the traps we hit, how to verify with &lt;code&gt;/proc&lt;/code&gt;, and a read-only audit script with its real output from one of our machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade every Node&lt;/strong&gt; to 22.23.4, 24.22.1 or 26.12.1 or later. Node 20, 25 and older are end-of-life and get no fixed release. Move them to 22, 24 or 26.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List every place Node runs or will start&lt;/strong&gt; : running processes, pm2 entries (live and saved), the pm2 daemon, systemd units, crontabs and helper scripts, and containers. Changing the nvm default moves none of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move each one&lt;/strong&gt; with the steps below, then check the real binary of every process in &lt;code&gt;/proc&lt;/code&gt;. Process names do not help: on our build machine &lt;code&gt;pgrep node&lt;/code&gt; found 0 of 5 Node processes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild images&lt;/strong&gt; with &lt;code&gt;--pull&lt;/code&gt; and redeploy. The official &lt;code&gt;node:22&lt;/code&gt;, &lt;code&gt;node:24&lt;/code&gt; and &lt;code&gt;node:26&lt;/code&gt; tags have the fix. &lt;code&gt;node:20&lt;/code&gt;, &lt;code&gt;node:25&lt;/code&gt; and &lt;code&gt;node:18&lt;/code&gt; will not get it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a service cannot be upgraded today&lt;/strong&gt; and accepts Brotli data from strangers, close that path in front of it (details below). For outbound &lt;code&gt;fetch()&lt;/code&gt; the fix is the upgrade; until then, turning off features that fetch user-supplied URLs is the way to close that path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the audit script&lt;/strong&gt; on every host and keep the output.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Shell access to the hosts that run Node, as the user that owns the Node processes. Root is not needed.&lt;/li&gt;
&lt;li&gt;Python 3.8 or newer for the audit script. It uses only the standard library and only reads.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;docker&lt;/code&gt; or &lt;code&gt;podman&lt;/code&gt; CLI on hosts that run Node in containers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was fixed
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases" rel="noopener noreferrer"&gt;advisory&lt;/a&gt; lists ten vulnerabilities. The four rated high:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CVE&lt;/th&gt;
&lt;th&gt;What the advisory calls it&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-111497&lt;/td&gt;
&lt;td&gt;Brotli decoder out-of-bounds write permits remote code execution&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-105483&lt;/td&gt;
&lt;td&gt;TLS certificate hostname bypass when the IP is written in fullwidth digits&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-106069&lt;/td&gt;
&lt;td&gt;TLS certificate hostname bypass when the host loosely resolves to an IP address&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-105482&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dns.resolve&lt;/code&gt; truncates names containing a NUL character&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The medium ones are a keep-alive socket that processes a response written before the request was sent (CVE-2026-105481), DNS SANs that can authenticate IP endpoints written in a non-canonical IPv4 form (CVE-2026-106072), and &lt;code&gt;crypto.randomUUID()&lt;/code&gt; and &lt;code&gt;randomInt()&lt;/code&gt; caches saved into startup snapshots and single executable applications, so that every process repeats the same values (CVE-2026-106074). The low ones are two Permission Model bypasses and a ZIP header issue on 26.x.&lt;/p&gt;

&lt;p&gt;The release also updates the bundled root certificates to NSS 3.131.1 and makes Node reject certificate chains that contain blocked public keys. The advisory lists this as the "ca-cert (eMudhra Intermediate CA Compromise)" update. The bundled undici, c-ares and OpenSSL (22 and 24) are updated too.&lt;/p&gt;

&lt;p&gt;Two things the advisory does not give you yet. There is no technical detail on any of the bugs, and on the morning of 22 October all ten CVE IDs were still in the RESERVED state at CVE.org, with no NVD record. So there is no CVSS score; "high" is the Node.js project's rating.&lt;/p&gt;

&lt;p&gt;The timeline in UTC, from the release metadata:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;21 Oct, 10:32 to 10:36&lt;/strong&gt; : the fixed binaries on nodejs.org/dist are dated in this window. The GitHub releases were published at 10:38.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10:48&lt;/strong&gt; : the update to the official Docker images was committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;about 11:35&lt;/strong&gt; : the Debian-based official images were built. Docker Hub shows the amd64 and arm64 images as pushed at about 14:40.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19:27&lt;/strong&gt; : the Brotli project released 1.2.1, 1.1.1 and 1.0.10, each with "CVE-2026-111497: out-of-bounds write permits remote code execution" in the notes. So the bug is in the Brotli decoder itself, and Node shipped the upstream fix as a cherry-picked commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;22:58 to 00:08&lt;/strong&gt; : the Alpine-based official images were built, about 12 to 13.5 hours after the release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who is exposed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Brotli: any Node process that decodes Brotli someone else chose
&lt;/h3&gt;

&lt;p&gt;The decoder is compiled into the &lt;code&gt;node&lt;/code&gt; binary and sits behind &lt;code&gt;node:zlib&lt;/code&gt;. Every way into it counts, and three are common:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your code decompresses Brotli directly&lt;/strong&gt; : &lt;code&gt;zlib.brotliDecompress()&lt;/code&gt;, &lt;code&gt;brotliDecompressSync()&lt;/code&gt; or &lt;code&gt;createBrotliDecompress()&lt;/code&gt; on uploads, archives, cache entries or messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your service fetches URLs.&lt;/strong&gt; &lt;code&gt;fetch()&lt;/code&gt; in Node is the bundled undici, and it decodes responses that say &lt;code&gt;Content-Encoding: br&lt;/code&gt; &lt;a href="https://github.com/nodejs/node/blob/v24.22.1/deps/undici/src/lib/web/fetch/index.js#L2256-L2260" rel="noopener noreferrer"&gt;with &lt;code&gt;zlib.createBrotliDecompress&lt;/code&gt;&lt;/a&gt;. It goes by the response's header, whatever the request asked for, so setting &lt;code&gt;Accept-Encoding: identity&lt;/code&gt; does not keep Brotli away from a hostile server. axios in Node &lt;a href="https://github.com/axios/axios/blob/v1.20.0/lib/adapters/http.js#L1179-L1209" rel="noopener noreferrer"&gt;does the same&lt;/a&gt; unless you pass &lt;code&gt;decompress: false&lt;/code&gt;. Link previews, webhook testers, feed readers, "import from URL" features and AI agents that browse are all services that fetch a URL a user chose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your service accepts compressed request bodies.&lt;/strong&gt; body-parser 2 &lt;a href="https://github.com/expressjs/body-parser/blob/v2.3.0/lib/read.js#L190-L224" rel="noopener noreferrer"&gt;decompresses &lt;code&gt;br&lt;/code&gt; request bodies&lt;/a&gt; that it parses, and its &lt;code&gt;inflate&lt;/code&gt; option defaults to &lt;code&gt;true&lt;/code&gt;. Express 5 ships body-parser 2, so &lt;code&gt;express.json()&lt;/code&gt; on Express 5 decodes Brotli bodies sent with a JSON content type, from any client. Express 4 uses body-parser 1.x, which handles only gzip and deflate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reaching the decoder is not the same as being exploitable, and the advisory does not say what input triggers the bug. These are the paths to upgrade or close first. Because the decoder is in the binary, no npm update fixes it. Upgrading the &lt;code&gt;undici&lt;/code&gt; package in your &lt;code&gt;package.json&lt;/code&gt;, for example, still decodes through the old Node's zlib.&lt;/p&gt;

&lt;p&gt;If you cannot upgrade a service today, the interim options close only the paths you control:&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;//&lt;/span&gt; &lt;span class="s"&gt;Express&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;refuse&lt;/span&gt; &lt;span class="s"&gt;compressed&lt;/span&gt; &lt;span class="s"&gt;request&lt;/span&gt; &lt;span class="s"&gt;bodies&lt;/span&gt; &lt;span class="s"&gt;(415)&lt;/span&gt; &lt;span class="s"&gt;until&lt;/span&gt; &lt;span class="s"&gt;Node&lt;/span&gt; &lt;span class="s"&gt;is&lt;/span&gt; &lt;span class="s"&gt;upgraded.&lt;/span&gt;
&lt;span class="n"&gt;//&lt;/span&gt; &lt;span class="s"&gt;This&lt;/span&gt; &lt;span class="s"&gt;also&lt;/span&gt; &lt;span class="s"&gt;refuses&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt; &lt;span class="s"&gt;and&lt;/span&gt; &lt;span class="s"&gt;deflate&lt;/span&gt; &lt;span class="s"&gt;request&lt;/span&gt; &lt;span class="s"&gt;bodies.&lt;/span&gt;
&lt;span class="s"&gt;app.use(express.json(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;inflate:&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;


&lt;span class="c1"&gt;# In front of any Node service: refuse Brotli request bodies.&lt;/span&gt;
&lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_content_encoding&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;"br")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;415&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;Distribution builds are different. Debian's &lt;code&gt;nodejs&lt;/code&gt; package (we checked the trixie build of 20.19.2) is built with &lt;code&gt;--shared-brotli&lt;/code&gt; against the system &lt;code&gt;libbrotli&lt;/code&gt;, so on Debian the Brotli fix comes from the distribution's brotli package, not from the Node version number. At the time we checked, neither Debian's security tracker nor Ubuntu's notices listed CVE-2026-111497 yet. The audit script reports such builds as unknown rather than guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  TLS hostname checks and DNS
&lt;/h3&gt;

&lt;p&gt;When Node connects over TLS it checks the server's certificate against the host you asked for. Both high-severity TLS bugs are in that check when the host is, or loosely resolves to, an IP address written in a non-standard form: fullwidth digits (24 and 26 only) or a form that "loosely resolves to an IP address" (all lines). The fix makes Node reject non-canonical IP addresses. The advisory gives no more detail than that. Our reading is that the services to look at first are the ones where someone else chooses the host string: webhook senders, URL fetchers and integrations that connect to customer endpoints.&lt;/p&gt;

&lt;p&gt;The DNS bug truncated names at a NUL byte in &lt;code&gt;dns.resolve*()&lt;/code&gt;, and the fix rejects such input. Code that resolves names it got from users is where this matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  End-of-life lines
&lt;/h3&gt;

&lt;p&gt;"End-of-Life versions are always affected when a security release occurs," says the advisory. Node 20 reached end of life on 30 April 2026 and Node 25 on 1 June 2026. Neither gets a fixed release, and the official &lt;code&gt;node:20&lt;/code&gt; and &lt;code&gt;node:25&lt;/code&gt; image tags stay on 20.20.2 and 25.9.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why changing the default moves nothing
&lt;/h2&gt;

&lt;p&gt;nvm installs the new version next to the old one, which is what you want: the old one stays for rollback. &lt;code&gt;nvm alias default 24.22.1&lt;/code&gt; then changes what &lt;strong&gt;new shells&lt;/strong&gt; get. Nothing that is already running reads that alias, and nothing that stored a path to the old install reads it either.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;nvm install 24.22.1&lt;/strong&gt; next to the old version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nvm alias default&lt;/strong&gt; new shells get it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What runs the new Node?&lt;/strong&gt; check /proc, not config&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;New shells, and pm2 apps restarted with --update-env from one&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pm2 daemon, cluster workers, absolute interpreters, systemd unit, cron, images: still old&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the production box, each of these needed its own step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pm2 entries with an absolute interpreter.&lt;/strong&gt; Several entries had the old Node binary written into them as the interpreter, and the ones started as &lt;code&gt;npm&lt;/code&gt; or &lt;code&gt;npx&lt;/code&gt; had their script path under the old version's &lt;code&gt;bin&lt;/code&gt; directory. Changing the default did not move them. They had to be deleted and recreated with the new interpreter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pm2 entries with a bare &lt;code&gt;node&lt;/code&gt; interpreter&lt;/strong&gt; took the new binary only after &lt;code&gt;pm2 restart &amp;lt;name&amp;gt; --update-env&lt;/code&gt; from a shell where the new Node came first in &lt;code&gt;PATH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pm2 systemd unit&lt;/strong&gt; that &lt;code&gt;pm2 startup&lt;/code&gt; wrote had the old version's &lt;code&gt;PATH&lt;/code&gt; and the old pm2 path hard-coded. After editing it, &lt;code&gt;pm2 update&lt;/code&gt; moved the pm2 daemon itself, which restarts every app at once. The sites on that box were down for under a minute while the Next.js apps started.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Child processes.&lt;/strong&gt; Checking &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/exe&lt;/code&gt; for every process turned up chains such as &lt;code&gt;npx&lt;/code&gt; to &lt;code&gt;tsx&lt;/code&gt; to &lt;code&gt;node&lt;/code&gt;, and &lt;code&gt;npm start&lt;/code&gt; to &lt;code&gt;next-server&lt;/code&gt;. Each process in a chain has its own binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs and helper scripts&lt;/strong&gt; on two other boxes had absolute paths to the old Node.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What pm2 really does on an upgrade
&lt;/h2&gt;

&lt;p&gt;To check each of those behaviors on its own, &lt;code&gt;lab/pm2-pin-lab.sh&lt;/code&gt; in the repo starts four throwaway apps in a separate &lt;code&gt;PM2_HOME&lt;/code&gt; from a shell where Node v24.14.0 comes first in &lt;code&gt;PATH&lt;/code&gt;: &lt;code&gt;bare&lt;/code&gt; (&lt;code&gt;pm2 start server.js&lt;/code&gt;), &lt;code&gt;via-npm&lt;/code&gt; (&lt;code&gt;pm2 start npm -- start&lt;/code&gt;, where the start script is &lt;code&gt;node server.js&lt;/code&gt;), &lt;code&gt;abs&lt;/code&gt; (&lt;code&gt;--interpreter&lt;/code&gt; set to the old binary) and &lt;code&gt;clustered&lt;/code&gt; (&lt;code&gt;-i 1&lt;/code&gt;, cluster mode). Then it puts v24.22.1 first in &lt;code&gt;PATH&lt;/code&gt;, as a changed nvm default does, and records the real binary of every process after each step. A plain &lt;code&gt;pm2 restart&lt;/code&gt; left all four on v24.14.0. This is the state after &lt;code&gt;pm2 restart &amp;lt;name&amp;gt; --update-env&lt;/code&gt; for each app:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pm2-pin-lab.sh, step B2&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# excerpt of results/pm2-lab-20261022T063002Z.txt (2026-10-22T06:30:02Z, aarch64, pm2 6.0.14)
# old: v24.14.0, new: v24.22.1 first in PATH; a plain restart left all four apps on v24.14.0
$ lab/pm2-pin-lab.sh &amp;lt;v24.14.0 bin&amp;gt; &amp;lt;v24.22.1 bin&amp;gt;
$ pm2 restart bare --update-env
$ pm2 restart via-npm --update-env
$ pm2 restart abs --update-env
$ pm2 restart clustered --update-env

== B2. pm2 restart --update-env from the new shell
daemon pid 1937570 node v24.14.0 &amp;lt;v24.14.0&amp;gt;/bin/node
bare interpreter=node script=&amp;lt;app&amp;gt;/server.js
           saved PATH finds node in &amp;lt;v24.22.1&amp;gt;/bin
  pid 1938284 node v24.22.1 &amp;lt;v24.22.1&amp;gt;/bin/node server.js
via-npm interpreter=&amp;lt;v24.14.0&amp;gt;/bin/node script=&amp;lt;v24.14.0&amp;gt;/bin/npm
           saved PATH finds node in &amp;lt;v24.22.1&amp;gt;/bin
  pid 1938375 node v24.14.0 &amp;lt;v24.14.0&amp;gt;/bin/node npm start
    pid 1938462 dash (not node) /usr/bin/dash sh -c node server.js
      pid 1938468 node v24.22.1 &amp;lt;v24.22.1&amp;gt;/bin/node node server.js
abs interpreter=&amp;lt;v24.14.0&amp;gt;/bin/node script=&amp;lt;app&amp;gt;/server.js
           saved PATH finds node in &amp;lt;v24.22.1&amp;gt;/bin
  pid 1938455 node v24.14.0 &amp;lt;v24.14.0&amp;gt;/bin/node server.js
clustered interpreter=node script=&amp;lt;app&amp;gt;/server.js (cluster mode)
           saved PATH finds node in &amp;lt;v24.22.1&amp;gt;/bin
  pid 1938518 node v24.14.0 &amp;lt;v24.14.0&amp;gt;/bin/node server.js

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bare&lt;/code&gt; moved. &lt;code&gt;via-npm&lt;/code&gt; is split: npm itself still runs on the old Node because pm2 stored the old binary as its interpreter, while its child &lt;code&gt;node server.js&lt;/code&gt; got the new one from &lt;code&gt;PATH&lt;/code&gt;. &lt;code&gt;abs&lt;/code&gt; did not move. &lt;code&gt;clustered&lt;/code&gt; did not move either, even though its saved &lt;code&gt;PATH&lt;/code&gt; now finds v24.22.1: in cluster mode the daemon forks the workers with its own Node binary, so they move only when the daemon moves. All four steps, from &lt;code&gt;results/pm2-lab-20261022T063002Z.txt&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;After &lt;code&gt;restart&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;After &lt;code&gt;restart --update-env&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;After delete and recreate&lt;/th&gt;
&lt;th&gt;After &lt;code&gt;pm2 update&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;bare&lt;/code&gt; (bare &lt;code&gt;node&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;not recreated: new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;via-npm&lt;/code&gt; (npm path stored)&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;npm old, app new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;abs&lt;/code&gt; (absolute interpreter)&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;clustered&lt;/code&gt; (cluster mode)&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;not recreated: old&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pm2 daemon&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;pm2 update&lt;/code&gt; restarted every app in one command: all four PIDs changed. On the production box that was the step where the sites went down. In the lab the command took 2.3 seconds; that measures the command, not how long the apps were unavailable, and real apps take as long as they take to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrade steps: nvm, pm2 and systemd
&lt;/h2&gt;

&lt;p&gt;Run these as the user that owns the pm2 daemon.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install next to the old version and bring global packages (pm2) along.&lt;/span&gt;
nvm &lt;span class="nb"&gt;install &lt;/span&gt;24.22.1 &lt;span class="nt"&gt;--reinstall-packages-from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24.14.0
nvm &lt;span class="nb"&gt;alias &lt;/span&gt;default 24.22.1

&lt;span class="c"&gt;# 2. Work from a shell where the new Node comes first in PATH.&lt;/span&gt;
nvm use 24.22.1
&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="c"&gt;# 3. See what each pm2 entry is pinned to.&lt;/span&gt;
pm2 jlist | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json, sys
for a in json.load(sys.stdin):
    e = a["pm2_env"]
    print(a["name"], e.get("exec_mode"), e.get("exec_interpreter"), e.get("pm_exec_path"), sep=" ")'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then move each entry according to what step 3 shows:&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;# Fork mode, interpreter "node": pick up the new PATH.&lt;/span&gt;
pm2 restart api &lt;span class="nt"&gt;--update-env&lt;/span&gt;

&lt;span class="c"&gt;# Interpreter is an absolute path to the old node, or the script is&lt;/span&gt;
&lt;span class="c"&gt;# .../v24.14.0/bin/npm or npx: delete and start it again from this shell&lt;/span&gt;
&lt;span class="c"&gt;# (or from your ecosystem file with the same name and arguments).&lt;/span&gt;
pm2 delete web
pm2 start npm &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;--&lt;/span&gt; start

&lt;span class="c"&gt;# Save the list that a reboot brings back.&lt;/span&gt;
pm2 save

&lt;span class="c"&gt;# Regenerate the startup unit: the old one hard-codes the old PATH and pm2 path.&lt;/span&gt;
pm2 unstartup &lt;span class="c"&gt;# prints a sudo command; run it&lt;/span&gt;
pm2 startup &lt;span class="c"&gt;# prints a sudo command; run it&lt;/span&gt;

&lt;span class="c"&gt;# Move the daemon, and every cluster-mode app with it.&lt;/span&gt;
&lt;span class="c"&gt;# This restarts ALL apps at once: do it in a quiet window.&lt;/span&gt;
pm2 update

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pm2 docs ask for the &lt;a href="https://pm2.keymetrics.io/docs/usage/startup/" rel="noopener noreferrer"&gt;&lt;code&gt;unstartup&lt;/code&gt; and &lt;code&gt;startup&lt;/code&gt; pair&lt;/a&gt; after every Node upgrade. The new unit is pinned again, this time to the new version. That is fine, as long as something checks it at the next upgrade.&lt;/p&gt;

&lt;p&gt;Two details are easy to miss. &lt;code&gt;pm2 save&lt;/code&gt; writes the saved list, including each entry's environment, so if you save before &lt;code&gt;--update-env&lt;/code&gt; the reboot copy keeps the old &lt;code&gt;PATH&lt;/code&gt;. And the daemon only reads the systemd unit when systemd starts it. If someone started pm2 by hand, the fixed unit applies at the next boot, not now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cron jobs and helper scripts
&lt;/h2&gt;

&lt;p&gt;Cron runs with a minimal &lt;code&gt;PATH&lt;/code&gt;, so jobs that need Node usually name it by absolute path, and that path names one install. The audit script below reads only your own &lt;code&gt;crontab -l&lt;/code&gt;, so search the system crontabs and your scripts on every host too:&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;# Paths inside a versioned nvm, fnm, n, volta or asdf install&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'/(\.nvm/versions/node|node-versions|n/versions/node|\.volta/tools/image/node|installs/nodejs)/v?[0-9]+\.[0-9]+\.[0-9]+'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/crontab /etc/cron.d /var/spool/cron /etc/systemd/system /usr/local/bin ~/bin 2&amp;gt;/dev/null

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the path in each match. A job that points at a version you later uninstall stops running, which at least fails loudly; one that points at the old version you kept for rollback keeps running vulnerable and says nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers
&lt;/h2&gt;

&lt;p&gt;A container keeps the Node it was built with. Rebuild with &lt;code&gt;--pull&lt;/code&gt; so the builder fetches the new base image instead of a cached one, deploy by the new tag or digest, and check what is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--pull&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; registry.example.com/app:2026-10-22 &lt;span class="nb"&gt;.&lt;/span&gt;

docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.ID}} {{.Names}}'&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;name&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-30s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; node &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;node --version&lt;/code&gt; inside a container reports the &lt;code&gt;node&lt;/code&gt; on the container's &lt;code&gt;PATH&lt;/code&gt;. If an image carries more than one Node, &lt;code&gt;docker exec &amp;lt;id&amp;gt; readlink /proc/1/exe&lt;/code&gt; shows which binary the container's main process runs (when the image has &lt;code&gt;readlink&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The repo's &lt;code&gt;registry_scan.py&lt;/code&gt; read the Node version of 24 official &lt;code&gt;node&lt;/code&gt; tags for amd64 and arm64 from registry metadata only (manifest and image config, no layers) at 06:23 UTC on 22 October. Every 22, 24 and 26 tag we sampled, and the &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;lts&lt;/code&gt; and &lt;code&gt;alpine&lt;/code&gt; aliases, shipped the fixed version. How long they took:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Official node images: hours from the release to build and to push&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Debian-based amd64: built&lt;/td&gt;
&lt;td&gt;1 h&lt;/td&gt;
&lt;td&gt;built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian-based amd64: pushed&lt;/td&gt;
&lt;td&gt;4.1 h&lt;/td&gt;
&lt;td&gt;pushed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian-based arm64: built&lt;/td&gt;
&lt;td&gt;1 h&lt;/td&gt;
&lt;td&gt;built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian-based arm64: pushed&lt;/td&gt;
&lt;td&gt;4.1 h&lt;/td&gt;
&lt;td&gt;pushed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine amd64: built&lt;/td&gt;
&lt;td&gt;12.4 h&lt;/td&gt;
&lt;td&gt;built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine amd64: pushed&lt;/td&gt;
&lt;td&gt;13.1 h&lt;/td&gt;
&lt;td&gt;pushed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine arm64: built&lt;/td&gt;
&lt;td&gt;13.5 h&lt;/td&gt;
&lt;td&gt;built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine arm64: pushed&lt;/td&gt;
&lt;td&gt;16.1 h&lt;/td&gt;
&lt;td&gt;pushed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Reference: fixed binaries on nodejs.org/dist by 10:36 UTC on 21 Oct 2026. Built: the image config's created time. Pushed: Docker Hub's last_pushed time for that platform image, which is metadata, not proof of when a tag first served it. Latest value in each group of tags. Source: results/registry-20261022T062338Z-summary.txt.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you rebuilt an Alpine-based image on the afternoon of 21 October, check it again: by this metadata the fixed Alpine images were built 12 to 13.5 hours after the release. The end-of-life tags will stay where they are: &lt;code&gt;node:18&lt;/code&gt; (18.20.8, built March 2025), &lt;code&gt;node:20&lt;/code&gt; (20.20.2, built April 2026) and &lt;code&gt;node:25&lt;/code&gt; (25.9.0, built April and May 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify with /proc, not with names
&lt;/h2&gt;

&lt;p&gt;pm2 renames its daemon to &lt;code&gt;PM2 v6.0.14: God Daemon&lt;/code&gt; and Next.js renames its server to &lt;code&gt;next-server&lt;/code&gt;, and on our build machine the plain &lt;code&gt;node&lt;/code&gt; processes showed up as &lt;code&gt;MainThread&lt;/code&gt;. Those names are what &lt;code&gt;pgrep&lt;/code&gt; matches and what &lt;code&gt;top&lt;/code&gt; shows. On that machine &lt;code&gt;pgrep -c node&lt;/code&gt; returned 0 while five processes ran a &lt;code&gt;node&lt;/code&gt; binary (&lt;code&gt;results/pgrep-vs-proc-20261022T063334Z.txt&lt;/code&gt;). The kernel's link to the executable does not lie:&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;# Every process whose binary is node, with the binary it really runs (root sees all users)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;d &lt;span class="k"&gt;in&lt;/span&gt; /proc/[0-9]&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;exe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/exe"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;exe&lt;/span&gt;&lt;span class="p"&gt;% (deleted)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;/node|&lt;span class="k"&gt;*&lt;/span&gt;/nodejs&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-8s %-70s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="p"&gt;#/proc/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$exe&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;/cmdline"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-50&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# The version of one of them, read from the binary without running it.&lt;/span&gt;
&lt;span class="c"&gt;# Official builds compile in their headers URL. Do not run another user's&lt;/span&gt;
&lt;span class="c"&gt;# binary with --version as root: that runs their code as root.&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'node-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?-headers\.tar\.gz'&lt;/span&gt; /proc/12345/exe
&lt;span class="c"&gt;# node-v24.14.0-headers.tar.gz&lt;/span&gt;

&lt;span class="c"&gt;# Its children, which have their own binaries&lt;/span&gt;
ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,args &lt;span class="nt"&gt;--forest&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ps &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;sid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 12345 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exe that ends in &lt;code&gt;(deleted)&lt;/code&gt; means the file was replaced on disk after the process started, so the process still runs the old code until it restarts. For more on that pattern, and on proving a runtime is patched when the version string can mislead, see &lt;a href="https://dev.to/devopsdaily/php2shell-how-to-check-that-every-php-you-run-is-actually-patched-3574-temp-slug-3364541"&gt;PHP2Shell: How to Check That Every PHP You Run Is Actually Patched&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the audit script
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/node-runtime-audit" rel="noopener noreferrer"&gt;The-DevOps-Daily/node-runtime-audit on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node_runtime_audit.py&lt;/code&gt; is one file of standard-library Python. Its sources are &lt;code&gt;/proc&lt;/code&gt;, the output of &lt;code&gt;pm2 jlist&lt;/code&gt; and &lt;code&gt;crontab -l&lt;/code&gt;, systemd unit files, and &lt;code&gt;node --version&lt;/code&gt; inside running containers; besides those it only runs &lt;code&gt;systemctl show&lt;/code&gt; for pm2 units and &lt;code&gt;docker&lt;/code&gt; or &lt;code&gt;podman&lt;/code&gt; &lt;code&gt;ps&lt;/code&gt; and &lt;code&gt;inspect&lt;/code&gt;, and looks at file metadata. For every running process it reads the real binary through &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/exe&lt;/code&gt; and the version compiled into that file, which works for deleted binaries and for processes inside containers. By default it never runs a binary it finds on the host, and it does not open the Node binaries that pm2 entries, crontabs and units name: their version comes from a running process that uses the same file, or from the path as a hint, where a path that names an unfixed version counts as vulnerable and one that names a fixed version stays unknown. Files are pinned with &lt;code&gt;O_PATH&lt;/code&gt; and checked before they are read, so a planted FIFO or device cannot stall it. Command lines and container names are printed with control characters escaped. Run it as the user that owns the Node processes; root is not needed.&lt;/p&gt;

&lt;p&gt;This is the real output on the Raspberry Pi we build on, run as the user that owns the Node processes, with &lt;code&gt;--redact&lt;/code&gt;, which hides user names and app names. By the time of this run, the fnm default, the crontab and the pm2 systemd unit had already been moved to v24.22.1:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;node_runtime_audit.py on the Pi&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 2026-10-22T07:10:33Z Raspberry Pi (Debian 12, aarch64), Node via fnm, pm2 6.0.14, run as the user that owns the Node processes
$ python3 node_runtime_audit.py --redact
node-runtime-audit: Node.js security release of 2026-10-21, fixed releases v22.23.4, v24.22.1, v26.12.1
lines without a fix (end-of-life) count as vulnerable
note: covers the processes, crontab and pm2 of USER; run it as each user that owns Node processes

Running Node processes (real binary from /proc/&amp;lt;pid&amp;gt;/exe)
  [vulnerable] pid 1883303 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
      cmd: PM2 v6.0.14: God Daemon (/home/USER/.pm2)
      parents: systemd(1)
      why: v24.14.0 &amp;lt; v24.22.1
  [vulnerable] pid 1883371 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
      cmd: node /home/USER/projects/app-1/node_modules/.bin/next dev
      parents: bun(1883370) &amp;lt;- node(1883303) &amp;lt;- systemd(1)
      why: v24.14.0 &amp;lt; v24.22.1
  [vulnerable] pid 1883390 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
      cmd: next-server (v16.2.6)
      parents: node(1883371) &amp;lt;- bun(1883370) &amp;lt;- node(1883303) &amp;lt;- systemd(1)
      why: v24.14.0 &amp;lt; v24.22.1
  [vulnerable] pid 1884634 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
      cmd: node /home/USER/projects/app-1/.next/dev/build/postcss.js 38963
      parents: node(1883390) &amp;lt;- node(1883371) &amp;lt;- bun(1883370) &amp;lt;- node(1883303)
      why: v24.14.0 &amp;lt; v24.22.1
  183 processes of other users not inspected: run the script as their owner

pm2 live list (pm2 jlist, PM2_HOME=/home/USER/.pm2)
  [unknown] pm2 CLI /home/USER/.bun/bin/pm2 is writable by group or others (mode 777): not run, live list skipped
  [vulnerable] daemon pid 1883303 v24.14.0 /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node

Installed Node versions (version managers)
  fnm in /home/USER/.local/share/fnm
      installed: v24.14.0 (running), v24.14.1 (from path), v24.22.1 (from path)
      default alias -&amp;gt; v24.22.1 (from path)
  `node` on this shell's PATH -&amp;gt; v24.22.1 (from path) /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node
  for information only: these do not change the exit code

Crontab: absolute Node paths
  [unknown] crontab -l (USER):33
      /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -&amp;gt; v24.22.1 (from path) (pinned to one install)
      why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)
  [unknown] crontab -l (USER):53
      /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -&amp;gt; v24.22.1 (from path) (pinned to one install)
      why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)

systemd units: absolute Node paths
  [unknown] /etc/systemd/system/pm2-USER.service:12
      /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin -&amp;gt; v24.22.1 (from path) (pinned to one install)
      why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)
      unit: pm2-USER.service ActiveState=inactive MainPID=0
      note: the unit is not what runs pm2 now; its PATH applies at the next boot

Containers
  no docker or podman CLI found

result: VULNERABLE: something runs or will start an unfixed Node (exit 1)
$ echo $?
1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fnm default, the shell's &lt;code&gt;node&lt;/code&gt;, both cron jobs and the systemd unit point at the v24.22.1 install. The script reports the cron and unit paths as unknown rather than patched, because no running process uses that binary and it does not open it. The four processes that run are v24.14.0. The pm2 daemon is not running under the systemd unit (ActiveState=inactive), so the unit we had fixed is not what runs it now. The one pm2 app is started by &lt;code&gt;bun&lt;/code&gt;, and the Node processes under it run the app's &lt;code&gt;node_modules/.bin/next&lt;/code&gt; script. A separate check (&lt;code&gt;scripts/pm2-path-evidence.py&lt;/code&gt;, &lt;code&gt;results/pm2-path-20261022T070504Z.txt&lt;/code&gt;) shows that the process the pm2 daemon started and the saved pm2 list both have the v24.14.0 install first on &lt;code&gt;PATH&lt;/code&gt;, which is where those Node processes come from. If the app is resurrected from that saved list after a reboot, it gets the same &lt;code&gt;PATH&lt;/code&gt;; we did not reboot to confirm. The fix here is &lt;code&gt;pm2 restart &amp;lt;app&amp;gt; --update-env&lt;/code&gt; from a fixed shell, then &lt;code&gt;pm2 save&lt;/code&gt;, then moving the daemon.&lt;/p&gt;

&lt;p&gt;The run found one more thing: the &lt;code&gt;pm2&lt;/code&gt; CLI on that machine is world-writable (mode 777). The script will not run a CLI that someone else could have changed, so it skipped the live pm2 list and reported it as unknown. Check the permissions of your Node installs and global CLIs while you are in there.&lt;/p&gt;

&lt;p&gt;The exit code is 1 when anything runs or will start a vulnerable Node, otherwise 2 when anything could not be checked, and 0 when it found nothing vulnerable or unknown within what it covers, including no Node at all. Unknown never counts as patched: a pre-release build, a distribution build linked to the system Brotli, a binary without an embedded version, a unit it could not read, a failed &lt;code&gt;pm2 jlist&lt;/code&gt;, &lt;code&gt;crontab -l&lt;/code&gt; or &lt;code&gt;docker ps&lt;/code&gt;, a config path that only names a fixed version, or a cron path to a Node that no longer exists all give 2. It only sees what the user running it can see, so run it as each user that owns Node processes. To run it across hosts without copying it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in &lt;/span&gt;deploy@web1 deploy@web2 deploy@web3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'python3 - --json'&lt;/span&gt; &amp;lt; node_runtime_audit.py &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"node-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;h&lt;/span&gt;&lt;span class="p"&gt;#*@&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt; exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests build fake hosts with the cases a version check gets wrong: a fake &lt;code&gt;node&lt;/code&gt; that would leave a marker file if the script ever ran it, a binary replaced while it runs, a container binary, a Debian-style build, a pm2 entry whose config is fixed but whose process was never restarted, npm pinned to the old install while its child gets the new one, a cluster-mode app, an old binary in a directory named after a fixed version, end-of-life and pre-release versions, a cron job on a removed version, a shell wrapper that mentions a fixed version, units owned by other users, and FIFOs, devices, hard links and symlinks planted where a file is expected. One test starts a real process, deletes its binary and checks that the script still reads the running file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;

&lt;p&gt;Keep the old version installed until every check passes. Rolling back is the same work in reverse: &lt;code&gt;nvm alias default 24.14.0&lt;/code&gt;, then the same &lt;code&gt;--update-env&lt;/code&gt;, delete-and-recreate, &lt;code&gt;pm2 save&lt;/code&gt;, startup unit and &lt;code&gt;pm2 update&lt;/code&gt; steps from a shell where the old version comes first. For containers, redeploy the previous image digest. A rollback puts the vulnerable version back, so treat it as a few hours to fix a regression, not as a resting state.&lt;/p&gt;

&lt;p&gt;Once the audit shows nothing on the old version, &lt;code&gt;nvm uninstall 24.14.0&lt;/code&gt;. Anything still pinned to it will then fail to start instead of quietly running the old code, and the audit script reports those paths as missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bugs themselves.&lt;/strong&gt; The advisory gives no technical detail, and we did not test whether any code path reaches the Brotli flaw. The exposure section is based on which code calls the decoder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scores.&lt;/strong&gt; The CVE records were still reserved when we checked, so there is no CVSS score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution packages.&lt;/strong&gt; Debian and Ubuntu had not published anything for CVE-2026-111497 when we checked. Check your distribution's &lt;code&gt;nodejs&lt;/code&gt; and &lt;code&gt;brotli&lt;/code&gt; advisories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Containers on a real host.&lt;/strong&gt; The Pi has no Docker, so the container checks are covered by the test fixtures only. The registry scan read real image metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The downtime number.&lt;/strong&gt;"Under a minute" comes from the production upgrade. The lab's &lt;code&gt;pm2 update&lt;/code&gt; took 2.3 seconds because its apps do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bare &lt;code&gt;node&lt;/code&gt; in cron jobs and units.&lt;/strong&gt; The script checks absolute paths and &lt;code&gt;PATH=&lt;/code&gt; lines; it does not resolve a bare &lt;code&gt;node&lt;/code&gt; through the job's &lt;code&gt;PATH&lt;/code&gt;. It reads only your own &lt;code&gt;crontab -l&lt;/code&gt;, not the system crontabs or the saved pm2 list, and it does not follow &lt;code&gt;EnvironmentFile=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other platforms.&lt;/strong&gt; The registry scan covers linux/amd64 and linux/arm64 only. The script needs Linux and &lt;code&gt;/proc&lt;/code&gt;, and does not cover Kubernetes, stopped containers, or Node binaries renamed to something other than &lt;code&gt;node&lt;/code&gt; (single executable applications, Electron).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt; Every host: Node 22.23.4, 24.22.1 or 26.12.1 or later installed; Node 20, 25 and older replaced&lt;/li&gt;
&lt;li&gt; Every pm2 entry: absolute interpreters and npm or npx script paths recreated, bare &lt;code&gt;node&lt;/code&gt; entries restarted with &lt;code&gt;--update-env&lt;/code&gt;, then &lt;code&gt;pm2 save&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; pm2 startup unit regenerated (&lt;code&gt;pm2 unstartup&lt;/code&gt;, &lt;code&gt;pm2 startup&lt;/code&gt;) and the daemon moved with &lt;code&gt;pm2 update&lt;/code&gt; in a quiet window&lt;/li&gt;
&lt;li&gt; Cluster-mode apps checked after the daemon moved&lt;/li&gt;
&lt;li&gt; Crontabs, systemd units and helper scripts searched for absolute paths to the old install&lt;/li&gt;
&lt;li&gt; Images rebuilt with &lt;code&gt;--pull&lt;/code&gt; (Alpine-based ones checked after the late rebuild) and redeployed by new tag or digest&lt;/li&gt;
&lt;li&gt; Services that fetch user-supplied URLs or accept compressed bodies upgraded first; interim measures where they cannot be&lt;/li&gt;
&lt;li&gt; Every Node process checked by &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/exe&lt;/code&gt;, not by name, including child processes&lt;/li&gt;
&lt;li&gt; Node installs and global CLIs (pm2, npm) writable only by their owner&lt;/li&gt;
&lt;li&gt; Audit script output saved per host, and the old version uninstalled once it is clean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every fact in this post is listed with its source in the repo's &lt;code&gt;SOURCES.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/node-october-2026-security-release-find-every-node" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>node</category>
      <category>security</category>
      <category>pm2</category>
    </item>
    <item>
      <title>A Service Catalog Nobody Updates Can Be Worse Than None: We Checked 217 Entries Against GitHub</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/a-service-catalog-nobody-updates-can-be-worse-than-none-we-checked-217-entries-against-github-1do7</link>
      <guid>https://dev.to/devopsdaily/a-service-catalog-nobody-updates-can-be-worse-than-none-we-checked-217-entries-against-github-1do7</guid>
      <description>&lt;p&gt;We took 217 service catalog entries that 30 organizations publish on GitHub, in the Backstage &lt;code&gt;catalog-info.yaml&lt;/code&gt; format, and checked each one against what GitHub says today. For one in ten (21 entries), GitHub contradicted a record: in 9 the catalog entry itself was wrong (a repository renamed while the catalog kept the old name, a repository reference without its owner, a dead link, no owner at all), and in 12 the CODEOWNERS file next to it named people or teams GitHub no longer accepts. That is a floor, not a total. For three entries in four, the owner could not be checked at all from outside the organization. And where the catalog and CODEOWNERS both named an owner for the same file, the two agreed, with every owner resolving, only half the time.&lt;/p&gt;

&lt;p&gt;That matters most at the worst moment. During an incident, someone opens the catalog to find out who owns the failing service. With no entry, they go and ask. With a wrong entry, they can page a team that was reorganized away, or a person who left, and do it with confidence because the catalog said so. We did not measure that happening; it is the reason the measurement is worth doing. This post shows the checker we built, how we picked the sample, what it found, the bugs two reviews found in our own method, and the habits that keep a catalog true.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;217 Component entries from 214 public repositories in 30 GitHub organizations, checked on 2026-10-22: &lt;strong&gt;21 (9.7%) had at least one record GitHub contradicts&lt;/strong&gt;, 9 of them in the catalog entry itself and 12 only in the CODEOWNERS file next to it. 62 (28.6%) had only warnings, and 134 (61.8%) had nothing we could find.&lt;/li&gt;
&lt;li&gt;Ownership is where the records disagree. Of 73 entries whose catalog file is also covered by a CODEOWNERS rule, 37 agree and every owner resolves. 14 name owners that GitHub cannot resolve, and 22 name different owners (in 7 of those, one team name contains the other, which may be the same team).&lt;/li&gt;
&lt;li&gt;159 entries (73.3%) have an owner nobody outside the org can check: GitHub teams are only visible to members, and some owners come from other directories. With a token that can read your org, the script checks teams and members directly.&lt;/li&gt;
&lt;li&gt;Links barely rotted: 2 of 152 were dead. But only 70 entries had any link, and none was titled as a runbook or a dashboard. We could not test the links that probably matter most during an incident.&lt;/li&gt;
&lt;li&gt;We expected older catalog files to drift more. Drift was 8.0% for files changed in the last year and 11.4% for older ones, a gap this sample cannot tell apart from chance.&lt;/li&gt;
&lt;li&gt;The fixes are boring and they work: derive owners from the source of truth, check &lt;code&gt;catalog-info.yaml&lt;/code&gt; in CI, use GitHub's own CODEOWNERS validation, and retire entries nobody claims.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Python 3.10 or later (the checker uses only the standard library)&lt;/li&gt;
&lt;li&gt;A GitHub token in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, or a logged-in &lt;code&gt;gh&lt;/code&gt; CLI. To check owners inside your own org, the token needs to read the org's teams and members&lt;/li&gt;
&lt;li&gt;A catalog in the Backstage &lt;code&gt;catalog-info.yaml&lt;/code&gt; format, or the public sample in the repository&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/service-catalog-drift" rel="noopener noreferrer"&gt;The-DevOps-Daily/service-catalog-drift on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a catalog drifts
&lt;/h2&gt;

&lt;p&gt;A catalog entry is usually written once, by a scaffolder template, on the day the repository is created. After that, teams get reorganized and renamed, people move on, repositories are renamed or transferred, and documentation moves from one wiki to the next. None of those changes touch &lt;code&gt;catalog-info.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of them break anything either. Backstage's own &lt;a href="https://backstage.io/docs/features/software-catalog/faq" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;, answering whether a plugin should reject entities that fail validation, says: "Never throw errors due to "soft" errors, in particular relations not matching an existing target". So an owner that points at a team that no longer exists loads and shows up like any other. GitHub &lt;a href="https://docs.github.com/en/repositories/creating-and-managing-repositories/renaming-a-repository" rel="noopener noreferrer"&gt;redirects a renamed repository&lt;/a&gt;, so a catalog that still uses the old name keeps working, and nobody has a reason to look. The catalog stays green while it drifts away from the truth.&lt;/p&gt;

&lt;p&gt;That is why a stale catalog can be worse than none. An empty owner field sends you looking. A wrong one can send you to the wrong place, quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checker compares
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;drift.py&lt;/code&gt; reads Backstage &lt;code&gt;Component&lt;/code&gt; entities and checks five things against GitHub, all read-only:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Drift (GitHub contradicts it)&lt;/th&gt;
&lt;th&gt;Warning (look at it)&lt;/th&gt;
&lt;th&gt;Cannot tell from outside&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;owner&lt;/td&gt;
&lt;td&gt;no &lt;code&gt;spec.owner&lt;/code&gt;; with org access, a team that does not exist or has no members&lt;/td&gt;
&lt;td&gt;a team GitHub cannot resolve in the CODEOWNERS files that name it; a &lt;code&gt;user:&lt;/code&gt; with no GitHub account of that name&lt;/td&gt;
&lt;td&gt;a team no CODEOWNERS file names; a user who is not a public member; a group in another directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repo&lt;/td&gt;
&lt;td&gt;the slug redirects (renamed or transferred); a &lt;code&gt;github.com/project-slug&lt;/code&gt; that is not &lt;code&gt;owner/repo&lt;/code&gt;; archived while the catalog does not say &lt;code&gt;deprecated&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no human commit on the default branch in a year; a root catalog file that describes a different repository&lt;/td&gt;
&lt;td&gt;a repository that is not visible (deleted or private)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;codeowners&lt;/td&gt;
&lt;td&gt;the CODEOWNERS rule for the catalog file names owners GitHub's validation cannot resolve&lt;/td&gt;
&lt;td&gt;CODEOWNERS and the catalog name different owners&lt;/td&gt;
&lt;td&gt;GitHub's validation not readable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;links&lt;/td&gt;
&lt;td&gt;404 or 410; a GitHub file missing at a ref that exists&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;401, 403, 429, 5xx, timeouts, internal hostnames, pages below a GitHub repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lifecycle&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;production&lt;/code&gt; or &lt;code&gt;experimental&lt;/code&gt; with no human commit in a year; no lifecycle&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;catalog-info.yaml&lt;/strong&gt; Component entities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner&lt;/strong&gt; teams API or CODEOWNERS errors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository&lt;/strong&gt; redirects, archive, commits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links&lt;/strong&gt; public addresses only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt; reasons and exit code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A drift on the CODEOWNERS check is about the file that routes code reviews, not about the catalog entry itself. We count both because both are ownership records people rely on, but we report them separately below. A warning is a fact worth a look: a library that works may not need a commit for a year. "Cannot tell" is its own status and never counted as either.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking an owner from outside the org
&lt;/h3&gt;

&lt;p&gt;Inside your org, checking an owner is a lookup: does the team exist, does it have members, is this user a member. From outside, GitHub hides teams. But GitHub validates CODEOWNERS files, and it publishes the result. For a public repository, anyone can call &lt;a href="https://docs.github.com/en/rest/repos/repos#list-codeowners-errors-for-a-repository" rel="noopener noreferrer"&gt;&lt;code&gt;GET /repos/{owner}/{repo}/codeowners/errors&lt;/code&gt;&lt;/a&gt;, and it lists every owner GitHub cannot resolve, with messages such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unknown owner on line 3: make sure the team @org/team exists, is publicly visible, and has write access to the repository

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if any CODEOWNERS file in the org names the catalog's owner team, GitHub can tell us whether that team resolves. The checker looks in the entry's own repository first, then runs one code search for the team in the org's other CODEOWNERS files. A team that resolves exists. A team that does not resolve might be deleted, renamed, secret, or only lack write access there, so on the owner check it is a warning, not drift. A team that no CODEOWNERS file names stays "cannot tell". All of this assumes Backstage group names are GitHub team slugs, which is what Backstage's GitHub org provider produces; groups from other namespaces are not mapped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Links come from strangers
&lt;/h3&gt;

&lt;p&gt;The links in a catalog file are written by whoever wrote the file, so link checks are opt-in (&lt;code&gt;--check-links&lt;/code&gt;) and careful. The token only ever goes to &lt;code&gt;api.github.com&lt;/code&gt;, and github.com links are checked through the API with every path segment validated and quoted. Everything else gets a &lt;code&gt;HEAD&lt;/code&gt;, then a &lt;code&gt;GET&lt;/code&gt;, with no credentials and no cookies, at most one request per second per host. The host name is resolved once, every address must be public, and the request goes to that pinned address, so a second DNS answer cannot point it at an internal service. Every redirect hop is checked the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sample
&lt;/h2&gt;

&lt;p&gt;We wanted catalogs that real organizations maintain, not tutorials. The selection, all recorded in the repository:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub code search for &lt;code&gt;filename:catalog-info.yaml path:/&lt;/code&gt; (files up to 20,000 bytes) on 2026-10-22, between 07:08 and 07:23 UTC. Code search returns at most 1,000 results per query, so &lt;code&gt;sample.py&lt;/code&gt; splits the query by file size into 16 ranges. GitHub reported 8,864 matches; we fetched 9,761 unique repositories (code search counts are estimates).&lt;/li&gt;
&lt;li&gt;3,579 of those repositories belong to personal accounts and were dropped. The other 6,182 belong to 984 organizations. Code search returned no forks.&lt;/li&gt;
&lt;li&gt;53 organizations had a root &lt;code&gt;catalog-info.yaml&lt;/code&gt; in 20 or more repositories. Many turned out to be test, demo or QA organizations for developer portals, so we kept the 30 that have a website on their GitHub profile and at least one repository with 10 or more stars. That rule keeps out obvious test orgs; it does not prove that every sampled repository is a real service, and a few are demos or samples. The excluded orgs are stored as hashes, because many are named after a person.&lt;/li&gt;
&lt;li&gt;From each of the 30, 8 repositories at random (seed 20261022): 240 files. One was a scaffolder template, one was not valid YAML, and 24 held no Component entity, only other kinds. That leaves &lt;strong&gt;217 Component entries from 214 repositories&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 30 organizations are a mix of companies, public bodies, research institutes and open-source projects: bcgov, broadinstitute, cds-snc, Clever, contentful, cultureamp, DEFRA, dell, DiamondLightSource, dwp, eduNEXT, elastic, equinor, grafana, hmcts, Invoca, Kajabi, kartverket, open-dxp, openedx, pantheon-systems, prime-radiant-inc, radiorabe, reearth, sendbird, snyk, sourcefuse, spotify, Unity-Technologies and vtex-apps.&lt;/p&gt;

&lt;p&gt;Every organization contributed 8 repositories, but a repository can hold several entries or none, so organizations contribute between 1 and 10 entries. The shares in this post are pooled over entries. Averaged per organization instead, the drift share is 10.7% rather than 9.7%.&lt;/p&gt;

&lt;p&gt;So the population this describes is narrow: root-level &lt;code&gt;catalog-info.yaml&lt;/code&gt; files in public repositories of organizations that use them widely. Catalog files elsewhere in a repository, catalogs kept in a separate repository, and private catalogs are not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run
&lt;/h2&gt;

&lt;p&gt;One pass makes about 1,700 GitHub API requests, cached on disk per token for 24 hours. &lt;code&gt;--redact&lt;/code&gt; keeps people out of the output: user logins become &lt;code&gt;user:&amp;lt;redacted&amp;gt;&lt;/code&gt;, and owner and team names become a hash with a key that is random per run, which is why you see &lt;code&gt;group:&amp;lt;56b023&amp;gt;&lt;/code&gt; below and different hashes in the committed results. The results name organizations, repositories and services, never a person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;drift.py on the public sample&lt;/strong&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="nv"&gt;$ &lt;/span&gt;python3 drift.py &lt;span class="nt"&gt;--sample&lt;/span&gt; sample/sample-2026-10-22.json &lt;span class="nt"&gt;--check-links&lt;/span&gt; &lt;span class="nt"&gt;--redact&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; report.txt 2&amp;gt;/dev/null
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-7&lt;/span&gt; report.txt
217 entries from 238 files &lt;span class="o"&gt;(&lt;/span&gt;1 templates, 1 unparsed, 0 unreadable skipped&lt;span class="o"&gt;)&lt;/span&gt;
  drift 21 &lt;span class="o"&gt;(&lt;/span&gt;9.7%&lt;span class="o"&gt;)&lt;/span&gt;, warn 62, ok 134
  owner drift 1, ok 53, unverifiable 159, warn 4
  repo drift 6, ok 168, warn 43
  codeowners drift 14, n/a 144, ok 37, warn 22
  links drift 2, n/a 147, ok 63, unverifiable 5
  lifecycle n/a 38, ok 148, warn 31
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^(DRIFT|WARN) /{p=($2=="com.unity.services.samples.relationships"||$2=="changelog-github"||$2=="kaizen-design-system"||$2=="loopback4-microservice-catalog")} p'&lt;/span&gt; report.txt
DRIFT com.unity.services.samples.relationships &lt;span class="o"&gt;(&lt;/span&gt;Unity-Technologies/com.unity.services.samples.friends:catalog-info.yaml&lt;span class="o"&gt;)&lt;/span&gt; owner group:&amp;lt;56b023&amp;gt;
         repo drift Unity-Technologies/com.unity.services.samples.relationships moved to Unity-Technologies/com.unity.services.samples.friends
         codeowners drift CODEOWNERS line 2 names owners GitHub cannot resolve: @Unity-Technologies/&amp;lt;072a85&amp;gt;
DRIFT changelog-github &lt;span class="o"&gt;(&lt;/span&gt;cultureamp/changelog-github:catalog-info.yaml&lt;span class="o"&gt;)&lt;/span&gt; owner group:&amp;lt;ffb463&amp;gt;
         repo drift github.com/project-slug is not a valid owner/repo
DRIFT kaizen-design-system &lt;span class="o"&gt;(&lt;/span&gt;cultureamp/kaizen-design-system:catalog-info.yaml&lt;span class="o"&gt;)&lt;/span&gt; owner group:&amp;lt;159434&amp;gt;
         codeowners drift CODEOWNERS line 4 names owners GitHub cannot resolve: @cultureamp/&amp;lt;15544f&amp;gt;
         links drift 1 of 5 links dead: other https://cultureamp.design/&amp;lt;path 642625a6c6&amp;gt; &lt;span class="o"&gt;(&lt;/span&gt;HTTP 404&lt;span class="o"&gt;)&lt;/span&gt;
DRIFT loopback4-microservice-catalog &lt;span class="o"&gt;(&lt;/span&gt;sourcefuse/loopback4-microservice-catalog:catalog-info.yaml&lt;span class="o"&gt;)&lt;/span&gt; owner group:&amp;lt;046fce&amp;gt;
         owner warn GitHub cannot resolve @sourcefuse/&amp;lt;046fce&amp;gt; &lt;span class="k"&gt;in &lt;/span&gt;the CODEOWNERS files that name it &lt;span class="o"&gt;(&lt;/span&gt;deleted, renamed, secret, or without write access&lt;span class="o"&gt;)&lt;/span&gt;
         codeowners drift .github/CODEOWNERS line 5 names owners GitHub cannot resolve: @sourcefuse/&amp;lt;046fce&amp;gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is 1 because drift was found, so the same command can fail a CI job. The four entries above show the main kinds. The Unity sample was renamed from &lt;code&gt;relationships&lt;/code&gt; to &lt;code&gt;friends&lt;/code&gt;, its catalog entry still uses the old name, and line 2 of its CODEOWNERS names a team GitHub cannot resolve. cultureamp's changelog tool sets &lt;code&gt;github.com/project-slug&lt;/code&gt; to the repository name without its owner, which Backstage's GitHub integrations cannot use. In cultureamp's design system, a CODEOWNERS team does not resolve and the Storybook link answers 404. In sourcefuse's microservice catalog, the catalog's own owner team is the one GitHub cannot resolve in the same repository's CODEOWNERS: drift for the CODEOWNERS file, a warning for the owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What 217 public catalog entries got wrong&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CODEOWNERS names an owner GitHub cannot resolve (14)&lt;/td&gt;
&lt;td&gt;6.5%&lt;/td&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository renamed, catalog has the old name (4)&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;project-slug is not owner/repo (2)&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead link (2)&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No owner at all (1)&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No human commit in a year (39)&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;td&gt;Warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Says production or experimental, idle a year (28)&lt;/td&gt;
&lt;td&gt;12.9%&lt;/td&gt;
&lt;td&gt;Warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog and CODEOWNERS name different owners (22)&lt;/td&gt;
&lt;td&gt;10.1%&lt;/td&gt;
&lt;td&gt;Warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner team does not resolve where it is named (3)&lt;/td&gt;
&lt;td&gt;1.4%&lt;/td&gt;
&lt;td&gt;Warning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Share of all 217 entries with each finding; an entry can have more than one. 30 GitHub organizations, 8 random repositories each, checked 2026-10-22. Source: results/summary-20261022T091627Z.json in the demo repository.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Ownership is where the records disagree
&lt;/h3&gt;

&lt;p&gt;Most findings are about who owns what. The clearest view is the 73 entries whose catalog file is also covered by a CODEOWNERS rule, so there are two records of the owner to compare:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;summarize.py&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 summarize.py results/run-20261022T091627Z.json | sed -n '/^catalog vs CODEOWNERS/,/Fisher/p'
catalog vs CODEOWNERS: 73 entries with both; agree and resolve 37 (50.7%); unknown owners 14 (teams 8, users 6)
  disagree: 4 catalog names a team, CODEOWNERS people
  disagree: 11 different team names
  disagree: 7 one team name contains the other
drift by file age: under 1 year {'entries': 112, 'drift': 9, 'pct': 8.0}, 1 year or more {'entries': 105, 'drift': 12, 'pct': 11.4}, Fisher p = 0.493

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;73 entries with both a catalog owner and a CODEOWNERS rule&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agree, every owner resolves&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;Agree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One team name contains the other&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different team names&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog names a team, CODEOWNERS people&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CODEOWNERS owner GitHub cannot resolve&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Cannot resolve&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;"One name contains the other" is, for example, a short catalog name inside a longer GitHub team slug; it may be the same team, but the data cannot show it. Unresolvable owners are what GitHub's CODEOWNERS validation reports for the rule that covers the catalog file.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;37 agree. The other 36, almost half, either name different owners or name owners GitHub cannot resolve. In 7 of them one team name contains the other: in four snyk repositories, for example, the catalog uses a short team name that is part of the longer GitHub team slug in CODEOWNERS. Those may well be the same team. Leave them out and 29 of 73 (40%) still disagree or cannot be resolved. Of the 14 with unresolvable owners, 8 involve a team and 6 involve individual users.&lt;/p&gt;

&lt;p&gt;Two caveats keep this from being a verdict on the catalog. CODEOWNERS says who reviews code, which is not always who owns the service, so a disagreement means one record is stale or the two mean different things. And GitHub reports a team as unknown when it was deleted or renamed, is secret, or has no write access to the repository; any of those means the team does not review that code, but only the first two mean it is gone.&lt;/p&gt;

&lt;p&gt;Unresolvable owners repeat. In vtex-apps, the same unknown team sits on line 3 of the CODEOWNERS in three of the eight sampled repositories, which looks like a file copied from a template and never updated. Four organizations hold 14 of the 21 drifted entries, and 20 of the 30 had none.&lt;/p&gt;

&lt;p&gt;The owner field itself could be checked for only 58 entries (27%): 37 resolved in the entry's own CODEOWNERS, 15 in another repository's, 3 named a team GitHub could not resolve in any CODEOWNERS file that names it, 2 were checked through GitHub's user API, and 1 had no owner at all. Backstage &lt;a href="https://backstage.io/docs/features/software-catalog/descriptor-format" rel="noopener noreferrer"&gt;requires&lt;/a&gt; &lt;code&gt;spec.owner&lt;/code&gt; on a Component, so that entry probably never loaded into anyone's catalog. The 159 we could not check are 142 groups and 17 users: hidden teams, groups that do not look like GitHub teams, users who are not public members of the org, and user IDs that are not GitHub logins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repositories that moved
&lt;/h3&gt;

&lt;p&gt;4 entries (1.8%) point at a repository by a name it no longer has. GitHub redirects the old name, so links and API calls keep working. But GitHub's documentation warns that if anyone creates a new repository with the old name, "redirects to the renamed repository will no longer work", and the catalog entry then points at someone else's code. 2 more entries set &lt;code&gt;github.com/project-slug&lt;/code&gt; to a bare repository name without the owner, and 2 live in one repository but describe another, which is what a catalog file copied from a sibling repository and never edited looks like.&lt;/p&gt;

&lt;p&gt;We found no archived repositories. That is a gap in the sample, not good news: none of the 211 repositories the checker read was archived (each entry records the flag), a code search we tried with &lt;code&gt;archived:true&lt;/code&gt; returned nothing (so archived repositories are probably missing from the index), and Backstage's &lt;a href="https://backstage.io/docs/integrations/github/discovery" rel="noopener noreferrer"&gt;GitHub discovery&lt;/a&gt; skips them by default (&lt;code&gt;allowArchived&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Idle, but still "production"
&lt;/h3&gt;

&lt;p&gt;39 entries (18.0%) live in repositories with no human commit on the default branch for over a year, and 2 more where the last 30 commits are all from bots. We do not count commits authored by Dependabot, Renovate or other bots as activity. 28 of the 39 still say &lt;code&gt;production&lt;/code&gt; (19) or &lt;code&gt;experimental&lt;/code&gt; (9). A stable library can go a year without a change, so this is a warning, not drift. An &lt;code&gt;experimental&lt;/code&gt; service with no human commit for a year is a decision nobody made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Links
&lt;/h3&gt;

&lt;p&gt;Only 70 of 217 entries (32%) had any link at all. Of 152 links, 132 answered, 2 were dead, and 18 could not be checked: 12 pointed at hostnames that do not resolve in public DNS (internal tools, most likely), 5 answered 403, and 1 was a page below a GitHub repository that we do not check. The two dead ones were a Storybook page and a vulnerability report page on a vendor's site.&lt;/p&gt;

&lt;p&gt;None of the 152 links was titled as a runbook, an on-call schedule or a dashboard. Public catalogs may simply not publish them, or may title them differently; either way we could not test those links, and they are the ones you would want to work during an incident. If you run this on your own catalog, the link check is where we would expect your numbers to differ most from ours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a catalog decay with age?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Drift by when the catalog file last changed&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under 6 months (81)&lt;/td&gt;
&lt;td&gt;9.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 to 12 months (31)&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 to 2 years (48)&lt;/td&gt;
&lt;td&gt;12.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over 2 years (57)&lt;/td&gt;
&lt;td&gt;10.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Share of entries with at least one drift, by the date of the last commit that touched catalog-info.yaml. Under one year: 9 of 112. One year or more: 12 of 105. Fisher's exact test p = 0.49, which also treats entries as independent although entries from one organization share habits.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We expected a clear slope and did not find one: 8.0% for files changed in the last year against 11.4% for older files. Fisher's exact test gives p = 0.49, and it assumes the entries are independent, which they are not quite: entries from one organization share templates and habits. So this sample cannot tell the difference from chance. Drift and warnings together are far more common in older files (24.7% and 19.4% under a year, 54.2% and 54.4% over), but mostly by construction: a repository with no commits for a year also has a catalog file nobody touched for a year. The median catalog file in the sample was last changed 352 days before we checked it.&lt;/p&gt;

&lt;p&gt;A single snapshot also cannot see the worst decay. Entries that rotted badly enough to be noticed may have been deleted, and deleted entries are not in a sample of files that still exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs two reviews found in our checker
&lt;/h2&gt;

&lt;p&gt;The first version of the checker and of this post had problems, found by our own runs and by a second review of the code and the draft. Several changed numbers you just read, and some of the corrections made the result look worse, others better.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An unresolvable team was called a missing owner.&lt;/strong&gt; The first version reported a catalog owner as drift when GitHub could not resolve the team in a CODEOWNERS file, including one in another repository. But a team can exist and simply lack write access there. That is now a warning on the owner, and only the broken CODEOWNERS file counts as drift. Three entries moved from owner drift to a warning; two of them had no other drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An invalid repository reference was quietly replaced.&lt;/strong&gt; Two entries set &lt;code&gt;github.com/project-slug&lt;/code&gt; to a repository name without its owner. The first version fell back to the repository the file lives in and reported nothing. It now reports the annotation as drift, which added two drifted entries just as the first fix removed two. The headline stayed at 21 by coincidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A user ID that looks like a GitHub login.&lt;/strong&gt; One organization names users by an ID from its own directory, three letters and five digits, which is also a valid GitHub login. GitHub had no account by that name, and the first version reported a departed owner. A missing account is now a warning, unless you pass &lt;code&gt;--users-are-github-logins&lt;/code&gt; for a catalog where you know users are GitHub logins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub's two error formats.&lt;/strong&gt; For a user, the CODEOWNERS error says &lt;code&gt;make sure @user exists&lt;/code&gt;; for a team, &lt;code&gt;make sure the team @org/team exists, is publicly visible, ...&lt;/code&gt;. The first version only parsed the user form, and for teams it fell back to marking every owner on the line as unknown. It changed no result in this sample, but on another catalog it would have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A YAML reader that was too forgiving.&lt;/strong&gt; The checker has its own small YAML reader to stay dependency-free. One sampled file had &lt;code&gt;description:&lt;/code&gt; at the wrong indentation, which swallowed the &lt;code&gt;annotations:&lt;/code&gt; block below it. PyYAML rejects that file, and our reader accepted it. It now raises the same error, and a cross-check against PyYAML on all 240 files agrees on every field the checker reads, with the one invalid file rejected by both. The review also found that it decoded some escapes in quoted strings wrongly; it now handles them or refuses the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too generous with links and CODEOWNERS.&lt;/strong&gt; Any page below an existing GitHub repository counted as a working link, a redirect loop counted as an answer, &lt;code&gt;docs/*&lt;/code&gt; in CODEOWNERS also matched files in subdirectories (GitHub's own docs say it does not), and an unreadable CODEOWNERS validation could still report agreement. Each now reports "cannot tell" or matches GitHub's rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People in the output.&lt;/strong&gt; A bare owner such as &lt;code&gt;owner: someone&lt;/code&gt; is treated as a team by Backstage, but it is sometimes a person's login, and one owner was an email address. An early run printed them. That file was deleted, and owner and team names are now hashed with a key that is never written down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Smaller fixes: a link with a space in it stopped an early run; cached answers now expire after a day and are kept per token; CODEOWNERS lines with invalid syntax are skipped, as GitHub skips them; a file link is not called dead when its ref might contain a slash; and an error in one entry, or a 401 or 5xx from GitHub, makes the run exit with 2 instead of stopping or passing. None of these changed a count in this sample. Every superseded run that left a file is kept in &lt;code&gt;results/&lt;/code&gt; with a note on what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  False positives, and what we could not check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A team GitHub cannot resolve is not always deleted.&lt;/strong&gt; GitHub's message covers deleted, renamed, secret, and teams without write access to that repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different names can be the same people.&lt;/strong&gt; A parent team and a child team, or a Backstage group name that differs from the GitHub team slug, show up as a disagreement. That is why disagreement is a warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A link that answers is not always alive.&lt;/strong&gt; A 200 can be a login page, or a "page not found" that answers 200. We count both as working. A 403 from a site that blocks scripts is "cannot tell".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot detection is by commit author.&lt;/strong&gt; A merge commit by a person counts as human activity even when it merges a bot's change, so "no human commit in a year" can undercount idle repositories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner coverage is uneven.&lt;/strong&gt; We could check 27% of owners, mostly those named in some CODEOWNERS file. Those may be better or worse kept than the rest; we cannot tell which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private catalogs are not in the sample.&lt;/strong&gt; Public repositories may be kept better or worse than internal ones, and internal catalogs carry the runbook, on-call and dashboard links we could not test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to keep a catalog honest
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Derive owners, do not type them
&lt;/h3&gt;

&lt;p&gt;Many of the disagreements above look like two hand-typed names for the same thing. Load groups from the source of truth instead: Backstage's &lt;a href="https://backstage.io/docs/integrations/github/org" rel="noopener noreferrer"&gt;GitHub org provider&lt;/a&gt; mirrors GitHub teams as Groups on a schedule, and the catalog products sync from identity providers. Then the owner in &lt;code&gt;catalog-info.yaml&lt;/code&gt; and the handle in CODEOWNERS are the same string, and a machine can compare them:&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;# catalog-info.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backstage.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Component&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;payments-api&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;github.com/project-slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme/payments-api&lt;/span&gt; &lt;span class="c1"&gt;# owner/repo, not just the repository name&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service&lt;/span&gt;
  &lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;group:default/payments&lt;/span&gt; &lt;span class="c1"&gt;# the GitHub team slug, not a display name&lt;/span&gt;


&lt;span class="c1"&gt;# .github/CODEOWNERS: the same team, so the two can be checked against each other&lt;/span&gt;
&lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="s"&gt;acme/payments&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check the catalog file in CI
&lt;/h3&gt;

&lt;p&gt;Drift arrives two ways: someone edits the file badly, or the world changes while nobody touches it. A pull request check catches the first, a weekly scheduled run catches the second. The repository ships this workflow in &lt;code&gt;examples/&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catalog drift&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;catalog-info.yaml'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/CODEOWNERS'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CODEOWNERS'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;docs/CODEOWNERS'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drift&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;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@v5&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@v5&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;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The-DevOps-Daily/service-catalog-drift&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt; &lt;span class="c1"&gt;# pin this to a commit SHA you have read&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;.catalog-drift&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/setup-python@v6&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;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.13'&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;check catalog-info.yaml&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# The default token cannot read org teams; use a token with Members: read for full owner checks&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CATALOG_DRIFT_TOKEN || github.token }}&lt;/span&gt;
          &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
          &lt;span class="c1"&gt;# on a pull request, read CODEOWNERS as the pull request changes it&lt;/span&gt;
          &lt;span class="na"&gt;REF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha || github.sha }}&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;python .catalog-drift/drift.py catalog-info.yaml --repo "$REPO" --ref "$REF" --check-links --no-org-search --cache-hours &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits with 1 on drift or on a catalog file that does not parse, and with 2 when it could not check what it was given, so a broken run never passes quietly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use the check GitHub already runs
&lt;/h3&gt;

&lt;p&gt;You do not need our script to find the worst cases. GitHub already validates every CODEOWNERS file, and one call per repository lists what it cannot resolve:&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;# Owners GitHub cannot resolve in this repository's CODEOWNERS&lt;/span&gt;
gh api repos/OWNER/REPO/codeowners/errors &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.errors[] | "line \(.line): \(.message | split("\n")[0])"'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop it over every repository in your org once a month. Each line it prints is an owner or a rule GitHub cannot use, which can leave reviews without the reviewer you meant, and is often a sign that the catalog owner is stale too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give the scorecard consequences
&lt;/h3&gt;

&lt;p&gt;Every catalog product has scorecards, and so does Backstage through the Tech Insights plugin. The useful checks are the ones in this post: the owner resolves, the runbook link answers, the lifecycle matches the activity. Put the result where people already work, such as a comment on the pull request or a weekly list sent to each owning team, and not only on a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retire what nobody claims
&lt;/h3&gt;

&lt;p&gt;An entry whose owner does not resolve and whose repository has been idle for a year may have nobody to fix it. One workflow we would suggest: open an issue for the last known owner, and if nobody confirms ownership within 30 days, mark the entry &lt;code&gt;deprecated&lt;/code&gt;; remove it 30 days later unless someone claims it. A missing entry sends people asking. A wrong one can send them to the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build or buy
&lt;/h2&gt;

&lt;p&gt;Every catalog product handles freshness with some mix of the same three tools: sync from a source of truth, automatic discovery, and scorecards. Here is what each one's public documentation says, in alphabetical order, with one documented limit each where we found one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backstage&lt;/strong&gt; (open source) mirrors GitHub teams and users with its &lt;a href="https://backstage.io/docs/integrations/github/org" rel="noopener noreferrer"&gt;org provider&lt;/a&gt;, finds &lt;code&gt;catalog-info.yaml&lt;/code&gt; files with &lt;a href="https://backstage.io/docs/integrations/github/discovery" rel="noopener noreferrer"&gt;GitHub discovery&lt;/a&gt;, and removes &lt;a href="https://backstage.io/docs/features/software-catalog/life-of-an-entity" rel="noopener noreferrer"&gt;orphaned entities&lt;/a&gt;. Checks on catalog data, including the owner field, come from the &lt;a href="https://github.com/backstage/community-plugins/tree/main/workspaces/tech-insights/plugins/tech-insights-backend" rel="noopener noreferrer"&gt;Tech Insights&lt;/a&gt; community plugin. Limit: as quoted above, relations that point at nothing are accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cortex&lt;/strong&gt; has &lt;a href="https://docs.cortex.io/scorecards/scorecard-examples" rel="noopener noreferrer"&gt;scorecard rules&lt;/a&gt; such as "has an owner" and "has a runbook", syncs &lt;a href="https://docs.cortex.io/ingesting-data-into-cortex/entities/ownership" rel="noopener noreferrer"&gt;ownership&lt;/a&gt; daily from identity providers, Git hosts and HR systems, and &lt;a href="https://docs.cortex.io/ingesting-data-into-cortex/entities-overview/entities/archiving-entities/auto-archive" rel="noopener noreferrer"&gt;auto-archives&lt;/a&gt; entities whose repository was deleted or archived. Limit: in GitOps mode, a YAML file that is moved rather than deleted is not archived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Humanitec&lt;/strong&gt; says in its &lt;a href="https://developer.humanitec.com/platform-orchestrator/docs/humanitec-vs-others/overview/" rel="noopener noreferrer"&gt;own docs&lt;/a&gt; that it is not an internal developer portal: the Platform Orchestrator is the backend of a platform and &lt;a href="https://developer.humanitec.com/platform-orchestrator/docs/humanitec-vs-others/backstage-port-cortex-etc./" rel="noopener noreferrer"&gt;complements&lt;/a&gt; portals such as Backstage, Port and Cortex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpsLevel&lt;/strong&gt; has a &lt;a href="https://docs.opslevel.com/docs/getting-started-with-rubrics" rel="noopener noreferrer"&gt;component ownership check&lt;/a&gt; in its rubric, &lt;a href="https://docs.opslevel.com/docs/github-teams-sync" rel="noopener noreferrer"&gt;syncs GitHub teams&lt;/a&gt; every 12 hours, &lt;a href="https://docs.opslevel.com/docs/service-detection" rel="noopener noreferrer"&gt;detects services&lt;/a&gt; from repositories and suggests owners from commit authors, and &lt;a href="https://docs.opslevel.com/docs/repositories" rel="noopener noreferrer"&gt;removes repositories&lt;/a&gt; deleted at the Git provider. Limit: the team sync page says deleted GitHub teams and users are not synced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port&lt;/strong&gt; has &lt;a href="https://docs.port.io/governance/standards-and-compliance/examples/scorecard-use-cases/" rel="noopener noreferrer"&gt;scorecards&lt;/a&gt; with a "has a team" rule, &lt;a href="https://docs.port.io/context-lake/business-context/ownership/" rel="noopener noreferrer"&gt;syncs users and teams&lt;/a&gt; from SSO and Git providers, deletes entities that are gone from the source on a &lt;a href="https://docs.port.io/context-lake/ingestion/ingest-data-into-port/native-integrations/sync-mechanisms/full-resync/" rel="noopener noreferrer"&gt;full resync&lt;/a&gt;, and has a &lt;a href="https://www.docs.port.io/guides/all/connect-github-codeowners-with-service-team-and-user" rel="noopener noreferrer"&gt;guide for mapping CODEOWNERS&lt;/a&gt; to teams. Limit: entities of a resource type removed from the mapping are &lt;a href="https://docs.port.io/context-lake/ingestion/configure-mapping/entity-cleanup/" rel="noopener noreferrer"&gt;not deleted automatically&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadie&lt;/strong&gt; is managed Backstage: &lt;code&gt;catalog-info.yaml&lt;/code&gt; is its &lt;a href="https://roadie.io/docs/catalog/overview" rel="noopener noreferrer"&gt;native format&lt;/a&gt;, it discovers files from Git providers, runs &lt;a href="https://roadie.io/docs/tech-insights/introduction/" rel="noopener noreferrer"&gt;Tech Insights&lt;/a&gt; scorecards, and its Catalog Completeness beta &lt;a href="https://roadie.io/docs/catalog/repositories/" rel="noopener noreferrer"&gt;suggests owners&lt;/a&gt; for repositories. Limit: deleting a location &lt;a href="https://roadie.io/docs/catalog/location-management/" rel="noopener noreferrer"&gt;does not remove&lt;/a&gt; the entities found through it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already run Backstage, the checks in this post fit in a standard-library Python script that runs in CI. What the products add is the plumbing around them: syncing from HR and identity systems, a place where scores are shown, and someone else keeping it running. Whichever way you go, test it the way we tested ours. The repository has a fixture catalog with one of each kind of drift; point your tool at something like it and confirm it fails on each one. A tool you have only seen report "all good" has not shown you that it can find anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whether catalogs decay with age.&lt;/strong&gt; The difference we saw (8.0% against 11.4%) is within chance for this sample, and one snapshot cannot measure a rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether three owners in four are right.&lt;/strong&gt; 159 owners could not be checked from outside. Among the 58 we could check, 1 entry had no owner and 4 drew a warning, and that rate does not carry over to the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether a disagreement means a wrong owner.&lt;/strong&gt; CODEOWNERS and the catalog can mean different things by "owner".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether runbooks and on-call links rot.&lt;/strong&gt; None were identified in public catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How private catalogs compare.&lt;/strong&gt; Every entry here is in a public repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether a wrong entry ever caused a bad page.&lt;/strong&gt;"Can be worse than none" is an argument about how people use catalogs during incidents, not something this data measures.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Check a catalog against the systems it describes, not against itself. Ours found 21 entries out of 217 where GitHub contradicts the entry or its CODEOWNERS, all of which looked fine in their own files.&lt;/li&gt;
&lt;li&gt;Ownership is where the records disagree. Where two records of the owner existed, half disagreed or named an owner GitHub could not resolve.&lt;/li&gt;
&lt;li&gt;From outside an org, most owners cannot be checked at all. Run the check where the token can see your teams, and map directory groups to GitHub teams explicitly.&lt;/li&gt;
&lt;li&gt;Make owners the same string everywhere by deriving them from GitHub teams or your identity provider, then compare catalog and CODEOWNERS in CI.&lt;/li&gt;
&lt;li&gt;Run the check on a schedule too: a team deleted in a reorg changes no file.&lt;/li&gt;
&lt;li&gt;Retire entries nobody claims. A gap sends people asking; a wrong answer can send them to the wrong place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checker, the sample, every result and the tests are in &lt;a href="https://github.com/The-DevOps-Daily/service-catalog-drift" rel="noopener noreferrer"&gt;The-DevOps-Daily/service-catalog-drift&lt;/a&gt;. Run it against your own catalog, with a token that can see your teams, and see how your numbers compare.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/service-catalog-drift" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>backstage</category>
      <category>servicecatalog</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>PHP2Shell: How to Check That Every PHP You Run Is Actually Patched</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/php2shell-how-to-check-that-every-php-you-run-is-actually-patched-b4m</link>
      <guid>https://dev.to/devopsdaily/php2shell-how-to-check-that-every-php-you-run-is-actually-patched-b4m</guid>
      <description>&lt;p&gt;On 21 October 2026 the PHP project released PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix CVE-2026-109618 and CVE-2026-109619, the pair now called PHP2Shell. Both are rated critical, CVSS v4.0 9.2. Unauthenticated remote code execution has been demonstrated against Apache with mod_php before any application code runs, and PHP says every installation that serves web requests is at risk.&lt;/p&gt;

&lt;p&gt;So update. The trouble is that "we updated" can be true while the server is still vulnerable. Ubuntu and RHEL fix PHP without changing the version number, so &lt;code&gt;php -v&lt;/code&gt; says the same thing before and after. PHP-FPM and Apache keep running the old code until they restart. A fixed base image fixes nothing until your image is rebuilt and the old containers are gone. This post shows how to find every PHP you run, how to prove each one is fixed, and what the interim settings do and do not block. It comes with a read-only check script, and a registry scan of 39 popular PHP images: by their own metadata, 8 still shipped a vulnerable PHP 4.4 hours after the scheduled release, and 3 still did at 5.5 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update PHP everywhere&lt;/strong&gt; to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or to your distribution's fixed package. On Ubuntu and RHEL the fixed package keeps the old upstream version, so check the package version, not &lt;code&gt;php -v&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart every PHP process&lt;/strong&gt; : PHP-FPM, Apache with mod_php, php-cgi, LiteSpeed and FrankenPHP. Then confirm nothing still runs the replaced binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild and redeploy containers&lt;/strong&gt; , then check what the running containers contain, not what the registry has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you cannot update today&lt;/strong&gt; , set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; (and the other settings below) in php.ini or the FPM pool, restart, and treat it as partial. PHP says only the update is a complete fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP 8.1 and older get no upstream release.&lt;/strong&gt; Use a vendor backport or move to 8.2 or newer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the check&lt;/strong&gt; on every host and keep the output as your evidence.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Shell access to the hosts that run PHP, and root if you want to see processes owned by other users.&lt;/li&gt;
&lt;li&gt;Python 3.6 or newer for the check script. It uses only the standard library.&lt;/li&gt;
&lt;li&gt;The docker or podman CLI on hosts that run PHP in containers, and &lt;code&gt;kubectl&lt;/code&gt; for clusters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was fixed
&lt;/h2&gt;

&lt;p&gt;PHP published two advisories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109618&lt;/strong&gt; , &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;GHSA-w8hw-w9fx-j79w&lt;/a&gt;, "RCE via OOM corruption". Out-of-memory handling can interrupt PHP while request data is only partly initialized, and request cleanup can then treat that data as valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109619&lt;/strong&gt; , &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;GHSA-6wmf-4rv5-p3rp&lt;/a&gt;, "RCE via stale global state". State owned by one request can survive after its memory is freed, and a later request handled by the same worker can use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8.5&lt;/td&gt;
&lt;td&gt;8.5.12&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;8.4.27&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;8.3.36&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.2&lt;/td&gt;
&lt;td&gt;8.2.35&lt;/td&gt;
&lt;td&gt;End of life on 31 December 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.1 and older&lt;/td&gt;
&lt;td&gt;no upstream release&lt;/td&gt;
&lt;td&gt;Source backports for 7.4, 8.0 and 8.1 only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The timeline, all on 21 October unless noted (UTC):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;20 October&lt;/strong&gt; : PHP announced the release for 21 October at 14:00 UTC and &lt;a href="https://www.php.net/archive/2026.php#2026-10-20-1" rel="noopener noreferrer"&gt;reserved the right to release earlier&lt;/a&gt; if details became public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;02:29&lt;/strong&gt; : VirusTotal saw the first submission of a &lt;a href="https://blog.virustotal.com/2026/10/sizing-php-exposure-window-for-cve-2026.html" rel="noopener noreferrer"&gt;leaked archive&lt;/a&gt; with technical details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13:34&lt;/strong&gt; : the GitHub advisories were published. Ubuntu's &lt;a href="https://ubuntu.com/security/notices/USN-8948-1" rel="noopener noreferrer"&gt;USN-8948-1&lt;/a&gt; followed at 13:56.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14:00&lt;/strong&gt; : the scheduled release time. The php.net &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;announcements&lt;/a&gt; carry no time of day; NVD published both CVEs at 14:17.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16:03&lt;/strong&gt; : the last of the 16 official &lt;code&gt;php&lt;/code&gt; tags we scanned for 8.2 to 8.5 (linux/amd64) was built with a fixed PHP. Docker Hub showed the new version tags as pushed between 16:48 and 17:31.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first report took about ten weeks to reach a release. &lt;a href="https://calif.io/php2shell/" rel="noopener noreferrer"&gt;Calif&lt;/a&gt;, which credits the discovery to Calif, OpenAI and Wiz, dates discovery to 1 August 2026, the first report to PHP to 11 August and the second to 21 August. Calif traces the earliest affected code path to PHP 5.4.0, and says a related out-of-memory issue found in a 2024 audit was left unfixed because exploitation was not proven. In OpenAI's account, Greg Brockman &lt;a href="https://x.com/gdb/status/2112921529650688312" rel="noopener noreferrer"&gt;wrote&lt;/a&gt; that OpenAI had spent the past two months "leading a coalition to mitigate" the bug, found as part of its Patch the Planet initiative. OpenAI's own write-up is &lt;a href="https://openai.com/index/coordinated-disclosure-of-critical-vulnerabilities/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is really exposed
&lt;/h2&gt;

&lt;p&gt;PHP's &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;advisory&lt;/a&gt; lists "all PHP installations serving web requests, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed" and puts mod_php at the top. The GitHub advisories go into more detail per server API (SAPI):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SAPI&lt;/th&gt;
&lt;th&gt;What the PHP advisories say&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apache mod_php&lt;/td&gt;
&lt;td&gt;Highest risk. Unauthenticated RCE validated over HTTP with default limits, before any application code runs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP-FPM&lt;/td&gt;
&lt;td&gt;At risk. The CVE-2026-109619 session path was validated against FPM, but it needs a suitable application operation. For CVE-2026-109618, source analysis says FPM is susceptible, but no complete FPM exploit was shown. The advisory also names per-request settings sent by the web server (&lt;code&gt;PHP_VALUE&lt;/code&gt; or &lt;code&gt;PHP_ADMIN_VALUE&lt;/code&gt;) that produce a warning as a risk for FPM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;php-cgi&lt;/td&gt;
&lt;td&gt;Listed as at risk by php.net. The advisories do not discuss it separately.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FrankenPHP&lt;/td&gt;
&lt;td&gt;Listed. Source analysis says that, like mod_php, it is likely susceptible to CVE-2026-109618.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteSpeed (LSAPI)&lt;/td&gt;
&lt;td&gt;Listed. Application-specific exploits are described as likely but not validated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;td&gt;Not named. The bugs are in web request handling. But &lt;code&gt;php -S&lt;/code&gt; serves HTTP from the CLI binary, and the CLI usually comes from the same source package as FPM and mod_php, so an old CLI is a sign of what else on the host is old.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Red Hat &lt;a href="https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-109618.json" rel="noopener noreferrer"&gt;rates both bugs Important, not Critical&lt;/a&gt;, because "Standard Red Hat Enterprise Linux deployments use PHP-FPM rather than mod_php." That is a statement about the default, not about your server. PHP names mod_php as the highest-risk setup, so if you run mod_php on RHEL, put it first even though Red Hat's rating is Important.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sources disagree on the affected range.&lt;/strong&gt; PHP's advisories and the CVE.org records list only 8.2 to 8.5. NVD marks every version below 8.2.35 as vulnerable, with no lower bound. Google Cloud says "All versions of PHP from 5.4 through 8.5.11", and PHP itself shipped source backports for 7.4, 8.0 and 8.1. Treat every PHP older than the fixed releases as affected, including end-of-life branches.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;php -v&lt;/code&gt; does not tell you
&lt;/h2&gt;

&lt;p&gt;Debian ships the new upstream release, so on Debian &lt;code&gt;php -v&lt;/code&gt; changes when you are fixed: bookworm gets 8.2.35 and trixie gets 8.4.27. Ubuntu and RHEL backport the patch into the version they already ship. Ubuntu 24.04's fixed php8.3 still reports 8.3.6. So does the vulnerable one.&lt;/p&gt;

&lt;p&gt;To show this with real packages, the repo's &lt;code&gt;scripts/ubuntu-backport-demo.sh&lt;/code&gt; downloads &lt;code&gt;php8.3-fpm&lt;/code&gt; and &lt;code&gt;php8.3-common&lt;/code&gt; from the Ubuntu archive, from before the fix (&lt;code&gt;.12&lt;/code&gt;) and with the fix (&lt;code&gt;.13&lt;/code&gt;). It extracts them without installing or running anything, reads the PHP version compiled into &lt;code&gt;php-fpm8.3&lt;/code&gt;, and searches the package changelog for the CVE IDs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ubuntu-backport-demo.sh&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ scripts/ubuntu-backport-demo.sh
# 2026-10-21T18:26:56Z Ubuntu 24.04 php8.3 packages from http://archive.ubuntu.com/ubuntu/pool
sha256 60187aae56db2056e916d99ba4854415993429f12732ace1b5eb5225bb33f584 php8.3-fpm_8.3.6-0ubuntu0.24.04.12_amd64.deb
sha256 85e4fc03ff84fef7dfed923c99d1a2e0e6f49147a50bcd5eaca4e7cd05a835fa php8.3-common_8.3.6-0ubuntu0.24.04.12_amd64.deb
sha256 ae395e2c15c3b96da56da807a204f771a589eb5bf54a3e5e239b1f50ade2815b php8.3-fpm_8.3.6-0ubuntu0.24.04.13_amd64.deb
sha256 801cae4e842e1b9e9d60a542bf18ba831bf504b2f3070404892bf3f8fb943ea4 php8.3-common_8.3.6-0ubuntu0.24.04.13_amd64.deb

package 8.3.6-0ubuntu0.24.04.12
  php-fpm8.3 embeds: PHP/8.3.6
  changelog top: php8.3 (8.3.6-0ubuntu0.24.04.12) noble-security; urgency=medium
  PHP2Shell IDs: none
package 8.3.6-0ubuntu0.24.04.13
  php-fpm8.3 embeds: PHP/8.3.6
  changelog top: php8.3 (8.3.6-0ubuntu0.24.04.13) noble-security; urgency=medium
  PHP2Shell IDs: CVE-2026-109618 CVE-2026-109619 

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both binaries carry the same version. Only the package version and its changelog say which one has the fix. This is what the fixed packages looked like on the evening of 21 October:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Fixed package&lt;/th&gt;
&lt;th&gt;Does &lt;code&gt;php -v&lt;/code&gt; change?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Debian 12 bookworm&lt;/td&gt;
&lt;td&gt;php8.2 8.2.35-1~deb12u1 (&lt;a href="https://security-tracker.debian.org/tracker/CVE-2026-109618" rel="noopener noreferrer"&gt;DLA-4848-1&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Yes, to 8.2.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian 13 trixie&lt;/td&gt;
&lt;td&gt;php8.4 8.4.27-1~deb13u1 (DSA-6574-1)&lt;/td&gt;
&lt;td&gt;Yes, to 8.4.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 26.04&lt;/td&gt;
&lt;td&gt;php8.5 8.5.4-0ubuntu1.5 (&lt;a href="https://ubuntu.com/security/notices/USN-8948-1" rel="noopener noreferrer"&gt;USN-8948-1&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;No, stays 8.5.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 24.04&lt;/td&gt;
&lt;td&gt;php8.3 8.3.6-0ubuntu0.24.04.13&lt;/td&gt;
&lt;td&gt;No, stays 8.3.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 22.04&lt;/td&gt;
&lt;td&gt;php8.1 8.1.2-1ubuntu2.28&lt;/td&gt;
&lt;td&gt;No, stays 8.1.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 14.04 to 20.04 (ESM)&lt;/td&gt;
&lt;td&gt;php5, php7.0, php7.2, php7.4 (&lt;a href="https://ubuntu.com/security/notices/USN-8948-2" rel="noopener noreferrer"&gt;USN-8948-2&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RHEL 8.10 EUS, php:7.4 module&lt;/td&gt;
&lt;td&gt;php-7.4.33-8.module+el8.10.0+25117+30337cca (RHSA-2026:87080)&lt;/td&gt;
&lt;td&gt;No, stays 7.4.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RHEL 8 php:8.2, RHEL 9, RHEL 10&lt;/td&gt;
&lt;td&gt;No fix listed at 16:49 UTC&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;packages.sury.org (Debian 12, arm64)&lt;/td&gt;
&lt;td&gt;Still at 8.2.34, 8.3.35, 8.4.26 and 8.5.11 at about 18:10 UTC&lt;/td&gt;
&lt;td&gt;Yes, when it lands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rpms.remirepo.net&lt;/td&gt;
&lt;td&gt;php.net: "The repo will not have updates today"&lt;/td&gt;
&lt;td&gt;Yes, when it lands&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same trap works in the other direction. A scanner that compares only the upstream version reports fixed Ubuntu and RHEL packages as vulnerable.&lt;/p&gt;

&lt;p&gt;The right check compares the package version with your distribution's fixed version for that package, or finds both CVE IDs in the changelog of the package that owns the binary. The examples use Ubuntu 24.04's php8.3-fpm; take the fixed version for your release and package from its USN, DSA or RHSA:&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;# Ubuntu 24.04, php8.3-fpm from the Ubuntu archive (fixed in USN-8948-1)&lt;/span&gt;
dpkg &lt;span class="nt"&gt;--compare-versions&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${Version}'&lt;/span&gt; php8.3-fpm&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ge 8.3.6-0ubuntu0.24.04.13 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;fixed &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;NOT fixed

&lt;span class="c"&gt;# Which package owns the binary that actually runs, and what its changelog says&lt;/span&gt;
dpkg-query &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/sbin/php-fpm8.3&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
zcat /usr/share/doc/php8.3-fpm/changelog.Debian.gz | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;CVE-2026-109618 CVE-2026-109619&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;zgrep &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /usr/share/doc/php8.3-fpm/changelog.Debian.gz &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;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; listed"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; NOT listed"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# RHEL, AlmaLinux, Rocky Linux: ask about the package that owns the binary&lt;/span&gt;
rpm &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/sbin/php-fpm&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;CVE-2026-109618 CVE-2026-109619&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;rpm &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="nt"&gt;--changelog&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/sbin/php-fpm&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&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;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; listed"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; NOT listed"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Alpine packages carry the upstream version&lt;/span&gt;
apk info &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^php[0-9]+(-fpm|-cgi|-apache2|-embed)?-[0-9]'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make the changelog check less simple than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On Debian and Ubuntu, &lt;code&gt;/usr/share/doc/php8.3-fpm&lt;/code&gt; is usually a symlink&lt;/strong&gt; to &lt;code&gt;php8.3-common&lt;/code&gt;'s docs. If an upgrade was partial and &lt;code&gt;php8.3-fpm&lt;/code&gt; was held back, you read the newer package's changelog and conclude the old FPM binary is fixed. Check that the first line of the changelog names the version that &lt;code&gt;dpkg-query -W php8.3-fpm&lt;/code&gt; reports. The check script refuses a changelog whose top entry does not match the installed package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container images often strip &lt;code&gt;/usr/share/doc&lt;/code&gt;.&lt;/strong&gt; Then there is no changelog to read, and the package version against the distribution's tracker is the only evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A changelog line is a mention, not proof.&lt;/strong&gt; A fix that was later reverted would still be listed. Treat it as supporting evidence next to the package version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Restart, or the old code keeps running
&lt;/h2&gt;

&lt;p&gt;Upgrading the package replaces the files on disk. The PHP-FPM master and its workers, and every Apache child with mod_php loaded, still have the old binary mapped in memory. Linux marks a mapped file that has been replaced as &lt;code&gt;(deleted)&lt;/code&gt;, which gives you a direct check:&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;# Run as root, or processes owned by other users are not visible.&lt;/span&gt;
&lt;span class="c"&gt;# PHP-FPM, php-cgi, LiteSpeed and FrankenPHP processes on a replaced binary&lt;/span&gt;
&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/[0-9]&lt;span class="k"&gt;*&lt;/span&gt;/exe 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'(php|frankenphp).*\(deleted\)'&lt;/span&gt;

&lt;span class="c"&gt;# Apache processes that still map a replaced libphp&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s1"&gt;'libphp.*(deleted)'&lt;/span&gt; /proc/[0-9]&lt;span class="k"&gt;*&lt;/span&gt;/maps 2&amp;gt;/dev/null

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the service (&lt;code&gt;systemctl restart php8.3-fpm&lt;/code&gt;, &lt;code&gt;systemctl restart apache2&lt;/code&gt;), run both commands again until they print nothing, and then check the version or package of the binary the service runs. An empty result only says no replaced file is still mapped; it does not say the file on disk is the fixed one. &lt;code&gt;needrestart&lt;/code&gt; on Debian and Ubuntu and &lt;code&gt;dnf needs-restarting&lt;/code&gt; on RHEL also list processes that need a restart after an upgrade. PHP's own advice for the interim settings is to "restart every worker" as well, because a setting changed in php.ini does not reach a worker that is already running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers: rebuild, redeploy, then check what is running
&lt;/h2&gt;

&lt;p&gt;A container keeps the PHP it was built with. Google's &lt;a href="https://cloud.google.com/blog/products/identity-security/responding-to-core-php-vulnerabilities-cve-2026-109618-cve-2026-109619" rel="noopener noreferrer"&gt;guidance for Cloud Run&lt;/a&gt; says it plainly: services without automatic base image updates "must be rebuilt and redeployed", and "Container images you build yourself are your responsibility."&lt;/p&gt;

&lt;p&gt;Rebuild with &lt;code&gt;--pull&lt;/code&gt;, so the builder fetches the new base instead of a cached one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--pull&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; registry.example.com/app:2026-10-21 &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your Dockerfile pins a digest (&lt;code&gt;FROM php:8.4-fpm@sha256:...&lt;/code&gt;), update the pin. If it pins a version tag, move to &lt;code&gt;8.4.27-fpm&lt;/code&gt; or later. Then deploy the new tag or digest. On Kubernetes, &lt;code&gt;kubectl rollout restart&lt;/code&gt; with a reused tag and &lt;code&gt;imagePullPolicy: IfNotPresent&lt;/code&gt; starts the new pods from the image already cached on the node, which is the old one.&lt;/p&gt;

&lt;p&gt;Then check the containers that are running, not the image you meant to deploy:&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;# Docker hosts (use podman the same way): the PHP CLI version in each running container&lt;/span&gt;
docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.ID}} {{.Image}} {{.Names}}'&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;image name&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-30s %-40s '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; php &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'echo PHP_VERSION, PHP_EOL;'&lt;/span&gt; 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PHP CLI check failed"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Kubernetes: the image digests your pods are actually running&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{..imageID}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI version is a first look, not proof for the web server: a container can have a fixed CLI and an older FPM or mod_php, so check the binary that serves requests (&lt;code&gt;php-fpm -v&lt;/code&gt;, or its package) as well. The repo's &lt;code&gt;registry_scan.py&lt;/code&gt; takes repository-qualified digests (&lt;code&gt;registry/repository@sha256:...&lt;/code&gt;) as arguments and reads the PHP version from each image's metadata without pulling it. Some runtimes report &lt;code&gt;imageID&lt;/code&gt; in other forms, such as a bare digest, which you need to map back to a repository first. That works for images built &lt;code&gt;FROM php&lt;/code&gt;, which carry &lt;code&gt;ENV PHP_VERSION&lt;/code&gt;. For images that install PHP from distribution packages, look inside a running container, because the version is not in the metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast the popular images were rebuilt
&lt;/h2&gt;

&lt;p&gt;We wanted to know how long "pull the new image" takes to become true, so &lt;code&gt;registry_scan.py&lt;/code&gt; read the PHP version of 39 popular images from registry metadata only: the manifest and the image config, a few KB each, checked against their digests, never a layer. The version is the one the image declares in &lt;code&gt;ENV PHP_VERSION&lt;/code&gt; (or Bitnami's &lt;code&gt;APP_VERSION&lt;/code&gt;), which images built &lt;code&gt;FROM php&lt;/code&gt; inherit. An image could replace PHP after that, so this is what the images report, not a look at the binary. The list is the official &lt;code&gt;php&lt;/code&gt; image for 8.1 to 8.5, the official application images built on it, and widely used community images. &lt;code&gt;images.txt&lt;/code&gt; in the repo says why each one is there. All results are for linux/amd64.&lt;/p&gt;

&lt;p&gt;We ran the scan twice, at 18:21 UTC and at 19:32 UTC, 4.4 and 5.5 hours after the scheduled release:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;39 popular PHP images by the PHP version their metadata reports&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;18:21 UTC: vulnerable&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;vulnerable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18:21 UTC: unknown&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18:21 UTC: patched&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19:32 UTC: vulnerable&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;vulnerable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19:32 UTC: unknown&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19:32 UTC: patched&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;patched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;linux/amd64. PHP version from ENV PHP_VERSION (or Bitnami APP_VERSION) in each image config, read without pulling layers. Fixed: 8.5.12, 8.4.27, 8.3.36, 8.2.35; 8.1 and older count as vulnerable. Unknown: no full version in the metadata. Files: results/scan-20261021T182129Z.json, results/scan-20261021T193200Z.json.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All 16 official &lt;code&gt;php&lt;/code&gt; tags we scanned for 8.2 to 8.5 reported a fixed PHP&lt;/strong&gt; in both runs, built 1.8 to 2.1 hours after the scheduled release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At 18:21, 8 images still shipped a vulnerable PHP&lt;/strong&gt; : &lt;code&gt;php:8.1-fpm&lt;/code&gt; and &lt;code&gt;php:8.1-apache&lt;/code&gt; (8.1.34, last built in December 2025; there is no fixed 8.1 release to rebuild with), &lt;code&gt;joomla:latest&lt;/code&gt; (8.4.26), &lt;code&gt;nextcloud:latest&lt;/code&gt; and &lt;code&gt;nextcloud:fpm&lt;/code&gt; (8.5.11), &lt;code&gt;phpmyadmin:latest&lt;/code&gt; (8.3.35), &lt;code&gt;yourls:latest&lt;/code&gt; (8.5.11) and &lt;code&gt;laravelsail/php84-composer:latest&lt;/code&gt; (8.4.1, built in November 2024). The Laravel documentation uses that image to run Composer, not to serve traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At 19:32, 3 were still vulnerable&lt;/strong&gt; : the two &lt;code&gt;php:8.1&lt;/code&gt; tags and &lt;code&gt;laravelsail/php84-composer:latest&lt;/code&gt;. &lt;code&gt;joomla&lt;/code&gt;, both &lt;code&gt;nextcloud&lt;/code&gt; tags, &lt;code&gt;phpmyadmin&lt;/code&gt; and &lt;code&gt;yourls&lt;/code&gt; had fixed images by then.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 images could not be judged from metadata&lt;/strong&gt; in either run: &lt;code&gt;thecodingmachine/php:8.4-v5-fpm&lt;/code&gt; sets &lt;code&gt;PHP_VERSION=8.4&lt;/code&gt; without the patch level, and &lt;code&gt;linuxserver/nextcloud&lt;/code&gt; installs PHP from Alpine packages and records no PHP version at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hours from the scheduled 14:00 UTC release to the image build&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official php 8.2 to 8.5 (16)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1.83 h&lt;/td&gt;
&lt;td&gt;1.935 h&lt;/td&gt;
&lt;td&gt;2.06 h&lt;/td&gt;
&lt;td&gt;2.06 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official app images (13)&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;2.47 h&lt;/td&gt;
&lt;td&gt;2.53 h&lt;/td&gt;
&lt;td&gt;4.08 h&lt;/td&gt;
&lt;td&gt;4.08 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community images (5)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2.23 h&lt;/td&gt;
&lt;td&gt;3.68 h&lt;/td&gt;
&lt;td&gt;4.05 h&lt;/td&gt;
&lt;td&gt;4.05 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Patched images only, by the 'created' time in each linux/amd64 image config, from results/scan-20261021T193200Z.json. This is when the image was built, not when the registry started serving it. Images still on a vulnerable PHP are not on this chart.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One result changes how you should read a registry. Docker Hub listed &lt;code&gt;joomla:latest&lt;/code&gt; as pushed at 15:46 UTC, after the release. Its amd64 image was built at 01:52 UTC with PHP 8.4.26. So a fresh "last pushed" time is not evidence that the image you pull has a fixed PHP. The build time is not either: the fixed &lt;code&gt;joomla&lt;/code&gt;, &lt;code&gt;phpmyadmin&lt;/code&gt; and &lt;code&gt;yourls&lt;/code&gt; images that the registry served at 19:32 were built between 16:28 and 16:36 UTC, but at 18:30 the registry still served the old ones for amd64. &lt;code&gt;nextcloud:latest&lt;/code&gt; and &lt;code&gt;nextcloud:fpm&lt;/code&gt; looked the same at 18:21; by 18:30 their amd64 images had moved to 8.5.12. To check the method, at 18:30 UTC we compared the amd64 digest from the Docker Hub API with the registry's own index for those three images and two others: they agreed for all five (&lt;code&gt;results/method-check-20261021T183037Z.txt&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The interim settings and their limits
&lt;/h2&gt;

&lt;p&gt;If you cannot update today, PHP lists &lt;a href="https://www.php.net/archive/2026.php#2026-10-21-5" rel="noopener noreferrer"&gt;settings that reduce exposure&lt;/a&gt; to specific paths. Its own words: they "reduce exposure to specific attack paths but do not make an installation safe."&lt;/p&gt;

&lt;p&gt;For CVE-2026-109619, turn off session upload progress, and on PHP 8.5 fatal error backtraces. Set them where they apply before PHP parses the request: php.ini, the FPM pool, or the Apache config. PHP says not to rely on &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt;, which apply too late.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini (and the php.ini of every SAPI: cli, fpm, apache2, cgi)
&lt;/span&gt;&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="c"&gt;; PHP 8.5 only
&lt;/span&gt;&lt;span class="py"&gt;fatal_error_backtraces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;


&lt;span class="c"&gt;; PHP-FPM pool, for example /etc/php/8.3/fpm/pool.d/www.conf
&lt;/span&gt;&lt;span class="err"&gt;php_admin_flag&lt;/span&gt;&lt;span class="nn"&gt;[session.upload_progress.enabled]&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="err"&gt;off&lt;/span&gt;


&lt;span class="c"&gt;# Apache with mod_php
&lt;/span&gt;&lt;span class="err"&gt;php_admin_flag&lt;/span&gt; &lt;span class="err"&gt;session.upload_progress.enabled&lt;/span&gt; &lt;span class="err"&gt;off&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart every worker. What this does and does not do, per the &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;advisory&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It "prevents the demonstrated early session producing and consuming operations. It does not mitigate the other lifecycle paths." &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt; "blocks the confirmed fatal backtrace path only."&lt;/li&gt;
&lt;li&gt;PHP lists it under CVE-2026-109619 only. For CVE-2026-109618 PHP suggests enforcing &lt;code&gt;memory_limit=-1&lt;/code&gt; so applications cannot override it, and warns that an underlying allocation failure can still reach the bug. With no PHP memory limit, use a container or cgroup limit as the availability boundary, and do not use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt;, which can cause the failure you are trying to avoid.&lt;/li&gt;
&lt;li&gt;Hiding warnings does not help. Changing &lt;code&gt;error_reporting&lt;/code&gt;, &lt;code&gt;display_errors&lt;/code&gt; or &lt;code&gt;log_errors&lt;/code&gt;, or using &lt;code&gt;@&lt;/code&gt;, does not protect against CVE-2026-109619. Instead, remove per-request settings that produce warnings or deprecation notices, including &lt;code&gt;PHP_VALUE&lt;/code&gt; or &lt;code&gt;PHP_ADMIN_VALUE&lt;/code&gt; lines your web server passes to FPM.&lt;/li&gt;
&lt;li&gt;If your application does not use multipart form data, PHP suggests blocking it at the frontend. It also suggests a low &lt;code&gt;post_max_size&lt;/code&gt; (8M for applications that do not need file uploads, 32M for those that do) and proxy limits that also cover requests without a usable Content-Length.&lt;/li&gt;
&lt;li&gt;Edge rules help but do not replace the update. &lt;a href="https://www.helpnetsecurity.com/2026/10/21/php2shell-php-vulnerabilities-patched-cve-2026-109618-cve-2026-109619/" rel="noopener noreferrer"&gt;Help Net Security&lt;/a&gt; reports that Cloudflare deployed WAF rules for the known exploit vectors to all customers, including Free, and &lt;a href="https://blog.virustotal.com/2026/10/sizing-php-exposure-window-for-cve-2026.html" rel="noopener noreferrer"&gt;VirusTotal&lt;/a&gt; reports its CTO saying that only updating PHP fully protects a site.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PHP also suggests keeping &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; after you patch if you do not use upload progress bars, "as more attackers try to find flaws in this hardly-used and hardly-tested feature."&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP 8.1 and older
&lt;/h2&gt;

&lt;p&gt;The PHP project does not release fixes for 8.1 or older. It published minimal source backports for 7.4, 8.0 and 8.1 for people who build from source, and points everyone else to their vendor. Some vendors have shipped them: Ubuntu for 22.04's PHP 8.1 and, under ESM, for 14.04 to 20.04, and Red Hat for the RHEL 8.10 EUS php:7.4 module.&lt;/p&gt;

&lt;p&gt;The official &lt;code&gt;php:8.1&lt;/code&gt; tags we scanned still reported 8.1.34, built in December 2025. Google's &lt;a href="https://cloud.google.com/blog/products/identity-security/responding-to-core-php-vulnerabilities-cve-2026-109618-cve-2026-109619" rel="noopener noreferrer"&gt;guidance&lt;/a&gt; says App Engine applications on PHP 5.5, 7.2, 7.3, 7.4 and 8.1 remain vulnerable and must move to 8.2 or newer. VirusTotal &lt;a href="https://blog.virustotal.com/2026/10/sizing-php-exposure-window-for-cve-2026.html" rel="noopener noreferrer"&gt;counted&lt;/a&gt; about 758,000 internet-facing hosts that announce an end-of-life PHP version from 5.4 to 8.1, out of about 1.1 million that announce a version at all. That counts exposed PHP, not confirmed vulnerable hosts, but it shows how much of the long tail has no simple update.&lt;/p&gt;

&lt;p&gt;If you are on 8.2, the same applies on 31 December 2026, when it reaches end of life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the check
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/php2shell-check" rel="noopener noreferrer"&gt;The-DevOps-Daily/php2shell-check on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;php2shell_check.py&lt;/code&gt; is one file of standard-library Python, so you can copy it to a server and run it. For each PHP it finds, it reports the SAPI, the version compiled into the binary or Apache module (so mod_php is checked without running Apache; &lt;code&gt;-v&lt;/code&gt; is the fallback for binaries without a readable string), the package that owns the file, and whether that package's changelog names both vulnerabilities by CVE or GHSA ID. A changelog only counts if it belongs to the installed package and &lt;code&gt;dpkg --verify&lt;/code&gt; or &lt;code&gt;rpm -V&lt;/code&gt; confirms the binary still matches that package. It also reports the interim settings from a fresh &lt;code&gt;php -i&lt;/code&gt;, the FPM pool files, or the parsed php.ini files for mod_php, plus host web server lines that set them or pass &lt;code&gt;PHP_VALUE&lt;/code&gt;. A running service started with other options can differ. Then it lists running PHP processes and marks any that still run a replaced binary. Through the first container CLI it finds (docker, then podman), it checks the PHP binaries inside each running container that has a PHP process, and adds an &lt;code&gt;unknown&lt;/code&gt; result when no checked binary covers the kind of PHP process running there (for example an FPM process but only a CLI binary found). The interim settings are reported only: they never turn a vulnerable result into a pass.&lt;/p&gt;

&lt;p&gt;This is the real output on the Raspberry Pi we build on, which has the PHP 8.4.23 CLI from packages.sury.org and no web server:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;php2shell-check on the Pi&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 2026-10-21T18:45:30Z Raspberry Pi, Debian 12, PHP CLI from packages.sury.org, no web server, run as a normal user
$ python3 php2shell_check.py
php2shell-check: fixed releases 8.2.35, 8.3.36, 8.4.27, 8.5.12

PHP on disk
  [vulnerable] /usr/bin/php8.4 cli
      version: 8.4.23 (embedded string)
      exposure: CLI: not a web SAPI unless it runs `php -S`; same package line often ships FPM/mod_php
      also as: /bin/php, /bin/php8.4, /usr/bin/php
      package: php8.4-cli 8.4.23-1+0~20260703.52+debian12~1.gbp817a0c (dpkg); CVE IDs in changelog: none
      why: upstream 8.4.23 &amp;lt; 8.4.27
      session.upload_progress.enabled: On [php8.4 -i]
      memory_limit=-1 post_max_size=8M

Running PHP processes
  none

Containers
  none checked
note: no docker or podman CLI found; container processes are judged by the version in /proc

result: VULNERABLE or restart needed (exit 1)
$ echo $?
1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code is 1 when anything is vulnerable or needs a restart. Otherwise it is 2 when anything could not be checked, and 0 when every PHP it found is patched or it found no PHP at all (it says which). Anything left &lt;code&gt;unknown&lt;/code&gt;, such as a changelog that lists only one of the two CVEs on an older version, a pre-release build, or an unreadable process, gives 2 rather than 0, unless something else is vulnerable, which gives 1. It only knows the paths and names it searches, so a 0 means "nothing vulnerable found", not proof that no other PHP exists. To run it across hosts without copying it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in &lt;/span&gt;web1 web2 web3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ssh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'sudo python3 - --json'&lt;/span&gt; &amp;lt; php2shell_check.py &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"php2shell-&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt; exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test suite builds fake hosts with the cases a version check gets wrong: an Ubuntu backport, the same version without the update, a partial upgrade whose symlinked changelog belongs to another package, an old binary copied over a patched package, a changelog with only one of the two CVEs, a source build where FPM was left on the old version, a process running a deleted binary, and containers on old and new images.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to prove you are fixed
&lt;/h2&gt;

&lt;p&gt;For each host, keep evidence of all of these, not just the first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Package&lt;/strong&gt; : the upstream version is at or above the fixed release, or the package version is at or above your distribution's fixed version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processes&lt;/strong&gt; : no PHP-FPM, php-cgi, LiteSpeed or FrankenPHP process runs a &lt;code&gt;(deleted)&lt;/code&gt; binary, and no Apache process maps a &lt;code&gt;(deleted)&lt;/code&gt; libphp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Containers&lt;/strong&gt; : the binary that serves requests inside each running container (&lt;code&gt;php-fpm&lt;/code&gt;, mod_php, FrankenPHP) has a fixed version or a fixed package, not just the CLI. The image digest you deployed is the one running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images&lt;/strong&gt; : every image you build FROM a PHP base has been rebuilt since the fix. The registry scan of its digest is a quick inventory check; the container check above is the proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposure while vulnerable&lt;/strong&gt; : if a host was internet-facing and unpatched, check it for compromise. The &lt;a href="https://www.ncsc.gov.uk/news/critical-security-issue-affecting-php" rel="noopener noreferrer"&gt;NCSC&lt;/a&gt; asks for this, and &lt;a href="https://www.wiz.io/blog/php2shell-php-rce" rel="noopener noreferrer"&gt;Wiz&lt;/a&gt; suggests looking for unusual multipart requests followed by memory-limit errors or repeated HTTP 500 responses, unexpectedly large or malformed &lt;code&gt;PHPSESSID&lt;/code&gt; response cookies, and unexplained worker churn.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we could not check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Containers on a real host.&lt;/strong&gt; The Pi we build on has no Docker, so the container checks are covered by the test fixtures only. The registry scan read real images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web servers.&lt;/strong&gt; The Pi runs PHP from the command line only. mod_php and FPM detection are covered by the tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the interim settings block anything.&lt;/strong&gt; We did not test any part of the bugs, and the tool does not. PHP's own text is the source for what the settings cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Later distribution updates.&lt;/strong&gt; The distribution table is as of the evening of 21 October. At 16:49 UTC Red Hat listed a fix only for the RHEL 8.10 EUS php:7.4 module, the Debian 12 arm64 index on packages.sury.org still had the old versions at about 18:10 UTC, and PHP said Remi would not have updates that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other architectures.&lt;/strong&gt; The registry scan reads linux/amd64 only. We did not measure arm64 images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unusual install paths.&lt;/strong&gt; The script searches distribution paths, Remi, cPanel, Plesk, CloudLinux alt-php, LiteSpeed, aaPanel and &lt;code&gt;$PATH&lt;/code&gt;. PHP elsewhere, &lt;code&gt;.htaccess&lt;/code&gt; overrides and stopped containers are not covered.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt; Every host: PHP package updated, checked against the distribution's fixed version, not &lt;code&gt;php -v&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Every PHP-FPM pool, Apache, php-cgi, LiteSpeed and FrankenPHP service restarted, with no &lt;code&gt;(deleted)&lt;/code&gt; PHP binaries left in &lt;code&gt;/proc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Every image built FROM a PHP base rebuilt with &lt;code&gt;--pull&lt;/code&gt; and redeployed by new tag or digest&lt;/li&gt;
&lt;li&gt; Running containers checked inside, including the binary that serves requests, and their image digests matched to what you deployed&lt;/li&gt;
&lt;li&gt; PHP 8.1 and older: vendor backport installed or upgrade to 8.2+ scheduled, with interim settings in place until then&lt;/li&gt;
&lt;li&gt; Interim settings applied where updates wait, in php.ini or the pool, with workers restarted&lt;/li&gt;
&lt;li&gt; &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; kept where upload progress bars are not used&lt;/li&gt;
&lt;li&gt; Internet-facing hosts that were unpatched checked for signs of compromise&lt;/li&gt;
&lt;li&gt; Check script output saved per host as evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every fact in this post is listed with its source in the repo's &lt;code&gt;SOURCES.md&lt;/code&gt;, together with the points where the sources disagree.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/php2shell-check-every-php-is-patched" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>php</category>
      <category>security</category>
      <category>docker</category>
    </item>
    <item>
      <title>What Keeps a Neon Compute Awake, and What It Costs</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/what-keeps-a-neon-compute-awake-and-what-it-costs-3p3h</link>
      <guid>https://dev.to/devopsdaily/what-keeps-a-neon-compute-awake-and-what-it-costs-3p3h</guid>
      <description>&lt;p&gt;We pointed a health check at a Neon compute every 5 minutes, the same as Neon's default scale-to-zero timeout. The compute never suspended. In 40 minutes it took 8 checks, woke once for the first one, and stayed awake the whole time. Over 30 days that is 180 CU-hours at the smallest compute size: about $19 on the Launch plan, $40 on Scale, and on the Free plan it uses up the 100 CU-hour allowance by day 17.&lt;/p&gt;

&lt;p&gt;The suspect most people reach for first, an idle connection left open by the app, does not keep a compute awake. We held idle connections, direct and pooled, and the compute suspended every time, a few seconds later than with nothing open. What the suspend did do was cut those connections, including the pooled ones.&lt;/p&gt;

&lt;p&gt;This post measures which ordinary clients keep a Neon compute running and which do not, how long a compute takes to suspend, what your app sees when it does, and what each pattern costs per month. Everything runs against real computes in one Neon project, watched through the Neon API, and the scripts are public so you can run them on your own account.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idle connections do not stop scale to zero.&lt;/strong&gt; With a 60-second timeout the compute suspended 66 to 75 seconds after the last query with nothing open, and 81 to 83 seconds with an idle direct or pooled connection held. The suspend dropped every held connection, pooled ones included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An open transaction keeps it awake for as long as it stays open.&lt;/strong&gt; A session left &lt;code&gt;idle in transaction&lt;/code&gt;, direct or through the pooler, kept the compute up for the whole 6-minute window, 4 runs out of 4. Setting &lt;code&gt;idle_in_transaction_session_timeout = '30s'&lt;/code&gt; let it suspend again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A monitoring agent that queries every 20 seconds keeps it awake&lt;/strong&gt; , even when it only reads &lt;code&gt;pg_stat_database&lt;/code&gt; on the &lt;code&gt;postgres&lt;/code&gt; database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;LISTEN&lt;/code&gt; connection does not keep it awake.&lt;/strong&gt; It is dropped on suspend, and a notification sent after the suspend never reached it (2 of 2 runs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks are the expensive one.&lt;/strong&gt; With the default 5-minute timeout, a check every 5 minutes kept the compute awake for the whole 40-minute run. In our runs the suspend came 312 to 327 seconds after a wake, so each check arrived before it. A check every 6 minutes still kept it awake 90.6% of the time, and every 10 minutes 52.1%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wake adds roughly half a second to a second.&lt;/strong&gt; End-to-end checks (connect, authenticate, query, close) that woke the compute had medians of 847 to 1,134 ms per run; checks on an awake compute took 308 to 339 ms, from a client 48 ms from the region.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Neon account and an API key (Account settings &amp;gt; API keys). The 60-second timeout used in part of the test needs a plan that lets you change it (Scale); everything with the default 5-minute timeout works on any plan.&lt;/li&gt;
&lt;li&gt;Node.js 22 or newer.&lt;/li&gt;
&lt;li&gt;Basic Postgres knowledge: sessions, transactions, &lt;code&gt;pg_stat_activity&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Neon decides a compute is idle
&lt;/h2&gt;

&lt;p&gt;Neon's documentation says a compute scales to zero after 5 minutes of inactivity, that you can change the timeout on paid plans, and that a connected logical replication subscriber prevents the suspend. It does not say what counts as activity.&lt;/p&gt;

&lt;p&gt;The answer is in the compute's own code. Every compute runs &lt;code&gt;compute_ctl&lt;/code&gt;, and its &lt;a href="https://github.com/neondatabase/neon/blob/f3a0e4f255/compute_tools/src/monitor.rs#L205-L350" rel="noopener noreferrer"&gt;activity monitor&lt;/a&gt; checks Postgres every 500 ms. It moves the "last active" time forward when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The totals in &lt;code&gt;pg_stat_database&lt;/code&gt; change: the summed &lt;code&gt;active_time&lt;/code&gt; (time spent running queries) or the number of &lt;code&gt;sessions&lt;/code&gt;, across all databases except &lt;code&gt;postgres&lt;/code&gt; and the templates. This catches a query that started and finished between two checks, and every new connection. The code calls this path experimental but says it is on for everyone.&lt;/li&gt;
&lt;li&gt;Any client backend is in a state other than &lt;code&gt;idle&lt;/code&gt;. That includes &lt;code&gt;active&lt;/code&gt; (a query is running) and &lt;code&gt;idle in transaction&lt;/code&gt; (a transaction is open but doing nothing). Backends of Neon's own &lt;code&gt;cloud_admin&lt;/code&gt; role are left out here.&lt;/li&gt;
&lt;li&gt;An idle backend changed state recently: the latest &lt;code&gt;state_change&lt;/code&gt; among idle backends counts as the last activity (&lt;a href="https://github.com/neondatabase/neon/blob/f3a0e4f255/compute_tools/src/monitor.rs#L444-L497" rel="noopener noreferrer"&gt;the backend check&lt;/a&gt;). A connection that ran a query 20 seconds ago and went idle moves the clock to then. Connecting moves it once too, but a connection that then just sits idle does not keep moving it.&lt;/li&gt;
&lt;li&gt;A logical replication walsender exists, an active logical replication subscription exists, or an autovacuum worker is running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The control plane reads the last activity time and suspends the compute once the timeout has passed. This is the open-source code at commit &lt;code&gt;f3a0e4f255&lt;/code&gt;; the version running in production may differ, which is one more reason to measure.&lt;/p&gt;

&lt;p&gt;So the code predicts that an idle connection should not keep a compute awake, and an open transaction should. The rest of this post checks those predictions and puts numbers on them.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-keep-awake" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-keep-awake on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One Neon project in &lt;code&gt;aws-eu-central-1&lt;/code&gt; running Lakebase Postgres (PostgreSQL 17), every compute fixed at 0.25 CU so CU-hours are easy to read off. Each health-check configuration gets its own branch, and each branch has its own compute, so configurations running side by side do not affect each other. The idle-client scenarios ran one after another on a single branch.&lt;/p&gt;

&lt;p&gt;The compute's state comes from &lt;code&gt;GET /projects/{id}/endpoints/{id}&lt;/code&gt;, which returns &lt;code&gt;current_state&lt;/code&gt; and &lt;code&gt;suspended_at&lt;/code&gt;. That is a control-plane call: it never touches the compute, so watching cannot keep it awake. The scripts read it every 2 seconds for the idle clients, every 3 seconds for the health checks, and every 15 seconds for the long unattended watch.&lt;/p&gt;

&lt;p&gt;Awake time is an estimate. An awake period starts when a check that found the compute suspended began connecting, and ends at the &lt;code&gt;suspended_at&lt;/code&gt; that Neon reports, which is in whole seconds. We checked it against Neon's own record: the project's operations log lists every &lt;code&gt;start_compute&lt;/code&gt; and &lt;code&gt;suspend_compute&lt;/code&gt;. For the health-check runs, the awake time from the log and from our polling differed by at most half a percentage point. That is a cross-check of the timing, not of the bill.&lt;/p&gt;

&lt;p&gt;That check caught one bug of ours. A poll taken a moment after a check woke the compute can still show the previous suspend, and our first summary ended the awake period there. The fix is to ignore a poll whose &lt;code&gt;suspended_at&lt;/code&gt; is older than the wake, and even that went wrong at first: Neon's timestamps are whole seconds (&lt;code&gt;17:57:46Z&lt;/code&gt;) and ours have milliseconds (&lt;code&gt;17:57:46.649Z&lt;/code&gt;), and compared as strings &lt;code&gt;46Z&lt;/code&gt; sorts after &lt;code&gt;46.649Z&lt;/code&gt;. The bug cost two runs 3 to 4 seconds of awake time each, small but in the direction that makes the bill look smaller. The summary now compares timestamps as dates. A review of the draft found a second one: the runner could fire one extra check right at the end of a window. The summary now counts only checks scheduled inside the window, which took one check off two of the runs.&lt;/p&gt;

&lt;p&gt;The scripts are split by stage, and each writes JSON lines stamped with a hash of the code that wrote it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;neon-keep-awake&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npm run setup
project odd-poetry-65586042, main branch br-icy-recipe-b91wrumq, compute ep-weathered-shadow-b91petoo
# stage 1: idle clients, 60 s timeout
$ npm run idle-clients
none #1: suspended at 2026-10-18T17:52:15Z (wake 694 ms)
none #2: suspended at 2026-10-18T17:53:30Z (wake 947 ms)
none #3: suspended at 2026-10-18T17:54:46Z (wake 907 ms)
idle-direct #1: suspended at 2026-10-18T17:56:16Z (wake 815 ms)
idle-direct #2: suspended at 2026-10-18T17:57:46Z (wake 805 ms)
idle-direct #3: suspended at 2026-10-18T17:59:16Z (wake 793 ms)
idle-pooled #1: suspended at 2026-10-18T18:00:45Z (wake 774 ms)
idle-pooled #2: suspended at 2026-10-18T18:02:17Z (wake 787 ms)
idle-pooled #3: suspended at 2026-10-18T18:03:46Z (wake 804 ms)
idle-in-transaction-direct #1: still active after 360s (wake 815 ms)
idle-in-transaction-direct #2: still active after 360s (wake 1020 ms)
idle-in-transaction-pooled #1: still active after 360s (wake 940 ms)
idle-in-transaction-pooled #2: still active after 360s (wake 792 ms)
idle-in-transaction-with-timeout #1: suspended at 2026-10-18T18:34:31Z (wake 1080 ms)
idle-in-transaction-with-timeout #2: suspended at 2026-10-18T18:36:16Z (wake 895 ms)
listen-direct #1: suspended at 2026-10-18T18:37:46Z (wake 876 ms)
listen-direct #2: suspended at 2026-10-18T18:39:16Z (wake 296 ms)
monitor-postgres-db-20s #1: still active after 360s (wake 263 ms)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;wake&lt;/code&gt; figure in that log is the time to connect and run &lt;code&gt;SELECT 1&lt;/code&gt; at the start of each run. Most runs started right after a suspend, so it is usually a cold start. The last two started on a warm compute: the previous &lt;code&gt;LISTEN&lt;/code&gt; run had just woken it to send its test notification, which is why they are near 300 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idle connections do not keep it awake, and they do not survive the suspend
&lt;/h2&gt;

&lt;p&gt;Stage 1 sets the suspend timeout to 60 seconds, wakes the compute, leaves one kind of client open, and times the suspend from our last query.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What was left open&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Suspended&lt;/th&gt;
&lt;th&gt;Seconds from the last query to the suspend&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;75, 68, 66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One idle direct connection&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;82, 81, 83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One idle pooled connection (PgBouncer)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;81, 83, 81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;LISTEN&lt;/code&gt; connection&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2 of 2&lt;/td&gt;
&lt;td&gt;83, 79&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An idle connection did not stop the suspend. The suspend did come later: the median was 82 seconds with an idle direct connection and 81 with a pooled one, against 68 with nothing open. With three runs per row, all on the same compute one after another, that is an observation we cannot explain, not an established effect. Either way, a compute with a 60-second timeout went to sleep in under a minute and a half with connections still open.&lt;/p&gt;

&lt;p&gt;Look at what happened to those connections. Five seconds after each suspend, the script ran &lt;code&gt;SELECT 1&lt;/code&gt; on the connection it had held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idle direct: 'error' event: Connection terminated unexpectedly
              next query: Client has encountered a connection error and is not queryable
idle pooled: 'error' event: Connection terminated unexpectedly
              next query: Client has encountered a connection error and is not queryable

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every held connection was dead, 6 out of 6, pooled ones included, and the &lt;code&gt;LISTEN&lt;/code&gt; connections in the last row died the same way. The pooler did not keep its client connections open across the suspend, so a pooled connection string does not protect your app from one.&lt;/p&gt;

&lt;p&gt;For most apps the fix is to make sure a dropped connection is handled, not fatal. With &lt;code&gt;node-postgres&lt;/code&gt;, use &lt;code&gt;Pool&lt;/code&gt;, not a single long-lived &lt;code&gt;Client&lt;/code&gt;, and handle the pool's &lt;code&gt;error&lt;/code&gt; event. Without a listener, an idle client that loses its socket throws an unhandled error and can crash the process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&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;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Close idle clients before the compute's suspend timeout closes them for you.&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// An idle client whose socket closed (for example, the compute suspended).&lt;/span&gt;
&lt;span class="c1"&gt;// The pool removes it; this listener stops it from crashing the process.&lt;/span&gt;
&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle client dropped:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Retry once on a dropped connection, but only when the caller says the&lt;/span&gt;
&lt;span class="c1"&gt;// statement is safe to run twice.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/Connection terminated/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry is off by default for a reason. When a connection drops, you cannot always tell whether the statement ran before it did, so only retry statements with no side effects or with an idempotency key. A plain read qualifies; an &lt;code&gt;INSERT&lt;/code&gt; without a key does not, and neither does a &lt;code&gt;SELECT&lt;/code&gt; that calls a function with side effects. The error check also catches only this one message, so treat it as a starting point, not complete coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  LISTEN does not keep it awake either
&lt;/h3&gt;

&lt;p&gt;A connection waiting on &lt;code&gt;LISTEN&lt;/code&gt; sits in the &lt;code&gt;idle&lt;/code&gt; state, so the monitor treats it like any idle connection. Both runs suspended on schedule. After each suspend the script opened a new connection, which woke the compute, and sent &lt;code&gt;NOTIFY keep_awake, 'after-suspend'&lt;/code&gt;. The listener never got it, in either run, because its connection had been closed by the suspend.&lt;/p&gt;

&lt;p&gt;If you use &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; for cache invalidation or live updates on a compute that scales to zero, the listener must reconnect and run &lt;code&gt;LISTEN&lt;/code&gt; again, and it must not assume it saw every notification sent while it was gone. Read the current state after each reconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  An open transaction keeps it awake for as long as it stays open
&lt;/h2&gt;

&lt;p&gt;The monitor counts any backend that is not &lt;code&gt;idle&lt;/code&gt; as activity, and a session that ran &lt;code&gt;BEGIN&lt;/code&gt; and then stopped is &lt;code&gt;idle in transaction&lt;/code&gt;. That is the classic leaked transaction: a request handler that checks out a client, starts a transaction, and returns on an error path without &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What was left open&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Suspended within 6 minutes&lt;/th&gt;
&lt;th&gt;After the transaction ended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BEGIN&lt;/code&gt; on a direct connection, then nothing&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0 of 2&lt;/td&gt;
&lt;td&gt;suspended 65 and 72 s later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BEGIN&lt;/code&gt; on a pooled connection, then nothing&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0 of 2&lt;/td&gt;
&lt;td&gt;suspended 68 and 72 s later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, with &lt;code&gt;idle_in_transaction_session_timeout = '30s'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2 of 2, 102 and 98 s after the last query&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Through the pooler it behaves the same way. In transaction mode, PgBouncer pins a server connection to the client for as long as the transaction is open, so Postgres still sees a backend &lt;code&gt;idle in transaction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With the timeout set, Postgres ended the leaked session after 30 seconds, and the compute suspended about 70 seconds after that, the same delay as with nothing open. That setting is the one line worth adding to every Neon project that scales to zero, at the database or role level so it applies to every connection:&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;-- Ends sessions that hold a transaction open with nothing running.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;neondb&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'60s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We checked this form too, with &lt;code&gt;30s&lt;/code&gt;: the database owner role that Neon creates is allowed to run it, a new connection reported &lt;code&gt;30s&lt;/code&gt; without setting anything itself, and a transaction leaked on that connection no longer kept the compute up. It suspended 101 seconds after the last query (&lt;code&gt;scripts/07-database-timeout.mjs&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Pick a value longer than any legitimate pause inside your transactions. A leaked transaction is a bug anyway: it holds locks and stops vacuum from cleaning up. On a compute that scales to zero, it also bills you for every hour it stays open.&lt;/p&gt;

&lt;h2&gt;
  
  
  A monitoring agent keeps it awake
&lt;/h2&gt;

&lt;p&gt;Monitoring agents such as &lt;code&gt;postgres_exporter&lt;/code&gt; keep a connection open and run queries on a timer. We ran the same pattern: one connection to the &lt;code&gt;postgres&lt;/code&gt; database, reading &lt;code&gt;pg_stat_database&lt;/code&gt; every 20 seconds.&lt;/p&gt;

&lt;p&gt;The compute stayed awake for the whole 6-minute window and suspended 63 seconds after the agent stopped. One run only, but it matches the code: each query moves the connection's &lt;code&gt;state_change&lt;/code&gt;, and the backend check counts that whichever database the query runs in. Only the &lt;code&gt;pg_stat_database&lt;/code&gt; totals leave the &lt;code&gt;postgres&lt;/code&gt; database out.&lt;/p&gt;

&lt;p&gt;So a database scrape every 15 or 30 seconds keeps the compute awake around the clock. If you want monitoring and scale to zero together, scrape less often than the suspend timeout and accept gaps, or read compute state from the Neon API, which does not wake the compute. That is how we watched compute state for this post without adding database activity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks: the interval decides everything
&lt;/h2&gt;

&lt;p&gt;Uptime monitors, load balancer probes and Kubernetes readiness probes often run a query to prove the database is reachable. Stage 2 runs exactly that: every N seconds, open a new pooled connection, run &lt;code&gt;SELECT 1&lt;/code&gt;, close it. Each configuration gets its own compute, starts from a suspended state, and runs for 12 to 60 minutes while the script records when the compute is awake.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suspend timeout&lt;/th&gt;
&lt;th&gt;Check every&lt;/th&gt;
&lt;th&gt;Minutes&lt;/th&gt;
&lt;th&gt;Checks&lt;/th&gt;
&lt;th&gt;Checks that woke it&lt;/th&gt;
&lt;th&gt;Awake&lt;/th&gt;
&lt;th&gt;CU-hours per 30 days at 0.25 CU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;90 s&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;82.2%&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;120 s&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;70.2%&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;180 s&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;40.5%&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;360 s&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;90.6%&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;420 s&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;74.7%&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 s&lt;/td&gt;
&lt;td&gt;600 s&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;52.1%&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Checks that woke it" counts the checks for which the last API reading before the check showed the compute suspended. When that number is 1, only the very first check of the run had to wake the compute, and every later check found it already awake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share of time the compute was awake, by health-check interval&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60 s timeout, check every 30 s&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;60 s suspend timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s timeout, check every 90 s&lt;/td&gt;
&lt;td&gt;82.2%&lt;/td&gt;
&lt;td&gt;60 s suspend timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s timeout, check every 120 s&lt;/td&gt;
&lt;td&gt;70.2%&lt;/td&gt;
&lt;td&gt;60 s suspend timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 s timeout, check every 180 s&lt;/td&gt;
&lt;td&gt;40.5%&lt;/td&gt;
&lt;td&gt;60 s suspend timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min timeout, check every 1 min&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;5 min suspend timeout (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min timeout, check every 5 min&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;5 min suspend timeout (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min timeout, check every 6 min&lt;/td&gt;
&lt;td&gt;90.6%&lt;/td&gt;
&lt;td&gt;5 min suspend timeout (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min timeout, check every 7 min&lt;/td&gt;
&lt;td&gt;74.7%&lt;/td&gt;
&lt;td&gt;5 min suspend timeout (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 min timeout, check every 10 min&lt;/td&gt;
&lt;td&gt;52.1%&lt;/td&gt;
&lt;td&gt;5 min suspend timeout (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;A new pooled connection and SELECT 1 every N seconds, one 0.25 CU compute per row, 12 to 60 minutes each, one run per row. Estimated by polling the Neon API; matches Neon's operations log within half a percentage point. Data: results/summary.json in The-DevOps-Daily/neon-keep-awake.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reason a 5-minute check beats a 5-minute timeout is the time between a wake and the suspend that follows it. Where the compute suspended between checks, we can estimate that cycle, from the moment the check started connecting to Neon's &lt;code&gt;suspended_at&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With the 60-second timeout, a compute woken by a single check suspended 71 to 87 seconds later (24 cycles, median 74 s).&lt;/li&gt;
&lt;li&gt;With the 5-minute timeout, it suspended 312 to 327 seconds later (19 cycles on three computes, median 314 s).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So in our runs, with the default timeout, a check that came within 312 seconds of the previous one always found the compute still up and started the clock again, and a check every 300 seconds kept it up for the whole 40-minute run. That is what we observed, not a guaranteed grace period: the suspend came 12 to 27 seconds after the timeout here, and Neon does not document that gap.&lt;/p&gt;

&lt;p&gt;Make the interval a little longer and the compute does get to sleep between checks, but not for long. Every check pays for a full cycle of about 5 minutes and 15 seconds, so every 6 minutes it was awake 90.6% of the time and every 7 minutes 74.7%. A check every 10 minutes woke the compute 6 times out of 6, and each wake kept it up 312 to 313 seconds for a query that took about a second. That is 52% of the hour, for a database that did nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a wake costs your users
&lt;/h3&gt;

&lt;p&gt;Letting the compute sleep has a price too: the first request after a quiet stretch waits for the compute to start.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;Checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check that found the compute awake&lt;/td&gt;
&lt;td&gt;308 to 339 ms per run&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;59 checks, 3 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check that had to wake it&lt;/td&gt;
&lt;td&gt;847 to 1,134 ms per run&lt;/td&gt;
&lt;td&gt;1,878 ms&lt;/td&gt;
&lt;td&gt;46 wakes, 9 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain TCP connect to the region (no TLS, no query)&lt;/td&gt;
&lt;td&gt;48 ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are end-to-end times for the whole check: open a new TLS connection, authenticate, run the query and close. That is why even a warm check takes about 300 ms from a client 48 ms away. In three of the runs only the first check woke the compute, so their wake figure is a single sample. Comparing across runs rather than pair by pair, waking the compute added roughly 0.5 to 0.8 seconds, and the slowest wake took 1.9 seconds. None of the 105 checks failed. For context, our &lt;a href="https://devops-daily.com/posts/neon-vs-supabase-free-tier-benchmarks" rel="noopener noreferrer"&gt;Neon free tier benchmarks&lt;/a&gt; measured cold starts with a median of 568 ms and a p95 of 1.06 s from a client in the same region, with a different method.&lt;/p&gt;

&lt;p&gt;For a health check, an extra second does not matter. For the first user request of the morning it might, and that is the real trade-off you are making when you decide how long the compute may sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Neon bills compute in CU-hours: compute size times the hours it runs. A compute kept awake by a light pattern like these sits at its minimum size, so the cost is the minimum CU times the hours awake (real load can scale it up, which costs more). With list prices from &lt;a href="https://neon.com/pricing" rel="noopener noreferrer"&gt;Neon's pricing page&lt;/a&gt; ($0.106 per CU-hour on Launch, $0.222 on Scale) and the Free plan's 100 CU-hours per project per month, for one 0.25 CU compute over 30 days with nothing else using it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What keeps it awake&lt;/th&gt;
&lt;th&gt;Awake&lt;/th&gt;
&lt;th&gt;CU-hours per 30 days at 0.25 CU&lt;/th&gt;
&lt;th&gt;Launch&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;th&gt;Free plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health check every 1 or 5 min, default timeout&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;$19.08&lt;/td&gt;
&lt;td&gt;$39.96&lt;/td&gt;
&lt;td&gt;allowance used up after 400 hours, about day 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health check every 6 min, default timeout&lt;/td&gt;
&lt;td&gt;90.6%&lt;/td&gt;
&lt;td&gt;163&lt;/td&gt;
&lt;td&gt;$17.28&lt;/td&gt;
&lt;td&gt;$36.19&lt;/td&gt;
&lt;td&gt;used up around day 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health check every 10 min, default timeout&lt;/td&gt;
&lt;td&gt;52.1%&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;$9.94&lt;/td&gt;
&lt;td&gt;$20.82&lt;/td&gt;
&lt;td&gt;fits, with 6 CU-hours left for real traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaked transaction, monitoring agent every 20 s&lt;/td&gt;
&lt;td&gt;100% while it lasts&lt;/td&gt;
&lt;td&gt;up to 180&lt;/td&gt;
&lt;td&gt;up to $19.08&lt;/td&gt;
&lt;td&gt;up to $39.96&lt;/td&gt;
&lt;td&gt;as above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle connections, &lt;code&gt;LISTEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;suspended normally&lt;/td&gt;
&lt;td&gt;no sustained cost observed&lt;/td&gt;
&lt;td&gt;none seen&lt;/td&gt;
&lt;td&gt;none seen&lt;/td&gt;
&lt;td&gt;none seen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the Free plan, Neon's plans page says that when a project runs out of CU-hours, its compute is suspended until the next billing period. A 5-minute health check on a Free project, starting the month with the full allowance and nothing else using it, does not cost money; it takes the database down for the last 13 days of a 30-day month.&lt;/p&gt;

&lt;p&gt;Two things make these numbers bigger in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum size.&lt;/strong&gt; We fixed every compute at 0.25 CU. If your compute's autoscaling minimum is 1 CU, multiply by four: an always-awake 1 CU compute is 720 CU-hours a month, $76 on Launch and $160 on Scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branches.&lt;/strong&gt; Every branch with a compute has its own suspend timer. A preview environment per pull request, each with its own health check, keeps each preview branch's compute awake. Ten open previews with a 5-minute check are ten always-on computes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does Neon wake it on its own?
&lt;/h2&gt;

&lt;p&gt;A May 2026 thread in Neon's GitHub discussions, &lt;a href="https://github.com/neondatabase/neon/discussions/12900" rel="noopener noreferrer"&gt;neondatabase/neon#12900&lt;/a&gt;, describes Launch projects at 0.25 CU that each used about 6 CU-hours a day whatever their traffic, which is a compute awake around the clock. One reply blamed &lt;code&gt;check_availability&lt;/code&gt;, an operation that Neon's control plane runs to &lt;a href="https://neon.com/docs/manage/operations" rel="noopener noreferrer"&gt;check that a compute can start on a branch&lt;/a&gt;. We looked for it in two places.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Our test project.&lt;/strong&gt; Stage 3 watched the main branch's compute, which nothing ever connected to, for 3.9 hours after it suspended, reading its state every 15 seconds. It never woke. The project's operations log, read at the end, shows no &lt;code&gt;check_availability&lt;/code&gt; and no &lt;code&gt;start_compute&lt;/code&gt; for it after the first suspend, and Neon's usage counter for that branch read 368 active seconds, about the six minutes the compute ran after we created the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An older project of ours.&lt;/strong&gt; Its main compute suspended on 23 September, and we stopped using the project that evening. Its operations log shows one &lt;code&gt;check_availability&lt;/code&gt; for that compute on 24 September, 929 ms long, and no &lt;code&gt;start_compute&lt;/code&gt; for it afterwards. The compute's &lt;code&gt;suspended_at&lt;/code&gt; still reads 23 September. Two weeks later Neon archived the idle branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matches Neon's own description. The operations page says the check verifies that a compute &lt;em&gt;can&lt;/em&gt; start, and a &lt;a href="https://neon.com/docs/changelog/2023-08-28" rel="noopener noreferrer"&gt;2023 changelog entry&lt;/a&gt; says the availability checker runs on 0.25 CU computes and can use pre-started ones. So in these two records, no availability check restarted a suspended compute. That does not rule out other behavior on other projects, and we cannot tell what kept the computes in that thread awake. The patterns that kept ours awake were open transactions, frequent monitoring queries and frequent health checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the database out of liveness checks.&lt;/strong&gt; A liveness probe should check that the process is up, not that Postgres answers. If you must check the database, do it well below the suspend timeout's rate: with the default 5-minute timeout, every 10 minutes still kept the compute awake half the time, so think in hours, not minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check compute state through the API instead of with a query.&lt;/strong&gt; &lt;code&gt;GET /projects/{project_id}/endpoints/{endpoint_id}&lt;/code&gt; returns &lt;code&gt;current_state&lt;/code&gt; without waking anything. A monitor that alerts on the API's view of the compute, and only queries the database when a user is actually using it, keeps scale to zero working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; at the database level.&lt;/strong&gt; It ends leaked transactions, which otherwise keep the compute awake for as long as they stay open, and they are a bug anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrape metrics less often than the timeout, or not from the database.&lt;/strong&gt; A monitoring agent that queries every 15 to 30 seconds turns scale to zero off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect connections to drop.&lt;/strong&gt; Idle and &lt;code&gt;LISTEN&lt;/code&gt; connections, direct or pooled, were closed when the compute suspended. Use a pool, handle its &lt;code&gt;error&lt;/code&gt; event, retry only statements that are safe to repeat, and re-run &lt;code&gt;LISTEN&lt;/code&gt; after a reconnect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you want it awake, say so.&lt;/strong&gt; On paid plans you can raise the suspend timeout or turn scale to zero off. For a production database with steady traffic, that is often the right call. The compute bill is the same either way, but a setting you chose is explicit and predictable, and a health check that happens to keep the compute up is neither.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample sizes are small.&lt;/strong&gt; Three runs for each idle-connection row, two for the transaction and &lt;code&gt;LISTEN&lt;/code&gt; rows, one for the monitoring agent, one for the database-level timeout check, and one run per health-check row. The results agree with the activity-monitor code, which is why we trust them, but the later suspend we saw with idle connections (median 81 to 82 seconds against 68) is not explained, and those scenarios ran one after another on the same compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open transactions were watched for 6 minutes.&lt;/strong&gt; The code says such a session counts as activity for as long as it stays open, and all four runs stayed awake for the whole window; we did not run one for hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One region, one compute size.&lt;/strong&gt; Everything ran in &lt;code&gt;aws-eu-central-1&lt;/code&gt; at 0.25 CU. The time from a wake to the suspend (71 to 87 seconds with a 60-second timeout, 312 to 327 seconds with 5 minutes) may differ in other regions or under load. It also differed between computes in the same project: with the 5-minute timeout, one compute's cycles took 325 to 327 seconds every time while the others took 312 to 314. We do not know why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost is computed, not billed.&lt;/strong&gt; Neon's per-branch usage counters (&lt;code&gt;active_time_seconds&lt;/code&gt;, &lt;code&gt;compute_time_seconds&lt;/code&gt;) read 0 for the short runs when we deleted their branches, and for the longer runs they were well behind our own measurement (for example 988 active seconds against our 1,877), so they seem to update with a delay. We could not check CU-hours against Neon's meter. We checked the awake time against Neon's operations log instead, and converted with list prices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks were on a precise schedule, and the gap after the timeout is not documented.&lt;/strong&gt; A real uptime service has some jitter, and Neon may suspend sooner after the timeout on other computes or in other regions. Either could let a 5-minute check miss now and then; we did not test that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not test logical replication or autovacuum.&lt;/strong&gt; The code says both keep a compute awake, and Neon's docs say so for replication subscribers.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Scale to zero on Neon worked the way the compute's own activity monitor says it should. Idle connections, pooled or direct, and &lt;code&gt;LISTEN&lt;/code&gt; connections did not hold a compute up; they were cut when it suspended. Activity did hold it up: a leaked transaction, a monitoring agent, a health check every 5 minutes with the default timeout. That last one is the expensive surprise. It costs a 0.25 CU compute 180 CU-hours a month, which is about $19 on Launch, $40 on Scale, or almost two weeks of downtime on Free.&lt;/p&gt;

&lt;p&gt;Run the scripts against your own project to see which of these you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/The-DevOps-Daily/neon-keep-awake
&lt;span class="nb"&gt;cd &lt;/span&gt;neon-keep-awake &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env &lt;span class="c"&gt;# add a Neon API key&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run setup &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run health-checks

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/what-keeps-a-neon-compute-awake" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>neon</category>
      <category>postgres</category>
      <category>scaletozero</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
