<?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>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>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>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>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>
    <item>
      <title>SOC 2 Evidence From GitHub and Terraform, and the Part That Still Needs a Person</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/soc-2-evidence-from-github-and-terraform-and-the-part-that-still-needs-a-person-4b20</link>
      <guid>https://dev.to/devopsdaily/soc-2-evidence-from-github-and-terraform-and-the-part-that-still-needs-a-person-4b20</guid>
      <description>&lt;p&gt;We pointed a small evidence collector at a GitHub repository whose main branch had a strict ruleset: every change through a pull request, one approval, and a required &lt;code&gt;test&lt;/code&gt; check. The ruleset control passed. Three of the change-management controls under it failed: none of the seven merged pull requests had an independent approval, one was merged five seconds before its check finished, and one commit reached main without a pull request at all. All three were real, and all three would have looked fine in a screenshot of the settings page.&lt;/p&gt;

&lt;p&gt;That gap is the whole job when a customer asks for SOC 2. An auditor does not only want to know what you configured. For a Type II report they want to see what happened under that configuration across the review period, usually by sampling. This post shows which of that evidence you can pull from the GitHub API and your Terraform code, which still needs a person and a signature, and the ten ways the first version of our collector could pass a control without evidence.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Settings are not evidence of operation. Our main branch required approvals and a passing check, and the evidence still showed 0 of 7 pull requests independently approved and 1 merged before its check finished, because admins could bypass the rule.&lt;/li&gt;
&lt;li&gt;"The checks are green" is the wrong question. Pull request #3's check passed 5 seconds after the merge. It is green today; it did not gate the merge. Compare the check's completion time with the merge time.&lt;/li&gt;
&lt;li&gt;An evidence script must fail closed. Two reviews of our first version found ten ways it could pass a control on missing or partial data, from approvals of an older commit to a branch name that could change the API query. Each now reports &lt;code&gt;fail&lt;/code&gt; or &lt;code&gt;incomplete&lt;/code&gt;, with a test.&lt;/li&gt;
&lt;li&gt;Terraform scanning is evidence of a configuration standard, but a pass is only as good as the rule list. Trivy flagged the public database flag, and its security-group rule passed a Postgres port open to the whole internet, because that rule only covers SSH and RDP.&lt;/li&gt;
&lt;li&gt;Access reviews, policy sign-offs, training, risk assessment, vendor reviews and incident-response tests still need a person. Automation can gather the list; it cannot make the judgement.&lt;/li&gt;
&lt;li&gt;One run took 23 seconds and 35 API calls for one repository and 90 days, and produced 15 timestamped evidence files with a SHA-256 manifest.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A GitHub repository you are admin on, and a token in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; or a logged-in &lt;code&gt;gh&lt;/code&gt; CLI&lt;/li&gt;
&lt;li&gt;Python 3.10 or later (the collector uses only the standard library)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://trivy.dev" rel="noopener noreferrer"&gt;Trivy&lt;/a&gt; for the Terraform scan&lt;/li&gt;
&lt;li&gt;A rough idea of what SOC 2 is: an AICPA attestation report on your controls, mapped to the Trust Services Criteria&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  What an auditor actually asks for
&lt;/h2&gt;

&lt;p&gt;SOC 2 reports on the &lt;a href="https://www.aicpa-cima.com/resources/download/2017-trust-services-criteria-with-revised-points-of-focus-2022" rel="noopener noreferrer"&gt;Trust Services Criteria&lt;/a&gt; (free download with an AICPA account), a set of criteria with "points of focus" that describe what a control usually covers. You choose the controls; the auditor tests them. A Type I report says the controls were designed and in place on one day. A Type II report says they operated over a period, often 3 to 12 months, and that is where the evidence work goes: the auditor picks a sample of changes, alerts and access changes from the period and asks you to show each one.&lt;/p&gt;

&lt;p&gt;Three criteria cover most of what a code repository can prove. In the AICPA's words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CC8.1&lt;/strong&gt; The entity authorizes, designs, develops or acquires, configures, documents, tests, approves, and implements changes to infrastructure, data, software, and procedures to meet its objectives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CC7.1&lt;/strong&gt; To meet its objectives, the entity uses detection and monitoring procedures to identify (1) changes to configurations that result in the introduction of new vulnerabilities, and (2) susceptibilities to newly discovered vulnerabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CC6.1&lt;/strong&gt; The entity implements logical access security software, infrastructure, and architectures over protected information assets to protect them from security events to meet the entity's objectives.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a change, "approves" and "tests" are the parts people get caught on. If your change-management control says that someone other than the author approves each change and that it is tested before it reaches production, the auditor will ask you to show both for each sampled change. For vulnerabilities, they want to see that you detect them and fix the serious ones within the time your own policy promises.&lt;/p&gt;

&lt;p&gt;The usual way to produce this is screenshots: the branch protection page, the pull request page for each sampled change, the Dependabot tab, the security settings. A screenshot shows one moment, and nothing in it says where the values came from. An API response saved with the time it was collected is easier to check: the auditor can see the source of each value, and you can run the same query again. A hash manifest lets you show that a file did not change after collection, as long as the manifest is stored where nobody can edit it. And a script can collect the evidence for every change in the period instead of a sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collector
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;collect.py&lt;/code&gt; reads one repository through the GitHub API and evaluates eight controls:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Where the evidence comes from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CC8.1&lt;/td&gt;
&lt;td&gt;Changes to the default branch go through a pull request&lt;/td&gt;
&lt;td&gt;branch rules and classic branch protection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC8.1&lt;/td&gt;
&lt;td&gt;Every merged change passed the required checks before the merge&lt;/td&gt;
&lt;td&gt;the last run of each required check on the pull request's head commit that started before the merge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC8.1&lt;/td&gt;
&lt;td&gt;Every merged change was approved by someone other than its author&lt;/td&gt;
&lt;td&gt;each reviewer's last decision before the merge, for the merged commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC8.1&lt;/td&gt;
&lt;td&gt;Nothing reached the default branch without a pull request&lt;/td&gt;
&lt;td&gt;the merged pull request behind each commit, and the branch's activity log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC7.1&lt;/td&gt;
&lt;td&gt;Dependency vulnerability alerts are on&lt;/td&gt;
&lt;td&gt;the Dependabot setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC7.1&lt;/td&gt;
&lt;td&gt;No critical or high alert is open past the SLA&lt;/td&gt;
&lt;td&gt;open Dependabot alerts and their age&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.1&lt;/td&gt;
&lt;td&gt;Secret scanning and push protection are on&lt;/td&gt;
&lt;td&gt;the repository's security settings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.3&lt;/td&gt;
&lt;td&gt;Repository access follows least privilege&lt;/td&gt;
&lt;td&gt;collaborator roles, judged by a person&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It saves the fields each control uses from each API response, with the collection time, in a timestamped folder with a SHA-256 manifest, and marks each control &lt;code&gt;pass&lt;/code&gt;, &lt;code&gt;fail&lt;/code&gt;, &lt;code&gt;needs a person&lt;/code&gt; or &lt;code&gt;incomplete&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GitHub API&lt;/strong&gt; rules, PRs, checks, alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;collect.py&lt;/strong&gt; 8 controls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;raw/*.json&lt;/strong&gt; timestamped, hashed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;summary.json&lt;/strong&gt; pass, fail, incomplete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A person&lt;/strong&gt; judgement, sign-off&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three of the controls look back over the window: the merged pull requests with their reviews and checks, the commits on main, and the branch's activity log. The other five read the settings and the open alerts as they are on the day you run it. One run shows the history of changes. For the history of settings across a Type II period, run it on a schedule and keep every folder.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test subject
&lt;/h3&gt;

&lt;p&gt;We made the collector's own repository its test subject, with a history built on purpose. Its main branch has a ruleset that requires a pull request, one approval and the &lt;code&gt;test&lt;/code&gt; check, with a bypass for organization admins. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first commit went straight to main, before the ruleset existed.&lt;/li&gt;
&lt;li&gt;Every pull request was merged by an admin through the bypass. Nobody else could approve: it is a one-person setup, which is exactly the situation many small teams are in when the first enterprise customer asks for SOC 2.&lt;/li&gt;
&lt;li&gt;Pull request #3 was merged while its &lt;code&gt;test&lt;/code&gt; check was still running.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;examples/app/requirements.txt&lt;/code&gt; pins PyYAML 5.3 and Jinja2 2.10, which have published advisories, so Dependabot has alerts to report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The run
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;collect.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 collect.py The-DevOps-Daily/soc2-evidence-collector --days 90
The-DevOps-Daily/soc2-evidence-collector, last 90 days: 15 evidence files, 35 API calls, 22.7 s
  CC8.1 pass Changes to the default branch go through a pull request
         rule on main: pull_request, 1 approval(s) required
  CC8.1 fail Every merged change passed the required checks before the merge
         required: test; 6 of 7 merged PRs passed every required check before the merge (#3 not finished)
  CC8.1 fail Every merged change was approved by someone other than its author
         0 of 7 merged PRs had an independent approval of the merged commit
  CC8.1 fail Nothing reached the default branch without a pull request
         commits not linked to a merged PR: 1 of 8; branch updates that were not PR merges: 1 (branch_creation)
  CC7.1 pass Dependency vulnerability alerts are on
         Dependabot alerts on
  CC7.1 pass No critical or high alert is open past 30 days
         8 open alerts, 0 critical or high open longer than 30 days
  CC6.1 pass Secret scanning and push protection are on
         secret scanning enabled, push protection enabled
  CC6.3 needs a person Repository access follows least privilege
         roles: {'admin': 4}, outside collaborators: 0; the list is evidence, the judgement is not
evidence: evidence/The-DevOps-Daily__soc2-evidence-collector/20261019T131928Z

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

&lt;/div&gt;



&lt;p&gt;It found every gap we put in, and it exits non-zero, so it can gate a pipeline or a weekly job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the failures mean
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The rule was on, and the evidence still failed
&lt;/h3&gt;

&lt;p&gt;The first control passed: a pull request with one approval is required on main. The approval control under it failed for all seven merged pull requests. An admin bypass is not a bug in the ruleset. Many teams keep one for emergencies, and an auditor's sample may well include a change that used it. If your system description says all changes are approved by a second person and your evidence shows admin merges without approval, expect the auditor to report them as exceptions.&lt;/p&gt;

&lt;p&gt;For a one-person or two-person team, the honest fix is not to pretend. Common approaches are a reviewer from outside the team for production repositories, or a documented control that someone reviews merged changes after the fact within a fixed time. Which of these your auditor accepts is a conversation to have before the review period starts, not after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Green now is not the same as tested before the merge
&lt;/h3&gt;

&lt;p&gt;Here is the evidence file for pull request #3 as the collector saved it, without the author's login and with the indentation widened for reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"collected_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-19T13:19:28.577807+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub API, The-DevOps-Daily/soc2-evidence-collector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pull"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Examples to scan, and the IaC scan script"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"merged_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-19T12:50:03Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"html_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/The-DevOps-Daily/soc2-evidence-collector/pull/3"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"head_sha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d4b17526cfcaf516833023b9ec402f45a70a10ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"check_runs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"app_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15368&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"head_sha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d4b17526cfcaf516833023b9ec402f45a70a10ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"conclusion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"started_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-19T12:50:01Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"completed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-19T12:50:08Z"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

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

&lt;/div&gt;



&lt;p&gt;The check started at 12:50:01. The pull request was merged at 12:50:03. The check succeeded at 12:50:08. Look at the pull request page today and you see a green check, which is what most screenshots record. The collector compares the timestamps, and so should anything you hand to an auditor.&lt;/p&gt;

&lt;p&gt;It asks GitHub for every run of the check, not only the latest, and judges each required check by its last run that started before the merge: that run is the check's state at the merge. If it had not finished by then, the check did not gate the merge, even if an earlier run had passed. A re-run started after the merge does not count. Only &lt;code&gt;success&lt;/code&gt; passes: GitHub lets a &lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging" rel="noopener noreferrer"&gt;skipped or neutral required check&lt;/a&gt; through, but a skipped check ran no tests. A required check stops a normal merge until it passes, so a merge before completion usually means someone used a bypass. It can also mean the required checks changed after the merge, because the collector compares with today's rules. Either way, it is an exception to explain.&lt;/p&gt;

&lt;h3&gt;
  
  
  The commit with no pull request
&lt;/h3&gt;

&lt;p&gt;The one commit without a pull request is the repository's first commit, pushed before the ruleset existed. The branch's activity log shows the same event from the other side: one update to main that was not a pull request merge, the creation of the branch. That is a normal, explainable exception, and the evidence lets you explain it with a date.&lt;/p&gt;

&lt;p&gt;The two sources catch different things. The commit list only holds commits that are still on main, so a force push that rewrote history leaves nothing in it. The activity log records updates to the branch, including direct pushes and force pushes, so an admin pushing a hotfix straight to main shows up there even when the commit list looks clean. If the log does not reach back to the start of the window, the control reports &lt;code&gt;incomplete&lt;/code&gt; instead of &lt;code&gt;pass&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The alerts that passed
&lt;/h3&gt;

&lt;p&gt;Dependabot raised 8 open alerts for the two pinned packages: 2 critical, 1 high and 5 medium. The SLA control passed, correctly, because the alerts were less than an hour old. Our default SLA is 30 days for critical and high; use the number your own vulnerability policy states, since that is what the auditor will hold you to. If those alerts are still open, the same run in five weeks would fail.&lt;/p&gt;

&lt;p&gt;This control reads the alerts that are open today. A critical alert that stayed open for two months last spring and was fixed in the summer does not appear in it. To cover a period, keep each run's folder, or extend the collector to fixed alerts and compare their &lt;code&gt;fixed_at&lt;/code&gt; with &lt;code&gt;created_at&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten ways our first version could pass without evidence
&lt;/h2&gt;

&lt;p&gt;Two reviews of the first version of the collector, one of the code and one of the post against the recorded output, found ten ways it could pass a control, or count something as evidence, when the data did not support it. For a tool whose output goes to an auditor, each one is a real defect: a control must never pass because data was missing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What went wrong&lt;/th&gt;
&lt;th&gt;What the collector does now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An approval of an older commit counted&lt;/td&gt;
&lt;td&gt;Only an approval whose &lt;code&gt;commit_id&lt;/code&gt; is the merged head commit counts; the rest are reported as stale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A withdrawn approval counted&lt;/td&gt;
&lt;td&gt;Only each reviewer's last decision before the merge counts, so a later change request or a dismissal replaces an approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any passing or skipped check counted as the required one&lt;/td&gt;
&lt;td&gt;Each required check is matched by name, and by app when the rule names one, and only &lt;code&gt;success&lt;/code&gt; passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An earlier pass could hide a later failure, or a re-run still going at the merge&lt;/td&gt;
&lt;td&gt;Each required check is judged by its last run that started before the merge, which must have succeeded before the merge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unreadable alerts looked like zero alerts&lt;/td&gt;
&lt;td&gt;A 403 or 404 makes the SLA control &lt;code&gt;incomplete&lt;/code&gt;, with the HTTP status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lists were cut off silently&lt;/td&gt;
&lt;td&gt;A list cut at its limit makes its control &lt;code&gt;incomplete&lt;/code&gt;, and an error on a later page stops the run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any pull request link counted&lt;/td&gt;
&lt;td&gt;The pull request must be merged into the default branch and be one of the merged pull requests evaluated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A force push left no trace&lt;/td&gt;
&lt;td&gt;The branch's activity log is read alongside the commit list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The SLA was rounded down to whole days, and alerts without a severity were skipped&lt;/td&gt;
&lt;td&gt;The age is compared as a time, and an unknown severity makes the control &lt;code&gt;incomplete&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch names went into URLs unencoded, and answers were not checked&lt;/td&gt;
&lt;td&gt;Every value is encoded, and a pull request, activity entry or check run for another branch or commit makes its list &lt;code&gt;incomplete&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one is easy to miss. Git allows &lt;code&gt;&amp;amp;&lt;/code&gt; and &lt;code&gt;#&lt;/code&gt; in branch names, so a default branch with a crafted name could change the query string, and the collector would judge GitHub's answer to a different question.&lt;/p&gt;

&lt;p&gt;None of these changed a result for our demo repository: it has no approvals at all, a readable list of 8 alerts and eight commits. They would change results for a real repository, where a stale approval, a re-run check or a missing permission is common. Each fix has a test in the repository.&lt;/p&gt;

&lt;p&gt;Running the collector also found one more problem: the Dependabot alerts API rejects page numbers (&lt;code&gt;Pagination using the page parameter is not supported&lt;/code&gt;) and pages with cursors in the &lt;code&gt;Link&lt;/code&gt; header instead. The collector now follows the &lt;code&gt;Link&lt;/code&gt; header for every list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence from Terraform
&lt;/h2&gt;

&lt;p&gt;CC7.1's points of focus start with "Uses Defined Configuration Standards" and "Monitors Infrastructure and Software" for noncompliance with them. For infrastructure as code, a misconfiguration scan in CI is evidence of both: the rule set is your standard, and the scan result on each change is the monitoring.&lt;/p&gt;

&lt;p&gt;The repository's &lt;code&gt;examples/terraform&lt;/code&gt; holds a small AWS stack: a customer-data bucket with a KMS key, versioning and a public access block; an exports bucket added later without any of those; and a Postgres instance, encrypted and backed up, with a security group open to &lt;code&gt;0.0.0.0/0&lt;/code&gt; on port 5432 and &lt;code&gt;publicly_accessible = true&lt;/code&gt;. &lt;code&gt;scan-iac.sh&lt;/code&gt; runs Trivy and keeps the report, a summary and a manifest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan-iac.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;$ ./scan-iac.sh examples/terraform
92 checks, 12 failed, 4.6 s
  HIGH AWS-0086 aws_s3_bucket.exports S3 Access block should block public ACL
  HIGH AWS-0087 aws_s3_bucket.exports S3 Access block should block public policy
  HIGH AWS-0091 aws_s3_bucket.exports S3 Access Block should Ignore Public ACL
  HIGH AWS-0093 aws_s3_bucket.exports S3 Access block should restrict public bucket to limit access
  HIGH AWS-0132 aws_s3_bucket.exports S3 encryption should use Customer Managed Keys
  HIGH AWS-0180 aws_db_instance.app RDS Publicly Accessible
  MEDIUM AWS-0090 aws_s3_bucket.exports S3 Data should be versioned
  MEDIUM AWS-0176 aws_db_instance.app RDS IAM Database Authentication Disabled
  LOW AWS-0089 aws_s3_bucket.customer_data S3 Bucket Logging
  LOW AWS-0089 aws_s3_bucket.exports S3 Bucket Logging
  LOW AWS-0094 aws_s3_bucket.exports S3 buckets should each define an aws_s3_bucket_public_access_block
  LOW AWS-0133 aws_db_instance.app Enable Performance Insights to detect potential problems
evidence: evidence/iac/20261019T130914Z

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

&lt;/div&gt;



&lt;p&gt;The exports bucket and the public database were both caught. The open security group was not. Trivy's ingress rule AWS-0107, titled "Security groups should not allow unrestricted ingress to SSH or RDP from any IP address", passed: port 5432 is neither SSH nor RDP. The database was flagged only because &lt;code&gt;publicly_accessible&lt;/code&gt; was also set. We removed that line, kept the security group and scanned again:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan-iac.sh, without publicly_accessible&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;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; examples/terraform /tmp/sg-only &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/publicly_accessible/d'&lt;/span&gt; /tmp/sg-only/main.tf
&lt;span class="nv"&gt;$ &lt;/span&gt;./scan-iac.sh /tmp/sg-only
92 checks, 11 failed, 4.2 s
  HIGH AWS-0086 aws_s3_bucket.exports S3 Access block should block public ACL
  HIGH AWS-0087 aws_s3_bucket.exports S3 Access block should block public policy
  HIGH AWS-0091 aws_s3_bucket.exports S3 Access Block should Ignore Public ACL
  HIGH AWS-0093 aws_s3_bucket.exports S3 Access block should restrict public bucket to limit access
  HIGH AWS-0132 aws_s3_bucket.exports S3 encryption should use Customer Managed Keys
  MEDIUM AWS-0090 aws_s3_bucket.exports S3 Data should be versioned
  MEDIUM AWS-0176 aws_db_instance.app RDS IAM Database Authentication Disabled
  LOW AWS-0089 aws_s3_bucket.customer_data S3 Bucket Logging
  LOW AWS-0089 aws_s3_bucket.exports S3 Bucket Logging
  LOW AWS-0094 aws_s3_bucket.exports S3 buckets should each define an aws_s3_bucket_public_access_block
  LOW AWS-0133 aws_db_instance.app Enable Performance Insights to detect potential problems
evidence: evidence/iac/20261019T130925Z

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

&lt;/div&gt;



&lt;p&gt;The only database findings left are IAM authentication and Performance Insights. Nothing points at a database port open to the world.&lt;/p&gt;

&lt;p&gt;That is the general limit of configuration scanning as evidence. A pass means "none of these rules failed", not "this is safe". Your configuration standard is the rule set you run plus the rules you add for what it misses. Trivy and similar tools accept custom policies, and a rule for "no database port open to &lt;code&gt;0.0.0.0/0&lt;/code&gt;" is a few lines.&lt;/p&gt;

&lt;p&gt;The scan reads code, not the running account. For the period itself, the auditor may ask for evidence that production matches the code: drift detection, or reading the live configuration through the cloud provider's API. That is the same pattern as the GitHub collector, with a different API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still needs a person
&lt;/h2&gt;

&lt;p&gt;Some controls produce evidence a script can gather but not judge, and some produce no machine evidence at all. These are the ones that usually remain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;What the auditor wants&lt;/th&gt;
&lt;th&gt;Why a script cannot finish it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CC6.2, CC6.3&lt;/td&gt;
&lt;td&gt;Periodic access reviews: someone confirms each account and role is still needed&lt;/td&gt;
&lt;td&gt;The collector lists the 4 admins; whether each should be an admin is a decision, with a name and a date on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC6.2&lt;/td&gt;
&lt;td&gt;Access removed when someone leaves&lt;/td&gt;
&lt;td&gt;Needs the HR leaving date to compare with when access ended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC1.4, CC2.2&lt;/td&gt;
&lt;td&gt;Security awareness training completed&lt;/td&gt;
&lt;td&gt;The record lives in a training tool or a signed list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC5.3&lt;/td&gt;
&lt;td&gt;Policies written, approved and acknowledged&lt;/td&gt;
&lt;td&gt;A policy document and each employee's acknowledgment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC3.2&lt;/td&gt;
&lt;td&gt;A documented risk assessment and its periodic review&lt;/td&gt;
&lt;td&gt;A document and a dated review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC9.2&lt;/td&gt;
&lt;td&gt;Vendor and subprocessor reviews&lt;/td&gt;
&lt;td&gt;An assessment of each vendor's security, often based on the vendor's own SOC 2 report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CC7.4, CC7.5&lt;/td&gt;
&lt;td&gt;Incident response that works&lt;/td&gt;
&lt;td&gt;A tabletop exercise or a real incident, with its write-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A1.3&lt;/td&gt;
&lt;td&gt;Recovery tested (if Availability is in scope)&lt;/td&gt;
&lt;td&gt;A restore test with its result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Automation helps with all of these by collecting the inputs and reminding people, but the evidence is a person's decision. In our collector, CC6.3 deliberately ends at &lt;code&gt;needs a person&lt;/code&gt;: it gives the reviewer the list, and the reviewer's sign-off is the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it or buy it
&lt;/h2&gt;

&lt;p&gt;The compliance automation platforms, such as Vanta, Drata, Secureframe, Sprinto and Thoropass, do the same thing at a much wider scale. They connect to source control, cloud accounts, identity providers and HR systems, map what they collect to controls, track the people-side tasks such as training and policy acknowledgments, and give the auditor a place to review the evidence. For a team going through its first audit, that coverage and the auditor relationships that come with it are the reason to pay for one.&lt;/p&gt;

&lt;p&gt;What a small script like ours is good for is understanding what those tools check, and covering what is specific to you: a custom deployment pipeline, a check a platform does not have, or a control you want tested on every change. It can run alongside a platform as well as instead of one. Whichever you use, test it the way we tested ours: give it a repository with known problems and confirm it fails on each of them. A tool you have only seen report &lt;code&gt;pass&lt;/code&gt; has not shown you that it can fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time
&lt;/h2&gt;

&lt;p&gt;For one repository and 90 days, the collector made 35 API calls and wrote 15 evidence files in 23 seconds. By hand, the closest you get is a screenshot of the ruleset, one of each merged pull request with its checks and reviews, the Dependabot tab, the security settings and the collaborator list: eleven screenshots here. They still miss the branch's history and the links from each commit to its pull request, which the direct-push control reads, and the pull request page does not put the check's start and completion times next to the merge time. We did not time the manual version, so we will not give a number for it. The difference grows with the sample: an auditor sampling 25 changes across 10 repositories is 25 pull request pages by hand, and one run per repository for the script.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Collect evidence of what happened, not only of what is configured. Our ruleset passed while three change-management controls failed under it.&lt;/li&gt;
&lt;li&gt;Compare timestamps. A check that is green today may have finished after the merge.&lt;/li&gt;
&lt;li&gt;Make evidence scripts fail closed: stale or withdrawn approvals, the wrong check run, unreadable data, wrong links and truncated lists must never pass.&lt;/li&gt;
&lt;li&gt;Treat a configuration scan as one rule set, not as proof of safety, and add rules for what it misses. Ours passed a database port open to the internet.&lt;/li&gt;
&lt;li&gt;Leave the judgement to people, and make their sign-off the evidence. A script can gather the access list; it cannot decide who should be on it.&lt;/li&gt;
&lt;li&gt;Agree with your auditor on how exceptions, such as admin bypasses, are handled before the review period starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The collector, the Terraform example and the tests are in &lt;a href="https://github.com/The-DevOps-Daily/soc2-evidence-collector" rel="noopener noreferrer"&gt;The-DevOps-Daily/soc2-evidence-collector&lt;/a&gt;. Point it at a repository you own and see which controls fail.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/soc2-evidence-from-github-and-terraform" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>soc2</category>
      <category>compliance</category>
      <category>github</category>
    </item>
    <item>
      <title>Neon Realtime, Measured: Live SQL Queries Replace the LISTEN/NOTIFY Layer We Built by Hand</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/neon-realtime-measured-live-sql-queries-replace-the-listennotify-layer-we-built-by-hand-2pl4</link>
      <guid>https://dev.to/devopsdaily/neon-realtime-measured-live-sql-queries-replace-the-listennotify-layer-we-built-by-hand-2pl4</guid>
      <description>&lt;p&gt;In July we built realtime for a Neon app by hand: a Neon Function holding one server-sent events stream per browser, a Postgres &lt;code&gt;LISTEN&lt;/code&gt; connection per function isolate, and a &lt;code&gt;pg_notify&lt;/code&gt; call after every write. It kept one counter in sync. In August we did it again for a chat, with WebSockets in place of SSE and the same &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt; underneath.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://neon.com/docs/realtime/overview" rel="noopener noreferrer"&gt;Neon Realtime&lt;/a&gt;, in public beta since October 15, replaces all of that with a SQL query you subscribe to. We rebuilt the idea as a live order board on Drizzle and measured it on a real Neon project in &lt;code&gt;aws-eu-central-1&lt;/code&gt;, from a client about 32 ms away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A plain live query kept pace with the write itself.&lt;/strong&gt; Across 16,200 writes at 1, 10 and 100 per second, the update reached the subscriber 35 to 43 ms (p50) after the write was issued, while the write's own commit took 34 to 40 ms to come back. Joins and aggregates were live too. All 48,600 writes in the matrix reached the subscriber, and every check we ran on the updates passed (which rows each one added, how many it removed, the size of every full result, the aggregate's total order count). We did not check every column value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But in this beta, most of the joins we tried, every aggregate and any &lt;code&gt;ORDER BY&lt;/code&gt; were re-sent in full on every write.&lt;/strong&gt; The three-table join we first wrote for the board pulled 27 KB per write on a 200-row result, 52 times what the plain query used, and the cost grows with the result: 134 KB per write at 1,000 rows. At 100 writes per second, Neon kept up with those full results in our two complete raw-protocol runs. The Realtime SDK on our client needed about 9.4 ms of CPU for each one, and in one of our two SDK runs it fell about two seconds behind within a minute.&lt;/p&gt;

&lt;p&gt;An open subscription let the compute scale to zero under it, and kept working when the next write woke it. The board did too, once its API stopped querying Postgres on every renewal. None of 11 tampered, forged or cross-branch attempts disclosed a victim row, an expired capability was refused, and a copied valid one kept working until it expired, which is how Neon documents it.&lt;/p&gt;

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

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

&lt;p&gt;Realtime is in public beta, and Neon publishes no benchmarks for it yet: its own docs say performance work is still under way. Every number here is a beta number from one project, one region and one client machine, recorded on October 16, 2026, with &lt;code&gt;@neon/realtime&lt;/code&gt; 0.1.0.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Setup is one deploy.&lt;/strong&gt; &lt;code&gt;neon deploy&lt;/code&gt; with &lt;code&gt;preview: { realtime }&lt;/code&gt; in &lt;code&gt;neon.ts&lt;/code&gt; turned it on in about 19 seconds, with no waitlist. The primary's &lt;code&gt;wal_level&lt;/code&gt; became &lt;code&gt;logical&lt;/code&gt;; nothing else changed there that we could see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plain live query is about as fast as the write.&lt;/strong&gt; p50 35 to 43 ms from write issued to update applied, within 4 ms of the write's own acknowledgement, from a client 32 ms away. p99 53 to 85 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All 48,600 writes arrived&lt;/strong&gt; , and every check on the updates passed: rows added, rows removed, result sizes and aggregate totals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most query forms we tried are re-sent whole on every write:&lt;/strong&gt; a join to &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;EXISTS&lt;/code&gt;, &lt;code&gt;IN&lt;/code&gt;, aggregates, &lt;code&gt;ORDER BY&lt;/code&gt; and &lt;code&gt;LIMIT&lt;/code&gt;. That is about 134 bytes per row each time, 27 KB per write at 200 rows. Single-table filters and a membership join pinned to one row stay incremental.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At 100 full results a second, our client was the limit.&lt;/strong&gt; Two complete raw-protocol runs kept up. The SDK on our client needs about 9.4 ms of CPU per 200-row result, and one of two SDK runs fell two seconds behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions end now and then, and a reconnect brings back the current result.&lt;/strong&gt; The server ended a session 11 times in an hour, and the SDK recovered in about 1.2 s each time. After a 75-second network cut, its backoff took 33 to 48 s to reconnect, and the result then held the same rows as the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open subscriptions let the compute scale to zero&lt;/strong&gt; , unless the renew path queries Postgres: a membership lookup on every seal kept ours awake for the whole 300 s we watched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sealed queries held, and the access rule belongs in the query.&lt;/strong&gt; No tampered, forged or cross-branch attempt disclosed a victim row. A removed member's result was empty 8 ms after the removal, against 56 s of leaked orders with a check at seal time only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget the bytes.&lt;/strong&gt; Neon's docs say network transfer is billed as usual, even though the project's meter showed far less than our test received.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Neon project in a Realtime region: &lt;code&gt;aws-us-east-1&lt;/code&gt;, &lt;code&gt;aws-us-east-2&lt;/code&gt;, &lt;code&gt;aws-eu-central-1&lt;/code&gt; or &lt;code&gt;aws-ap-southeast-1&lt;/code&gt; during the beta. There is no waitlist.&lt;/li&gt;
&lt;li&gt;The Neon CLI (&lt;code&gt;npm i -g neon&lt;/code&gt;) and Node.js 22.18 or later (the repo's scripts run as TypeScript with Node's type stripping).&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://github.com/The-DevOps-Daily/neon-realtime-measured" rel="noopener noreferrer"&gt;companion repository&lt;/a&gt;, if you want to rerun anything. Every script writes its raw output to &lt;code&gt;results/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Our earlier posts for context: &lt;a href="https://devops-daily.com/posts/neon-functions-realtime-without-websockets" rel="noopener noreferrer"&gt;Realtime Without a WebSocket Service&lt;/a&gt; and &lt;a href="https://dev.to/devopsdaily/realtime-chat-with-auth-nextjs-neon-auth-and-websockets-2k4n"&gt;Realtime Chat With Auth&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we are replacing
&lt;/h2&gt;

&lt;p&gt;The July layer had three moving parts, all ours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Neon Function holding one server-sent events stream open per browser.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;LISTEN counter_updates&lt;/code&gt; connection per function isolate, on the direct connection string, because the transaction pooler cannot hold a session.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;pg_notify&lt;/code&gt; call after every write, carrying the new value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Its own limits section said where it stopped. &lt;code&gt;NOTIFY&lt;/code&gt; is fire-and-forget, so a browser that is offline at that moment misses the event. Payloads are capped at 8,000 bytes. And the app decides what each event means for the screen: a notify says "something changed", not "here is the new result".&lt;/p&gt;

&lt;p&gt;With Realtime, the subscription is the query the screen shows. There is no function, listener or notify call in the app:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The live part of the app, before and after&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;July: LISTEN/NOTIFY + SSE&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;// Function: one LISTEN connection per isolate, on the unpooled URL&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listener&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;Client&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseUrlUnpooled&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LISTEN counter_updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;listener&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;notification&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;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Every write path has to remember to notify&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT pg_notify($1, $2)&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;counter_updates&lt;/span&gt;&lt;span class="dl"&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;

&lt;span class="c1"&gt;// Browser&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&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;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Now: a sealed live query&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;// API: seal the query the board shows, scoped by the user id from the verified token&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/board/live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;userId&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;requireUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;storeId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;realtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;openOrdersQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Writes are ordinary writes. There is nothing to notify.&lt;/span&gt;

&lt;span class="c1"&gt;// Browser (React)&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useLiveQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sealedQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;refreshQuery&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The browser never sees SQL. The API builds the query with the user id from a verified Neon Auth token, and &lt;code&gt;realtime.seal()&lt;/code&gt; encrypts the SQL, the parameters and the database name into a capability that expires after 60 seconds. The client can subscribe to it but cannot read or change it, and it renews by asking the API again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Realtime is enabled per branch. In &lt;code&gt;neon.ts&lt;/code&gt; it sits next to Neon Auth, which the board uses for sign-in:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neon/config/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Browsers may connect only from these origins. Servers and scripts&lt;/span&gt;
    &lt;span class="c1"&gt;// that send no Origin header are not restricted by this list.&lt;/span&gt;
    &lt;span class="na"&gt;realtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowedOrigins&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;http://localhost:5173&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;One deploy applied both and wrote the variables the app needs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;one deploy enables Neon Auth and Realtime on the branch&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;$ npx neon deploy
→ Applying to branch main (br-frosty-queen-baejcesa)
Applied changes
  + Neon Auth
  + Realtime

Utilized services: Postgres, Neon Auth, Realtime
Pulled 7 Neon variables into .env
  Postgres DATABASE_URL, DATABASE_URL_UNPOOLED
  Branch NEON_BRANCH
  Neon Auth NEON_AUTH_BASE_URL, NEON_AUTH_JWKS_URL
  Realtime NEON_REALTIME_URL, NEON_REALTIME_SECRET

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

&lt;/div&gt;



&lt;p&gt;We read the primary before and after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wal_level&lt;/code&gt; went from &lt;code&gt;replica&lt;/code&gt; to &lt;code&gt;logical&lt;/code&gt;, because Realtime consumes logical replication.&lt;/li&gt;
&lt;li&gt;In three checks (before, after, and while live queries were open), the primary showed no new replication slot, publication or WAL sender. The Neon API still listed a single read-write compute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neon's FAQ says the incremental work "is incurred on a replica". We found no replica we could query, so we could not measure its lag on its own. What we measured is the time from a write to the update arriving, which includes it.&lt;/p&gt;

&lt;p&gt;The CLI is the quickest way to see a live query. Here is a join, subscribed in one terminal while another made three transactions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a join as a live query, from the CLI&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;# terminal 1: subscribe to a join
$ npx neon realtime subscribe "SELECT o.id, o.status, o.total_cents, c.name AS customer FROM orders o JOIN customers c ON c.id = o.customer_id"
Live query
Project purple-term-17791629
Branch main (br-frosty-queen-baejcesa)
Database neondb
Mode Materialized result
Query SELECT o.id, o.status, o.total_cents, c.name AS customer FROM orders o JOIN customers c ON c.id = o.customer_id
[2026-10-16T14:46:41.545Z] Connecting…

[2026-10-16T14:46:43.904Z] Initial snapshot · 0 rows
Query returned no rows.
[2026-10-16T14:46:43.905Z] Live — waiting for changes.
# terminal 2: seed a store, then three transactions three seconds apart
$ node scripts/smoke-write.ts
[2026-10-16T14:46:48.851Z] insert order { id: 1, txid: '1052' }
[2026-10-16T14:46:51.906Z] update order { id: 1, txid: '1053' }
[2026-10-16T14:46:54.947Z] insert + update in one transaction { new_id: 2, txid: '1054' }
# terminal 1: each transaction arrives as a complete new result (Rows), not as a change
[2026-10-16T14:46:48.844Z] Rows · 0 rows
Query returned no rows.

[2026-10-16T14:46:48.862Z] Rows · 1 row
ID Status Total Cents Customer
1 new 1250 Ada

[2026-10-16T14:46:51.913Z] Rows · 1 row
ID Status Total Cents Customer
1 preparing 1250 Ada

[2026-10-16T14:46:54.952Z] Rows · 2 rows
ID Status Total Cents Customer
1 ready 1250 Ada
2 new 830 Grace

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

&lt;/div&gt;



&lt;p&gt;Each transaction came back as a complete new result (&lt;code&gt;Rows&lt;/code&gt;), not as a change. In a second run with &lt;code&gt;--changes&lt;/code&gt;, the same three kinds of transaction against a plain &lt;code&gt;SELECT ... FROM orders WHERE store_id = 1&lt;/code&gt; arrived as small change sets, &lt;code&gt;Transaction 1056 · 1 upsert, 0 removals&lt;/code&gt; and so on. That difference turned out to matter more than anything else in this post.&lt;/p&gt;

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

&lt;p&gt;Everything ran from one client machine against the project in &lt;code&gt;aws-eu-central-1&lt;/code&gt;. &lt;code&gt;SELECT 1&lt;/code&gt; over a direct connection took 31 to 39 ms (p50 per run, median 32 ms), and a WebSocket ping to the Live SQL API 34 to 45 ms (median 41 ms). That round trip is the floor under every latency below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workload.&lt;/strong&gt; Each run creates a fresh store with 200 orders. The writer is open-loop: it issues writes on a fixed schedule (1, 10 or 100 per second) whether or not earlier ones have finished, over a pool of 12 direct connections. When its timer runs late it catches up, and latency counts from the moment each write was actually issued, including any wait for a free connection. Each write is one transaction that inserts an order and deletes the store's oldest one, so the live result stays at 200 rows for the whole run, and the three shapes are compared at the same size. Each write returns its own transaction id from &lt;code&gt;pg_current_xact_id()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three query shapes&lt;/strong&gt; , written with Drizzle and sealed by the SDK's server half:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;plain: &lt;code&gt;orders&lt;/code&gt; filtered by &lt;code&gt;store_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;join: &lt;code&gt;orders&lt;/code&gt; joined to &lt;code&gt;customers&lt;/code&gt; for the customer's name and to &lt;code&gt;store_members&lt;/code&gt; for access&lt;/li&gt;
&lt;li&gt;aggregate: count and revenue per status for the store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Matching updates to writes.&lt;/strong&gt; The subscriber runs the browser client (&lt;code&gt;createRealtimeClient&lt;/code&gt;, with the &lt;code&gt;ws&lt;/code&gt; package as its WebSocket) in a separate process on the same machine, and both processes stamp events from the same clock. An incremental update carries the ids of the transactions it covers, so it matches directly. A full result carries no ids, but the wire protocol opens each new result with the Postgres snapshot it was read at (&lt;code&gt;xmin&lt;/code&gt;, &lt;code&gt;xmax&lt;/code&gt;, &lt;code&gt;xip&lt;/code&gt;), and the scorer applies Postgres's own visibility rule to work out which writes it includes. Latency runs from the moment the writer issued a statement to the moment the SDK handed over the first update that included it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking answers, not only timing.&lt;/strong&gt; Every update is checked against the writes. An incremental batch must add exactly the rows its transactions inserted (by the sequence number each write stamps on its row) and remove one row for each write that deleted one. A full result must hold every stamped row the visible writes leave in the window, and exactly as many rows as the window should have. An aggregate's order counts must add up to the window. These checks do not cover which seed or deleted rows were involved, per-status counts, revenue or other column values. A self-test corrupts copies of recorded runs (a dropped update, a wrong row, a missing row, an aggregate off by one, a stall, a line from another run) and checks that the scorer reports each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separating our own client.&lt;/strong&gt; In our first recording of the matrix, other jobs on the client machine pushed it into swap, and our recorder processes froze for up to 11 seconds at a time. A frozen recorder looks exactly like a slow server in the timestamps. So the recorders now log every gap of more than 60 ms in their own event loops, the scorer sets apart writes whose window overlaps one, and the first recording stays in the repo marked as superseded. Each configuration ran twice, in two rounds with the shape order rotated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write issued to update applied, p50&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 write/s&lt;/th&gt;
&lt;th&gt;10 writes/s&lt;/th&gt;
&lt;th&gt;100 writes/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;41.9ms&lt;/td&gt;
&lt;td&gt;42.8ms&lt;/td&gt;
&lt;td&gt;34.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;56.2ms&lt;/td&gt;
&lt;td&gt;53.6ms&lt;/td&gt;
&lt;td&gt;60.4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aggregate&lt;/td&gt;
&lt;td&gt;47.2ms&lt;/td&gt;
&lt;td&gt;42.4ms&lt;/td&gt;
&lt;td&gt;40.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;write acknowledged&lt;/td&gt;
&lt;td&gt;42.5ms&lt;/td&gt;
&lt;td&gt;38.4ms&lt;/td&gt;
&lt;td&gt;35ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Neon Realtime beta, aws-eu-central-1, client about 32 ms away. Each point pools two runs: 600 writes at 1/s, 3,600 at 10/s, 12,000 at 100/s. The live result is held at 200 rows. Dashed: how long the write itself took to be acknowledged, all shapes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;All 18 runs, 48,600 writes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Writes/s&lt;/th&gt;
&lt;th&gt;Writes&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;max&lt;/th&gt;
&lt;th&gt;After the commit ack, p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;41.9&lt;/td&gt;
&lt;td&gt;44.8&lt;/td&gt;
&lt;td&gt;52.6&lt;/td&gt;
&lt;td&gt;1,423&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3,600&lt;/td&gt;
&lt;td&gt;42.8&lt;/td&gt;
&lt;td&gt;46.9&lt;/td&gt;
&lt;td&gt;58.6&lt;/td&gt;
&lt;td&gt;938&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;12,000&lt;/td&gt;
&lt;td&gt;34.7&lt;/td&gt;
&lt;td&gt;37.5&lt;/td&gt;
&lt;td&gt;85.0&lt;/td&gt;
&lt;td&gt;1,011&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;56.2&lt;/td&gt;
&lt;td&gt;64.9&lt;/td&gt;
&lt;td&gt;90.3&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;12.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3,600&lt;/td&gt;
&lt;td&gt;53.6&lt;/td&gt;
&lt;td&gt;59.8&lt;/td&gt;
&lt;td&gt;94.4&lt;/td&gt;
&lt;td&gt;1,335&lt;/td&gt;
&lt;td&gt;17.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;12,000&lt;/td&gt;
&lt;td&gt;60.4&lt;/td&gt;
&lt;td&gt;244&lt;/td&gt;
&lt;td&gt;2,083&lt;/td&gt;
&lt;td&gt;2,320&lt;/td&gt;
&lt;td&gt;23.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aggregate&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;47.2&lt;/td&gt;
&lt;td&gt;50.7&lt;/td&gt;
&lt;td&gt;63.0&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;td&gt;3.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aggregate&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3,600&lt;/td&gt;
&lt;td&gt;42.4&lt;/td&gt;
&lt;td&gt;44.9&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;td&gt;1,220&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aggregate&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;12,000&lt;/td&gt;
&lt;td&gt;40.7&lt;/td&gt;
&lt;td&gt;47.6&lt;/td&gt;
&lt;td&gt;648&lt;/td&gt;
&lt;td&gt;1,374&lt;/td&gt;
&lt;td&gt;7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All times are in milliseconds, from the write being issued to the update that included it. The last column is the same, measured from the moment the writer got its commit acknowledged; the update usually arrived within a few milliseconds of the writer hearing back, and sometimes before.&lt;/p&gt;

&lt;p&gt;Three things in that table need explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plain query is about as fast as the network allows.&lt;/strong&gt; Its p50 is within 4 ms of the write's own acknowledgement at every rate. Both the writes and the updates got faster as the rate went up, by about 7 ms between 1 and 100 writes per second. The likely reason is our client, not Neon: its Wi-Fi saves power when traffic is light (more on that at the end).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The join costs 11 to 26 ms more than the plain query at p50,&lt;/strong&gt; and the reason is not the join itself. It is that every write re-sends the whole result, which the next section is about. At 100 writes per second its tail is our client falling behind, also below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The maximums of about one second at most rates are the server ending the session.&lt;/strong&gt; Eleven times in 59 minutes of subscriptions, a subscription received &lt;code&gt;connection_error: backend_session_lost&lt;/code&gt; ("live query session ended; reconnect required"). Every time, the SDK reconnected and was live again 0.95 to 1.51 s later (median 1.2 s), with a complete fresh result. The scorer tied 233 writes slower than 300 ms to these episodes, 66 of them at 1 or 10 writes per second, and they account for every maximum above 900 ms at those rates. All eleven came at the same second past the minute (:42 or :43), as if a task on a 60-second cycle sometimes ends sessions. Our first, superseded recording had nine more. We cannot see the cause from the client.&lt;/p&gt;

&lt;p&gt;At 100 writes per second, our own client stalled during part of the plain and aggregate runs (107 and 433 writes overlap a stall); without those writes, the p99 is 46 ms for the plain query and 92 ms for the aggregate. Treat those two as diagnostics, not corrected Neon numbers: a slow write has more chances to overlap a stall, and at high rates the SDK's own work causes some of the stalls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most queries are re-sent whole on every write
&lt;/h2&gt;

&lt;p&gt;Neon's CLI docs say "some queries, such as ones with &lt;code&gt;ORDER BY&lt;/code&gt;, can get a complete replacement for a change". In our tests, most query forms got one on every write. We subscribed to several forms of the same question at once, on a store with 300 orders, and made three single-row inserts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query form (300 orders in the store)&lt;/th&gt;
&lt;th&gt;Rows in result&lt;/th&gt;
&lt;th&gt;On each insert&lt;/th&gt;
&lt;th&gt;Bytes per write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WHERE store_id = $1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;incremental change&lt;/td&gt;
&lt;td&gt;479&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;... ORDER BY id DESC LIMIT 20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;2,947&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JOIN customers&lt;/code&gt; for the name&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;37,346&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JOIN store_members&lt;/code&gt;, pinned to one user and store&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;incremental change&lt;/td&gt;
&lt;td&gt;472&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXISTS (SELECT 1 FROM store_members ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;33,245&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;store_id IN (SELECT ... FROM store_members ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;33,245&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count(*) ... GROUP BY status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;977&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count(*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;full result again&lt;/td&gt;
&lt;td&gt;760&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of these forms, only two stayed incremental: a single-table filter, and a join to &lt;code&gt;store_members&lt;/code&gt; whose &lt;code&gt;WHERE&lt;/code&gt; clause pins it to one row. Everything else, including &lt;code&gt;EXISTS&lt;/code&gt; and &lt;code&gt;IN&lt;/code&gt;, which are the obvious ways to write a membership check, was re-sent in full: 110 to 125 bytes per row on every write, whether the write touched one row or all of them.&lt;/p&gt;

&lt;p&gt;A second set, against the board's own queries and three kinds of write:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query form&lt;/th&gt;
&lt;th&gt;Order insert&lt;/th&gt;
&lt;th&gt;Order marked done&lt;/th&gt;
&lt;th&gt;Customer insert&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Board: open orders (&lt;code&gt;JOIN store_members&lt;/code&gt;, &lt;code&gt;status &amp;lt;&amp;gt; 'done'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;change, 396 B&lt;/td&gt;
&lt;td&gt;change, 354 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Board: customers (&lt;code&gt;JOIN store_members&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;td&gt;change, 389 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Board: counts per status (&lt;code&gt;GROUP BY&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;full result, 885 B&lt;/td&gt;
&lt;td&gt;full result, 986 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JOIN customers&lt;/code&gt;, both tables filtered to the store&lt;/td&gt;
&lt;td&gt;full result, 37,180 B&lt;/td&gt;
&lt;td&gt;full result, 37,175 B&lt;/td&gt;
&lt;td&gt;full result, 37,175 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ORDER BY id&lt;/code&gt;, no &lt;code&gt;LIMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;full result, 33,104 B&lt;/td&gt;
&lt;td&gt;full result, 33,099 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LIMIT 50&lt;/code&gt;, no &lt;code&gt;ORDER BY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;full result, 5,978 B&lt;/td&gt;
&lt;td&gt;full result, 5,972 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT DISTINCT status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full result, 868 B&lt;/td&gt;
&lt;td&gt;full result, 965 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WHERE store_id = $1&lt;/code&gt; (control)&lt;/td&gt;
&lt;td&gt;change, 387 B&lt;/td&gt;
&lt;td&gt;change, 388 B&lt;/td&gt;
&lt;td&gt;nothing sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two useful details are in there. A write to a table a query does not read sends that subscriber nothing at all. And sorting alone is enough to lose incremental updates: &lt;code&gt;ORDER BY id&lt;/code&gt; with no &lt;code&gt;LIMIT&lt;/code&gt; re-sent all 300 rows on every write. Sort in the client instead.&lt;/p&gt;

&lt;p&gt;The cost of a full result grows with the result. The join, at 1 write per second for 60 seconds, at four result sizes, with a 5,000-row plain query as the control:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Live query&lt;/th&gt;
&lt;th&gt;Rows in result&lt;/th&gt;
&lt;th&gt;Bytes per write&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1,980&lt;/td&gt;
&lt;td&gt;48.1 ms&lt;/td&gt;
&lt;td&gt;52.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;13,953&lt;/td&gt;
&lt;td&gt;52.9 ms&lt;/td&gt;
&lt;td&gt;62.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;134,225&lt;/td&gt;
&lt;td&gt;99.2 ms&lt;/td&gt;
&lt;td&gt;111.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;668,496&lt;/td&gt;
&lt;td&gt;225.1 ms&lt;/td&gt;
&lt;td&gt;273.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;572&lt;/td&gt;
&lt;td&gt;42.6 ms&lt;/td&gt;
&lt;td&gt;45.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is linear: about 134 bytes per row, re-sent on every write that changes anything in the result. Neon's docs say egress from Realtime is billed as usual (Free includes 5 GB per project per month; Launch and Scale include 500 GB, then $0.10 per GB). One browser tab watching that 1,000-row join while the table takes a steady 10 writes per second receives about 4.8 GB (decimal) an hour. Twenty tabs for an 8-hour day, 22 working days a month, is about 17 TB, or roughly $1,650 a month past the Launch allowance, if nothing else in the project uses that allowance. The plain query in the same setting comes to about 73 GB.&lt;/p&gt;

&lt;p&gt;One thing we cannot square with that. When we read Neon's consumption API at 19:26 UTC, it showed 16.2 MB of public network transfer for the project for 15:00 to 18:00 UTC, three hours in which our bench recorders alone received at least 862 MB of Realtime messages. We did not find out why. Realtime is free during the beta, but the docs say the waiver does not cover network transfer, so plan on paying for the bytes above rather than going by the meter's current reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  At 100 writes per second, the client falls behind first
&lt;/h2&gt;

&lt;p&gt;A full result has to be decoded and applied by the client. For the 200-row join at 100 writes per second, that is 100 full results a second, about 2.7 MB/s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 200-row join at 100 writes/s, p50 per 5 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;0s&lt;/th&gt;
&lt;th&gt;5s&lt;/th&gt;
&lt;th&gt;10s&lt;/th&gt;
&lt;th&gt;15s&lt;/th&gt;
&lt;th&gt;20s&lt;/th&gt;
&lt;th&gt;25s&lt;/th&gt;
&lt;th&gt;30s&lt;/th&gt;
&lt;th&gt;35s&lt;/th&gt;
&lt;th&gt;40s&lt;/th&gt;
&lt;th&gt;45s&lt;/th&gt;
&lt;th&gt;50s&lt;/th&gt;
&lt;th&gt;55s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SDK client, round A&lt;/td&gt;
&lt;td&gt;51.6ms&lt;/td&gt;
&lt;td&gt;50.4ms&lt;/td&gt;
&lt;td&gt;51.7ms&lt;/td&gt;
&lt;td&gt;59.2ms&lt;/td&gt;
&lt;td&gt;60.5ms&lt;/td&gt;
&lt;td&gt;60ms&lt;/td&gt;
&lt;td&gt;61.1ms&lt;/td&gt;
&lt;td&gt;60.8ms&lt;/td&gt;
&lt;td&gt;62.3ms&lt;/td&gt;
&lt;td&gt;70.7ms&lt;/td&gt;
&lt;td&gt;745.8ms&lt;/td&gt;
&lt;td&gt;1787.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDK client, round B&lt;/td&gt;
&lt;td&gt;49.8ms&lt;/td&gt;
&lt;td&gt;50.2ms&lt;/td&gt;
&lt;td&gt;51.8ms&lt;/td&gt;
&lt;td&gt;59.1ms&lt;/td&gt;
&lt;td&gt;59.1ms&lt;/td&gt;
&lt;td&gt;59.3ms&lt;/td&gt;
&lt;td&gt;60ms&lt;/td&gt;
&lt;td&gt;53.4ms&lt;/td&gt;
&lt;td&gt;61.3ms&lt;/td&gt;
&lt;td&gt;61.4ms&lt;/td&gt;
&lt;td&gt;64.4ms&lt;/td&gt;
&lt;td&gt;64.1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw recorder, run D1&lt;/td&gt;
&lt;td&gt;40.8ms&lt;/td&gt;
&lt;td&gt;40.2ms&lt;/td&gt;
&lt;td&gt;40.2ms&lt;/td&gt;
&lt;td&gt;48.9ms&lt;/td&gt;
&lt;td&gt;47.8ms&lt;/td&gt;
&lt;td&gt;48.4ms&lt;/td&gt;
&lt;td&gt;50.7ms&lt;/td&gt;
&lt;td&gt;48.8ms&lt;/td&gt;
&lt;td&gt;48.4ms&lt;/td&gt;
&lt;td&gt;48.7ms&lt;/td&gt;
&lt;td&gt;48.9ms&lt;/td&gt;
&lt;td&gt;48.7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw recorder, round B&lt;/td&gt;
&lt;td&gt;45.6ms&lt;/td&gt;
&lt;td&gt;45.3ms&lt;/td&gt;
&lt;td&gt;45.8ms&lt;/td&gt;
&lt;td&gt;53.4ms&lt;/td&gt;
&lt;td&gt;53.4ms&lt;/td&gt;
&lt;td&gt;53.2ms&lt;/td&gt;
&lt;td&gt;52.2ms&lt;/td&gt;
&lt;td&gt;53.7ms&lt;/td&gt;
&lt;td&gt;48.7ms&lt;/td&gt;
&lt;td&gt;52.5ms&lt;/td&gt;
&lt;td&gt;52ms&lt;/td&gt;
&lt;td&gt;53.3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Every write re-sends the whole 200-row result (about 27 KB). The raw recorder speaks the wire protocol without decoding rows; the SDK client decodes and applies each full result. Same project, same writer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To find out which side was falling behind, we wrote a second recorder that speaks the wire protocol directly and never decodes the rows. With it, at the same rate, the join held a p50 of 44 to 49 ms and a p99 of 73 to 114 ms over two complete runs of 6,000 writes, with no backlog. A third raw run started badly: 220 writes in its first five seconds were delayed by up to 957 ms, with no recorded client stall overlapping those delays, and a session loss then cut it off 16 seconds in, because the raw recorder does not reconnect. The two complete runs kept up; the third had update-path delays we cannot explain before the server ended its session.&lt;/p&gt;

&lt;p&gt;The SDK client is the slow part, on our machine. In a separate benchmark at 20 updates a second, with a materialized subscription, it used 2.1 ms of CPU per update for the plain query, 2.0 ms for the aggregate and 9.4 ms for each 200-row join result. If that cost held at 100 a second, it would take about 94% of one core. In one of our two SDK runs at that rate it fell behind and never caught up (p50 1.8 s in the last five seconds); in the other it held on, with stalls of up to 470 ms. We did not measure other hardware. Either way, the work grows with result size times write rate, on every subscriber.&lt;/p&gt;

&lt;p&gt;All four runs in the chart, raw and SDK alike, stepped up by 7 to 9 ms about 15 seconds in and stayed there. We did not find out why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a reconnect delivers
&lt;/h2&gt;

&lt;p&gt;Our July layer had no answer for a dropped connection: whatever &lt;code&gt;NOTIFY&lt;/code&gt; fired while a browser was away was gone. Realtime's answer is a fresh copy of the result. We watched two live queries (the plain one and the join) while a writer added an order every 500 ms, then cut the network twice: every socket destroyed, and new connections sent to an address that never answers. The second cut lasted 75 seconds, longer than a sealed query lives, and renewal failed during it, as it would for a browser that is offline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cutting the network for 5 s, then for 75 s&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;# two live queries, plain and join, on one store; a writer adds an order every 500 ms
$ node scripts/reconnect.ts
   0.57s plain state live 
   0.92s join state live 
   0.92s setup both live: plain 100 rows, join 100 rows 
   8.92s net short: cut for 5 s 
   8.94s join state connecting 
   8.94s plain state connecting 
  13.93s net short: restored; 10 orders were written during the cut 
  16.72s plain state live 
  16.77s join state live 
  20.60s plain short +2 s: 134 rows, database 134, missing 0, extra 0 {"status":"live"}
  20.65s join short +2 s: 134 rows, database 134, missing 0, extra 0 {"status":"live"}
  28.65s net long: cut for 75 s 
  28.65s join state connecting 
  28.65s plain state connecting 
  49.85s join renew failed: offline 
# 107 more renewal failures follow, one a second for each subscription, until the network returns
 103.65s net long: restored; 140 orders were written during the cut 
 136.93s plain renewed 
 137.02s plain state live 
 151.87s join renewed 
 151.97s join state live 
 153.92s plain renewed 
 153.94s join renewed 
 156.06s plain long +2 s: 382 rows, database 382, missing 0, extra 0 {"status":"live"}
 156.14s join long +2 s: 382 rows, database 382, missing 0, extra 0 {"status":"live"}
 159.73s plain final: 382 rows, database 382, missing 0, extra 0 {"status":"live"}
 159.78s join final: 382 rows, database 382, missing 0, extra 0 {"status":"live"}
 159.79s plain state closed 
 159.79s join state closed 

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

&lt;/div&gt;



&lt;p&gt;After both cuts, the first thing to arrive was a complete result (130 rows, then 351 and 378), not a replay of the changes that were missed, and both live results held exactly the same rows as the database. That is what you want for a screen. It is not a durable event log: if your app needs to see every intermediate state, Realtime will not give it to you after a gap, and neither did &lt;code&gt;NOTIFY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The wait after the long cut is the part to plan for. The SDK reconnects with jittered exponential backoff (a 1-second base, capped at 60 seconds), and version 0.1.0 does not listen for the browser's &lt;code&gt;online&lt;/code&gt; event (we checked the source). So after a long outage the network can be back for half a minute or more before the board is. Show the subscription's &lt;code&gt;status&lt;/code&gt; in the UI rather than assuming it is live. The short cut cost 2.8 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sealed queries: what we could and could not forge
&lt;/h2&gt;

&lt;p&gt;A sealed query is an AES-256-GCM encrypted capability (a compact JWE) that holds the SQL, its parameters and the database name, and expires after 60 seconds. The browser holds it but cannot read it, and the Live SQL API checks it without calling your server. We tried to get rows we were not sealed for. The attacker holds a valid sealed query for their own store, as any signed-in user of the board would, and wants another store's orders. The script speaks the wire protocol directly, because an attacker is not bound to use the SDK. Each attempt has one expected outcome, and anything else (another error or a timeout) counts as a failure, as does a single row from the victim's store, except in the documented stolen-query case. The control, the stolen-query case and the renewal attack each include a write during the attempt, so incremental changes are checked for leaks as well as first results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control: the attacker's own sealed query, then an order in their store&lt;/td&gt;
&lt;td&gt;delivered: the attacker's 5 rows, then the new order as a change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One character changed in the ciphertext, the tag or the IV&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header and IV from one capability, ciphertext and tag from another&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciphertext truncated&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The victim's query sealed with a guessed secret in the right format&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header rewritten to &lt;code&gt;alg: none&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;sql&lt;/code&gt; field added to the subscribe message&lt;/td&gt;
&lt;td&gt;&lt;code&gt;connection_error: invalid_payload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renewing a live subscription with a forged capability for the victim's query, then an order in the victim's store&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;renewal_failed&lt;/code&gt;, and nothing from the victim's store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A query sealed with a child branch's secret, sent to the parent branch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A query sealed with the parent's secret, sent to the child branch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The attacker's own sealed query, 65 seconds after sealing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorization_expired&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The victim's real sealed query, copied, then an order in the victim's store&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;delivered: all 30 victim rows, then the new order&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 14 outcomes were as expected, in a first run and again in this stricter one after review. No tampered, forged or cross-branch attempt disclosed a victim row or change, and the expired capability was refused. A child branch got its own Realtime URL and its own secret, so a preview branch's capabilities do not open production. To check that the harness could fail, a self-test swaps one attack's payload for the victim's real sealed query; the checks then report that attack as a leak.&lt;/p&gt;

&lt;p&gt;The last row is the documented catch: &lt;strong&gt;a sealed query is a bearer token.&lt;/strong&gt; Anyone who holds one can subscribe until it expires. Expiry closes that window within 60 seconds of sealing; it does not take back rows already received. Keep sealed queries out of URLs, logs and persistent browser storage, as Neon's docs say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping per user without row-level security
&lt;/h2&gt;

&lt;p&gt;Realtime has no row-level security yet. Neon's guidance is to authorize in the request handler and put the authorization inside the query: take the user id from a verified token, and join through the membership table so the query itself only matches rows that user may see. We tested that against two weaker designs, through the board's API, with real Neon Auth users. These runs used a version of the API that also looked the user up in &lt;code&gt;store_members&lt;/code&gt; before sealing, which is why renewals were refused after a removal; the board in the repo now skips that lookup (see the next two sections) and seals a query whose membership join simply returns nothing for a non-member:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Design&lt;/th&gt;
&lt;th&gt;After the user lost access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Membership join inside the live query (the board)&lt;/td&gt;
&lt;td&gt;Their result was empty &lt;strong&gt;8 ms&lt;/strong&gt; after the removal was acknowledged (one test, checked every 5 ms). 0 of 6 orders added afterwards reached them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Membership checked when sealing, but the query only filters by &lt;code&gt;store_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;12 of 18&lt;/strong&gt; new orders still reached them. The last one was added 56 s after the removal. It stopped when the sealed query expired and the API refused to renew it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A member's sealed query copied to a non-member&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;12 of 18&lt;/strong&gt; new orders reached the non-member. The last one was added 56 s after sealing. The API refused the non-member's renewal.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the pattern works, and the join is what makes it work. A check at seal time alone leaves a window of up to one sealed-query lifetime, and only because the renewal goes back through your API, which refuses it. Put the access rule in the query, with the user id sealed in from a verified token, and a removal reaches the live result with the next update, without waiting for the sealed query to expire.&lt;/p&gt;

&lt;p&gt;One more consequence of the full-result rule: the obvious ways to write that access check, &lt;code&gt;EXISTS&lt;/code&gt; and &lt;code&gt;IN (SELECT ...)&lt;/code&gt;, are re-sent whole on every write. The join to &lt;code&gt;store_members&lt;/code&gt;, pinned to one row by the &lt;code&gt;WHERE&lt;/code&gt; clause, is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The board
&lt;/h2&gt;

&lt;p&gt;The live order board in the repo is a Vite and React app with a Hono API, Drizzle for the schema and the queries, and Neon Auth for sign-in. Its first version used the three-table join from the measurements, which re-sends every order on every write. The version in the repo subscribes to three queries instead, each scoped by a join to &lt;code&gt;store_members&lt;/code&gt; on the user id from the verified token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Open orders: incremental. The customer name is not joined in here.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openOrdersQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Db&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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;orders&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orders&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="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;innerJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&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="c1"&gt;// Customers: incremental, and an order write sends this subscription nothing.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;storeCustomersQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Db&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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;customers&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customers&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;innerJoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storeMembers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The header: an aggregate, re-sent whole on every write, which is fine at four rows.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;statusCountsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Db&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;storeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/* GROUP BY status */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The browser joins the first two with a &lt;code&gt;Map&lt;/code&gt; from customer id to name, and sorts on its own side, because &lt;code&gt;ORDER BY&lt;/code&gt; in the live query would re-send everything. Status changes are optimistic: the card moves at once with React's &lt;code&gt;useOptimistic&lt;/code&gt;, the API's &lt;code&gt;UPDATE ... RETURNING pg_current_xact_id()&lt;/code&gt; returns the transaction id, and &lt;code&gt;awaitTxId()&lt;/code&gt; on the live query holds the optimistic state until that transaction arrives.&lt;/p&gt;

&lt;p&gt;The API's seal endpoint verifies the Neon Auth token and seals, and that is all: sealing is local crypto, so a renewal never touches Postgres. It does not look the user up in &lt;code&gt;store_members&lt;/code&gt; first. That lookup would only turn a non-member's empty board into a 403, because the join inside each query is what keeps them out, and it would cost a database query on every renewal of every subscription. The next section shows what that does to scale to zero.&lt;/p&gt;

&lt;p&gt;A scripted run through the same API and SDK calls the app makes (&lt;code&gt;scripts/board-check.ts&lt;/code&gt;) passed every check. A new order appeared on the board 99 ms after the POST was sent, moving it to &lt;code&gt;preparing&lt;/code&gt; showed up in 70 ms, and marking it &lt;code&gt;done&lt;/code&gt; took it off the board in 54 ms, each measured to the moment &lt;code&gt;awaitTxId&lt;/code&gt; resolved. A user from another store got an empty board from the same endpoint, and a 403 when adding an order to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale to zero: open subscriptions let the compute sleep
&lt;/h2&gt;

&lt;p&gt;Neon's docs say holding a subscription open does not keep the database awake, but starting one can wake it. We checked both, with the suspend timeout shortened to 60 seconds through the Neon API and the compute's state polled every 5 seconds:&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;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control: nothing open&lt;/td&gt;
&lt;td&gt;The compute suspended 65 s after we started watching.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A live query started while the compute was suspended&lt;/td&gt;
&lt;td&gt;First result in 0.98 s. The compute was active again 0.3 s later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;That subscription held open, renewed every 50 s, no writes&lt;/td&gt;
&lt;td&gt;The compute suspended 75 s later, and was still suspended 60 s after that. The subscription stayed &lt;code&gt;live&lt;/code&gt; the whole time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A write while suspended, subscription still open&lt;/td&gt;
&lt;td&gt;Connecting to the waking compute took 853 ms and the insert 247 ms (1.14 s in all, closing the connection included). The open subscription received the change 1.12 s after the write was issued, with no reconnect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A second live query started from a suspended compute&lt;/td&gt;
&lt;td&gt;First result in 1.84 s, and it woke the compute.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So an open subscription costs no compute while nothing writes, and it survives the compute going to sleep under it. The cost is on the first write or the first new subscriber after a quiet spell: about a second, the same cold start any query pays. We could not see the replica that Neon says does the incremental work, so we cannot say whether it suspends too; Neon does not charge for it during the beta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The board itself is another matter, because its renewals go through your API.&lt;/strong&gt; We ran alice's three board queries with the browser's renewal timing (10 seconds before each 60-second expiry), sealed and renewed through the board's API, with the suspend timeout at 60 seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Board API&lt;/th&gt;
&lt;th&gt;Compute while the board sat open with no writes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Looks the user up in &lt;code&gt;store_members&lt;/code&gt; on every seal&lt;/td&gt;
&lt;td&gt;Never suspended in 300 s. 18 renewals, each one a database query.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seals without touching Postgres (the version in the repo)&lt;/td&gt;
&lt;td&gt;Suspended 75 s after the board opened.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The membership lookup looks harmless, and it cost scale to zero entirely: with it, each open tab made three database queries roughly every 50 seconds while its renewals succeeded, one every 17 seconds on average, and that kept the compute awake for the whole 300 seconds we watched. Keep the renew path free of database calls. Signing in, and refreshing the Neon Auth token (it lasts about 15 minutes), go through Neon Auth, which keeps its tables in the same database; we did not measure whether a token refresh wakes the compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writes on the primary, with and without 100 live queries
&lt;/h2&gt;

&lt;p&gt;Neon's FAQ says Realtime has "zero impact on the write-latency, write-throughput or write-compute of your primary database". We ran a writer at 20 writes per second for the whole test and switched 100 live queries on and off in alternating 60-second blocks: off, on, off, on, off, on. Each query belonged to a different user and every write touched all 100 of them: nominally 2,000 updates a second (20 writes times 100 queries) to the subscriber process. Writes during each switch and for 20 seconds after it were left out.&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;100 live queries off&lt;/th&gt;
&lt;th&gt;100 live queries on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write execution time on the primary (&lt;code&gt;pg_stat_statements&lt;/code&gt;, mean per block)&lt;/td&gt;
&lt;td&gt;0.157 to 0.164 ms&lt;/td&gt;
&lt;td&gt;0.150 to 0.166 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write latency on the client, p50 per block&lt;/td&gt;
&lt;td&gt;34.3 to 39.4 ms&lt;/td&gt;
&lt;td&gt;32.3 to 33.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write latency on the client, p99 (all blocks)&lt;/td&gt;
&lt;td&gt;47.6 ms&lt;/td&gt;
&lt;td&gt;42.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;td&gt;3,603&lt;/td&gt;
&lt;td&gt;3,603&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the server, the write's own execution time did not move. On the client, the pooled median write was about 2.5 ms faster with the live queries on, and each on block had a lower median than the off block before it, which is the opposite of a slowdown. We think that is our client and not Neon: it is on Wi-Fi with power saving on, and the extra incoming traffic keeps the radio awake. We did not test that explanation. A first version of this test let the CPU clock follow the load, tagged some switching time to the wrong block, and lacked an index that keeps the write's &lt;code&gt;DELETE&lt;/code&gt; cheap (its execution time jumped from 0.3 to 3.2 ms partway through). It showed the same direction and is kept in the repo as superseded. Both versions ran with Realtime enabled throughout, so neither measures what turning Realtime on (&lt;code&gt;wal_level = logical&lt;/code&gt;) costs.&lt;/p&gt;

&lt;p&gt;So under this workload we saw no slowdown of writes on the primary with 100 subscriptions open, which matches Neon's claim. &lt;code&gt;pg_stat_statements&lt;/code&gt; does not include commit time, though, and our client's own noise is larger than a millisecond, so "no slowdown we could measure" is as far as this goes.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The replica's lag on its own.&lt;/strong&gt; Neon says the incremental work runs on a replica. We could not see one in the project, the Neon API or the primary's replication views, so every number here is end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the server ends sessions.&lt;/strong&gt; Twenty &lt;code&gt;backend_session_lost&lt;/code&gt; errors across both recordings, most of them at the same second past the minute, as if on a 60-second cycle. The SDK handled every one, but we cannot see the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the four join runs at 100 writes per second in the chart got 7 to 9 ms slower about 15 seconds in,&lt;/strong&gt; the raw recorder's included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the SDK costs on other hardware.&lt;/strong&gt; We measured it on one client, a Raspberry Pi on Wi-Fi with power saving on, and nowhere else. The Wi-Fi also shaves a few milliseconds off everything when traffic is heavy, which is the likely reason most numbers improved at higher write rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More than 100 subscribers, or more than one client.&lt;/strong&gt; Neon says thundering-herd protection is built in; we did not test a reconnect storm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bill.&lt;/strong&gt; Realtime is free during the beta and its network transfer is documented as billed, but the project's transfer meter showed far less than our Realtime traffic, so we cannot tell you what this test would have cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether any of this survives the beta.&lt;/strong&gt; Neon calls the protocol unstable and the performance work unfinished. The full-result behaviour in particular may change; rerun &lt;code&gt;scripts/explore-incremental.ts&lt;/code&gt; before you design around it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;For a live order board, Neon Realtime replaces everything our July layer did and covers what it could not. There is no function holding connections, no &lt;code&gt;LISTEN&lt;/code&gt; session, and no &lt;code&gt;NOTIFY&lt;/code&gt; call to forget on a write path. A reconnect brings back the right result instead of losing events, and the board shows a query result rather than an event the app has to interpret. A plain live query arrived as fast as the write's own acknowledgement, and none of the 48,600 writes went missing.&lt;/p&gt;

&lt;p&gt;The part to design for is the full re-send. Write the live queries as single-table filters, or join only to a membership table pinned to one row. Do the other joins and all sorting in the client, and keep live aggregates to a handful of groups. Check each query with the CLI's &lt;code&gt;--changes&lt;/code&gt; flag: a &lt;code&gt;Rows&lt;/code&gt; line on every write means the whole result is coming back each time. Put the access rule inside the query, and budget for the bytes a full result costs on every write, times every subscriber.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/The-DevOps-Daily/neon-realtime-measured" rel="noopener noreferrer"&gt;repo&lt;/a&gt; has the board, every script, and the raw output of every run, including the ones we superseded.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/neon-realtime-live-queries-measured" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>neon</category>
      <category>postgres</category>
      <category>realtime</category>
      <category>livequeries</category>
    </item>
    <item>
      <title>Prompt Caching on DigitalOcean Inference, Measured: What Breaks It and How Long It Lasts</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/prompt-caching-on-digitalocean-inference-measured-what-breaks-it-and-how-long-it-lasts-8g9</link>
      <guid>https://dev.to/devopsdaily/prompt-caching-on-digitalocean-inference-measured-what-breaks-it-and-how-long-it-lasts-8g9</guid>
      <description>&lt;p&gt;Most LLM features send the same long block of text on every request: a system prompt, a policy, a runbook, a list of tools. DigitalOcean Serverless Inference caches that repeated prefix automatically on the models it hosts, bills the cached part at a discount, and starts streaming sooner. You do not turn it on. You can turn it off by accident, and the request itself does not tell you.&lt;/p&gt;

&lt;p&gt;We sent 867 requests to Llama 4 Maverick, DeepSeek V4.1 Flash and &lt;code&gt;gpt-oss-120b&lt;/code&gt;, with an on-call runbook of up to about 67,000 tokens as the system prompt. With an 8,000-token runbook first and unchanged, the cache served &lt;strong&gt;99.7 percent&lt;/strong&gt; of the prompt on Llama 4 Maverick after the first request and cut the input cost about &lt;strong&gt;5 times&lt;/strong&gt;. On DeepSeek V4.1 Flash it cut it more than &lt;strong&gt;12 times&lt;/strong&gt;. Then we put the current time on the first line of the system prompt, the way a lot of prompt templates do, and &lt;strong&gt;every request after the first missed&lt;/strong&gt; , on all three models.&lt;/p&gt;

&lt;p&gt;This post covers what we measured: which prompt layouts keep the cache and which break it, what a hit does to time to first token as the prompt grows, how long a cached prefix lasted on each model, what &lt;code&gt;prompt_cache_key&lt;/code&gt; changes, and why &lt;code&gt;gpt-oss-120b&lt;/code&gt; was the hardest to keep warm. Every token and cache number comes from the usage block the API returned, timings come from our client, and the harness and raw results are public.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/do-inference-prompt-cache" rel="noopener noreferrer"&gt;The-DevOps-Daily/do-inference-prompt-cache on GitHub&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;With a stable 8k-token prefix, input cost per 1,000 requests went from $1.92 to $0.39 on Llama 4 Maverick and from $2.40 to $0.19 on DeepSeek V4.1 Flash. &lt;code&gt;gpt-oss-120b&lt;/code&gt; went from $0.78 to $0.35 in one run and to $0.55 in another; the share of its prompt served from cache swung between 35 and 68 percent.&lt;/li&gt;
&lt;li&gt;A timestamp on the first line of the system prompt: all 57 requests after the first missed, across the three models. The same timestamp after the runbook: back to 99.5, 93.7 and 63.2 percent of tokens from cache. A user's name a quarter of the way in: only the part before it, 27 to 28 percent of the prompt, was cached.&lt;/li&gt;
&lt;li&gt;At 64k tokens, a cache hit cut the time to the first streamed token from 5.0 to 1.4 seconds on Llama 4 Maverick and from 3.1 to 0.9 seconds on DeepSeek V4.1 Flash. &lt;code&gt;gpt-oss-120b&lt;/code&gt; never hit at 64k in 5 tries.&lt;/li&gt;
&lt;li&gt;How long a prefix stayed usable depended on the model. Llama 4 Maverick hit after a nominal 5 idle minutes and missed after 15. DeepSeek V4.1 Flash missed every prefix it had seen only once, and hit every prefix it had seen twice, up to 30 minutes later. &lt;code&gt;gpt-oss-120b&lt;/code&gt; missed all 9 probes after a minute or more.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prompt_cache_key&lt;/code&gt; kept caches apart: a second key on the same prefix missed 12 of 12 times. It did not change hit rates in our runs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A DigitalOcean account with a Serverless Inference &lt;a href="https://docs.digitalocean.com/products/inference/how-to/manage-model-access-keys/" rel="noopener noreferrer"&gt;model access key&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Node.js 22 or later (the harness has no dependencies)&lt;/li&gt;
&lt;li&gt;Optional: an API token with billing read access, for the invoice check in the repo&lt;/li&gt;
&lt;li&gt;About $2 of inference credit to rerun everything; our recorded requests cost about $1.06 at list prices&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the cache works on DigitalOcean-hosted models
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.digitalocean.com/products/inference/how-to/use-prompt-caching/" rel="noopener noreferrer"&gt;prompt caching guide&lt;/a&gt; covers three families. Anthropic and OpenAI models have their own controls (&lt;code&gt;cache_control&lt;/code&gt;, &lt;code&gt;prompt_cache_retention&lt;/code&gt;). The open-weight models DigitalOcean hosts itself, which is what we tested, cache with no code change at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The cache matches an exact token prefix, in blocks of up to 256 tokens depending on the model.&lt;/li&gt;
&lt;li&gt;Writing to the cache is free. Cached input tokens are billed at the model's cache read rate.&lt;/li&gt;
&lt;li&gt;Entries have no fixed expiry. They are evicted least-recently-used first, "and sooner under heavy load".&lt;/li&gt;
&lt;li&gt;The cache lives on the replica that served the earlier request. The platform routes matching prefixes back to that replica, but under very high load it may send a request elsewhere, which is a miss.&lt;/li&gt;
&lt;li&gt;Every response reports the cached part in &lt;code&gt;usage.prompt_tokens_details.cached_tokens&lt;/code&gt; (and &lt;code&gt;cache_read_input_tokens&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The discount differs a lot between models. These are the rates for the three we tested, from the &lt;a href="https://docs.digitalocean.com/products/inference/details/pricing/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; (last verified 15 October 2026) and our own invoice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input, per 1M tokens&lt;/th&gt;
&lt;th&gt;Cached input, per 1M&lt;/th&gt;
&lt;th&gt;Discount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;td&gt;$0.25&lt;/td&gt;
&lt;td&gt;$0.05&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;td&gt;$0.30&lt;/td&gt;
&lt;td&gt;$0.006&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-oss-120b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pricing page lists no cache read rate for &lt;code&gt;gpt-oss-120b&lt;/code&gt;; the $0.02 is the rate on the &lt;code&gt;Cache Read Input&lt;/code&gt; line of our invoice preview. The caching guide says cached tokens are "discounted by up to 90%", but the pricing page and our invoice both show DeepSeek V4.1 Flash at 98 percent off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The prompt is an on-call runbook for a made-up platform: 64 services, each with an owner, an SLO, its alerts and numbered first steps with real-looking commands (&lt;code&gt;deployctl rollback checkout-api&lt;/code&gt;, &lt;code&gt;featurectl set checkout-api.degraded true&lt;/code&gt;). A seeded script cuts it at section boundaries to nominal sizes of 1k, 4k, 8k, 16k and 64k tokens, at about four characters per token. Each model's tokenizer counts the same text a little differently: the 8k runbook is 7,700 to 8,000 tokens, and the 64k one 64,000 to 67,000. The runbook is the system prompt, and the user message is one of 40 on-call questions ("checkout-indexer is returning 5xx after a deploy 10 minutes ago. What is the first command to run?").&lt;/p&gt;

&lt;p&gt;Every request is streamed with &lt;code&gt;stream_options: { include_usage: true }&lt;/code&gt;, &lt;code&gt;temperature&lt;/code&gt; 0 and &lt;code&gt;max_tokens&lt;/code&gt; 48. We record the time to the first streamed token, total time and the usage block. For DeepSeek V4.1 Flash and &lt;code&gt;gpt-oss-120b&lt;/code&gt; that first token is usually reasoning, not answer text, because both reason before they answer. Each experiment starts from a prefix the cache has never seen: a random revision id on the first line, the same for all requests in that experiment.&lt;/p&gt;

&lt;p&gt;Two definitions matter for reading the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;hit&lt;/strong&gt; is a request where at least half the prompt came from the cache, and a &lt;strong&gt;miss&lt;/strong&gt; is one where under 10 percent did. &lt;code&gt;gpt-oss-120b&lt;/code&gt; reports 64 cached tokens on many requests that share nothing with an earlier one, which looks like its chat template, so "cached tokens above zero" does not mean a hit.&lt;/li&gt;
&lt;li&gt;Llama 4 Maverick answered &lt;code&gt;529 Platform overloaded&lt;/code&gt; 55 times during the run. The harness retried each one, counted it and kept it out of the timings. The other two models refused nothing. So the 867 requests are the ones that succeeded: 922 attempts in all, plus a first run of 20 requests we discarded because it did not record its retries.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;00-build-prompts&lt;/strong&gt; runbook 1k to 64k&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;02-run&lt;/strong&gt; one experiment, one model&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;results/raw&lt;/strong&gt; every usage block&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;03-score&lt;/strong&gt; hits, TTFT, cost&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A stable prefix: what you get
&lt;/h2&gt;

&lt;p&gt;The first experiment is the best case: the same 8k-token runbook on 20 requests in a row, a different question each time. This chart shows how much of each request's prompt came from the cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share of the prompt served from cache, 20 requests in a row&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&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;5&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;th&gt;7&lt;/th&gt;
&lt;th&gt;8&lt;/th&gt;
&lt;th&gt;9&lt;/th&gt;
&lt;th&gt;10&lt;/th&gt;
&lt;th&gt;11&lt;/th&gt;
&lt;th&gt;12&lt;/th&gt;
&lt;th&gt;13&lt;/th&gt;
&lt;th&gt;14&lt;/th&gt;
&lt;th&gt;15&lt;/th&gt;
&lt;th&gt;16&lt;/th&gt;
&lt;th&gt;17&lt;/th&gt;
&lt;th&gt;18&lt;/th&gt;
&lt;th&gt;19&lt;/th&gt;
&lt;th&gt;20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;99.6%&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.3%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;98.9%&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;8k-token runbook as the system prompt, one new question per request, sent one after another. cached_tokens / prompt_tokens from each response. Run on 19 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three different behaviors from one feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Llama 4 Maverick&lt;/strong&gt; hit from the second request on and never missed again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek V4.1 Flash&lt;/strong&gt; missed the first two requests and hit every one after that. That was common, not universal. Counting every experiment that sent a new prefix twice within seconds, the second request missed 24 times out of 30, and the third request on the same prefix hit all 26 times we sent one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gpt-oss-120b&lt;/code&gt;&lt;/strong&gt; missed 6 of the 19 requests after the first, scattered through the run. In a later run with the same layout, it hit only 10 of 28. A cached prefix lives on one replica, and the guide says requests can land on one that does not hold it. That fits what we saw, but the response does not say which replica served it, so we cannot confirm it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is what that does to the input bill, per 1,000 requests at list prices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input cost per 1,000 requests, 8k-token prompt&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;Llama 4 Maverick, no cache&lt;/td&gt;
&lt;td&gt;1.92$&lt;/td&gt;
&lt;td&gt;Without the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick, measured&lt;/td&gt;
&lt;td&gt;0.39$&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, no cache&lt;/td&gt;
&lt;td&gt;2.4$&lt;/td&gt;
&lt;td&gt;Without the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, measured&lt;/td&gt;
&lt;td&gt;0.19$&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, no cache&lt;/td&gt;
&lt;td&gt;0.78$&lt;/td&gt;
&lt;td&gt;Without the cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, stable run&lt;/td&gt;
&lt;td&gt;0.35$&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, later run&lt;/td&gt;
&lt;td&gt;0.55$&lt;/td&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Average over requests 2 to 20 of the stable run, scaled to 1,000, so DeepSeek's second-request miss is included. Counting the first request too gives $0.47, $0.30 and $0.37. The gpt-oss-120b later run is the key-ab experiment (both arms, requests 2 to 8 on four prefixes each, about 36% of tokens from cache), priced the same way. Input only; output is capped at 48 tokens and not compared.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On list price, &lt;code&gt;gpt-oss-120b&lt;/code&gt; is the cheap one: $0.10 per million input tokens against $0.30 for DeepSeek V4.1 Flash. With a stable prefix the order flips, because DeepSeek's cached tokens cost almost nothing and it hit far more often. If most of your tokens are a repeated prompt, compare models on cached cost and measured hit rate, not on the input price. Output is a separate sum: both models spent most of their 48 output tokens on reasoning in our runs, and DeepSeek's output costs $1.20 per million against $0.70.&lt;/p&gt;

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

&lt;p&gt;The guide lists the patterns that break the prefix match: a timestamp or request ID in the system prompt, per-user data before the shared instructions, tool definitions that change order. We tried three of those layouts on the same runbook, 20 requests each, each from a fresh prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stable [revision][runbook .......................][question]
timestamp-first [time][revision][runbook .................][question]
timestamp-last [revision][runbook .................][time][question]
user-in-middle [revision][runbook ...][name][runbook ....][question]

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Share of the prompt from cache after the first request, by layout&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;Llama 4 Maverick, stable&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick, time first&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick, time last&lt;/td&gt;
&lt;td&gt;99.5%&lt;/td&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick, name in middle&lt;/td&gt;
&lt;td&gt;27.4%&lt;/td&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, stable&lt;/td&gt;
&lt;td&gt;94%&lt;/td&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, time first&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, time last&lt;/td&gt;
&lt;td&gt;93.7%&lt;/td&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, name in middle&lt;/td&gt;
&lt;td&gt;26.5%&lt;/td&gt;
&lt;td&gt;DeepSeek V4.1 Flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, stable&lt;/td&gt;
&lt;td&gt;67.9%&lt;/td&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, time first&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, time last&lt;/td&gt;
&lt;td&gt;63.2%&lt;/td&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, name in middle&lt;/td&gt;
&lt;td&gt;19.5%&lt;/td&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Cached tokens as a share of prompt tokens over requests 2 to 20 of each run. 8k-token runbook, 19 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A timestamp on the first line changes the first tokens of every request, so nothing after it can match. All 57 requests after the first missed on all three models (the 0.8 percent on &lt;code&gt;gpt-oss-120b&lt;/code&gt; is its 64 template tokens). The requests still succeeded, and the only sign in the response was the usage block. On Llama 4 Maverick that layout cost $1.93 per 1,000 requests instead of $0.39.&lt;/p&gt;

&lt;p&gt;Moving the timestamp after the runbook brought the cached share back to the stable run's level on Llama 4 Maverick and DeepSeek V4.1 Flash (99.5 and 93.7 percent), and to 63.2 percent on &lt;code&gt;gpt-oss-120b&lt;/code&gt;, inside its usual swing from run to run. The name a quarter of the way in shows how prefix matching degrades. When the cache was used, it served the part before the name, 27 to 28 percent of the prompt, and nothing after it. DeepSeek V4.1 Flash and &lt;code&gt;gpt-oss-120b&lt;/code&gt; also missed outright on 1 and 6 of the 19 requests.&lt;/p&gt;

&lt;p&gt;The fix is in the template, not the platform:&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;// Breaks the cache: the first tokens change on every request.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Current time: &lt;/span&gt;&lt;span class="p"&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="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runbook&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="c1"&gt;// Keeps it: the runbook is a fixed prefix and the time comes after it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runbook&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nCurrent time: &lt;/span&gt;&lt;span class="p"&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="nf"&gt;toISOString&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="c1"&gt;// Better still: keep the system prompt byte-identical and put&lt;/span&gt;
&lt;span class="c1"&gt;// per-request data in the user message.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&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="na"&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;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;runbook&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`On-call engineer: &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="s2"&gt;\nTime: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&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="p"&gt;];&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;"Byte-identical" includes things that are easy to miss: a JSON blob serialized with keys in a different order, a tool list built from a &lt;code&gt;Set&lt;/code&gt;, trailing whitespace from a template engine. If you build the prefix in code, hash it and log the hash. Two hashes for one prompt version is a cache problem waiting to show up on the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bigger prompts, bigger latency win
&lt;/h2&gt;

&lt;p&gt;We ran the same comparison at four more prompt sizes: 5 requests on a fresh prefix each (all misses), and 6 requests on one prefix per size, where requests 2 to 6 that hit give the hit column. Medians of the time to the first streamed token; each miss cell is 5 requests, and each hit cell is 5 unless marked:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt size&lt;/th&gt;
&lt;th&gt;Llama 4 Maverick, miss / hit&lt;/th&gt;
&lt;th&gt;DeepSeek V4.1 Flash, miss / hit&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;gpt-oss-120b&lt;/code&gt;, miss / hit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~1k tokens&lt;/td&gt;
&lt;td&gt;940 / 773 ms&lt;/td&gt;
&lt;td&gt;707 / 524 ms (4 hits)&lt;/td&gt;
&lt;td&gt;599 / 564 ms (3 hits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~4k tokens&lt;/td&gt;
&lt;td&gt;939 / 807 ms&lt;/td&gt;
&lt;td&gt;642 / 546 ms&lt;/td&gt;
&lt;td&gt;848 / 519 ms (4 hits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~16k tokens&lt;/td&gt;
&lt;td&gt;1,458 / 878 ms&lt;/td&gt;
&lt;td&gt;1,032 / 626 ms&lt;/td&gt;
&lt;td&gt;1,169 / 609 ms (2 hits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~64k tokens&lt;/td&gt;
&lt;td&gt;5,015 / 1,444 ms&lt;/td&gt;
&lt;td&gt;3,068 / 911 ms&lt;/td&gt;
&lt;td&gt;4,770 ms / no hit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Time to first streamed token, nominal 64k-token prompt&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;Llama 4 Maverick, miss&lt;/td&gt;
&lt;td&gt;5015ms&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick, hit&lt;/td&gt;
&lt;td&gt;1444ms&lt;/td&gt;
&lt;td&gt;Hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, miss&lt;/td&gt;
&lt;td&gt;3068ms&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4.1 Flash, hit&lt;/td&gt;
&lt;td&gt;911ms&lt;/td&gt;
&lt;td&gt;Hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b, miss&lt;/td&gt;
&lt;td&gt;4770ms&lt;/td&gt;
&lt;td&gt;Miss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median of 5 requests each. gpt-oss-120b did not hit on any of 5 requests at this size. Run on 19 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At 1k to 4k tokens the medians differed by 35 to 330 milliseconds. At 64k they differed by 2.2 seconds on DeepSeek V4.1 Flash and 3.6 seconds on Llama 4 Maverick, seconds in which nothing streams back. If you put whole documents or long tool output in the prompt, this can matter more than the discount. The groups are small and were collected a few minutes apart rather than paired, so read the shape, not the exact milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long a cached prefix lasts
&lt;/h2&gt;

&lt;p&gt;The guide says entries have no fixed expiry and are evicted when the space is needed. We wanted to know what that means in minutes. Each prefix was sent once or twice, left alone, and asked again. The minute gaps are nominal: we counted them from the start of priming, so the real idle time was up to 35 seconds shorter (the raw data has the exact times).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asked again after&lt;/th&gt;
&lt;th&gt;Llama 4 Maverick&lt;/th&gt;
&lt;th&gt;DeepSeek V4.1 Flash&lt;/th&gt;
&lt;th&gt;&lt;code&gt;gpt-oss-120b&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 to 45 seconds (sent twice)&lt;/td&gt;
&lt;td&gt;8 of 8 hit&lt;/td&gt;
&lt;td&gt;8 of 8 hit&lt;/td&gt;
&lt;td&gt;4 of 8 hit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 minute, sent once / twice&lt;/td&gt;
&lt;td&gt;hit / hit&lt;/td&gt;
&lt;td&gt;miss / hit&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 minutes, sent once / twice&lt;/td&gt;
&lt;td&gt;hit / hit&lt;/td&gt;
&lt;td&gt;miss / hit&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 minutes, sent once / twice&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;td&gt;miss / hit&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 minutes, sent once / twice&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;td&gt;miss / hit&lt;/td&gt;
&lt;td&gt;miss / miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 minutes, sent once&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each cell is one request (two per gap for the seconds row), so this is a sketch of one afternoon, not a guarantee. A miss also does not prove the entry was evicted; the request may have gone to a replica that never had it. It still says three useful things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Llama 4 Maverick&lt;/strong&gt; hit at the nominal 5-minute probes and missed at 15 minutes, whether it had seen the prefix once or twice. It was also the model returning 529 overload errors, and the guide says eviction comes sooner under heavy load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek V4.1 Flash&lt;/strong&gt; missed every prefix it had seen only once, even after one minute, and hit every prefix it had seen twice, up to 30 minutes later. That is the same pattern as the second-request misses above, so it may have nothing to do with time: on this model, the cache paid off from the third request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gpt-oss-120b&lt;/code&gt;&lt;/strong&gt; hit about half the time within 45 seconds and missed all 9 probes at a minute or more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For traffic that repeats every few seconds, all three caches work. For a prefix used once every 15 minutes or so, such as a per-tenant prompt on a quiet tenant or a nightly job, plan on paying full price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bursts on a cold prefix
&lt;/h2&gt;

&lt;p&gt;What happens when a deploy or a queue drain sends ten requests at once with a prefix nobody has sent before? We sent ten at once on a cold prefix, then ten more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DeepSeek V4.1 Flash hit on 8 of the first 10 and &lt;code&gt;gpt-oss-120b&lt;/code&gt; on 7, even though none of them had seen the prefix before the burst started.&lt;/li&gt;
&lt;li&gt;Llama 4 Maverick refused 5 of the first 10 with 529 (9 refusals in all) and the harness retried them a few seconds later, so its number mixes concurrent and later requests. Of the 5 that went through on the first try, 4 hit.&lt;/li&gt;
&lt;li&gt;The second wave of ten hit 10 of 10 on all three models.&lt;/li&gt;
&lt;li&gt;One request alone and then ten at once on another new prefix: 10, 9 and 9 of the ten hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a cold burst does not pay full price ten times. The responses do not show how the server scheduled the requests, only that most of them reported cached tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What prompt_cache_key does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prompt_cache_key&lt;/code&gt; is an optional field on the request. The guide describes it as a way to keep cached context apart inside one account, for different tenants or workloads, and as a cache reset when you roll out a new prompt. Caches are already isolated between accounts.&lt;/p&gt;

&lt;p&gt;We checked the separation. On four fresh prefixes per model, we sent key A twice, then key B, then key A again, then no key at all, then key B a second time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;node 03-score.mjs (cache-key rows)&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="c"&gt;# a-1, a-2: key A twice. b-1: key B. a-3: key A again. none: no key. b-2: key B again.&lt;/span&gt;
&lt;span class="nv"&gt;$ STAMPS_OK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... node 03-score.mjs | &lt;span class="nb"&gt;grep &lt;/span&gt;cache-key
  cache-key a-1 0/4, a-2 4/4, b-1 0/4, a-3 4/4, none 0/4, b-2 4/4
  cache-key a-1 0/4, a-2 3/4, b-1 0/4, a-3 4/4, none 0/4, b-2 3/4
  cache-key a-1 0/4, a-2 2/4, b-1 0/4, a-3 1/4, none 0/4, b-2 1/4

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

&lt;/div&gt;



&lt;p&gt;The rows are Llama 4 Maverick, DeepSeek V4.1 Flash and &lt;code&gt;gpt-oss-120b&lt;/code&gt;. The first request with key B missed 12 of 12 times across the three models, on a prefix that had just hit under key A in 9 of those 12 trials. The same prefix with no key also missed 12 of 12, so in our runs "no key" behaved as a namespace of its own. Key A kept hitting after B was used on Llama 4 Maverick and DeepSeek V4.1 Flash (8 of 8); &lt;code&gt;gpt-oss-120b&lt;/code&gt; hit 1 of 4, in line with its usual misses.&lt;/p&gt;

&lt;p&gt;Does a key also improve hit rates, by keeping related requests together? We ran two new prefixes side by side, one with a key and one without, 8 requests each, four times per model. Llama 4 Maverick hit 28 of 28 later requests either way, DeepSeek V4.1 Flash 24 of 28 either way, and &lt;code&gt;gpt-oss-120b&lt;/code&gt; 10 of 28 either way. The guide says the key does not pin requests to a replica, and it made no difference here.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;prompt_cache_key&lt;/code&gt; for what it is for: separating tenants that share a prompt template, and forcing a clean start when you ship a new prompt version. Use a value that is stable per tenant or workload. A request ID as the key gives every request its own empty cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the cached share in production
&lt;/h2&gt;

&lt;p&gt;The cache never fails loudly. A template change that puts a timestamp first looks exactly like a working deploy, until the invoice arrives. The usage block on every response is enough to catch it:&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;// After each streamed response, with stream_options: { include_usage: true }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt_tokens&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;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt_tokens_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cached_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_read_input_tokens&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;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llm_prompt_tokens_total&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llm_cached_tokens_total&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Alert on sum(cached) / sum(prompt) per feature and model, over 15 minutes,&lt;/span&gt;
&lt;span class="c1"&gt;// against that feature's normal rate. A drop right after a deploy points at the prompt first.&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The DigitalOcean control panel shows the hit rate per model too (Serverless Inference, Analyze tab). Per-feature numbers from your own logs are more useful, because one model usually serves several features and only one of them will break.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One afternoon, one account.&lt;/strong&gt; All 867 recorded requests ran between 12:48 and 13:55 UTC on 19 October 2026. Hit rates and eviction depend on load: the share of &lt;code&gt;gpt-oss-120b&lt;/code&gt;'s prompt served from cache went from 68 to 36 percent between two runs about an hour apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small samples where it hurts.&lt;/strong&gt; The idle table is one request per cell, and the latency table is 5 misses and at most 5 hits per cell. They show direction, not exact values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No view of replicas.&lt;/strong&gt; The response does not say which replica served it, so the DeepSeek second-request pattern and the &lt;code&gt;gpt-oss-120b&lt;/code&gt; misses are described, not explained.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bill check is not done yet.&lt;/strong&gt; The repo compares the token counts from the API with the change in the invoice preview. A snapshot taken right after the last request, 68 minutes after the first one, showed no change yet (it is in the repo), so this post uses list prices and the cache read rates from our invoice, not a measured bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First streamed token, not first answer word.&lt;/strong&gt; For the two reasoning models, the latency numbers measure the first reasoning token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic prompts, ungraded answers.&lt;/strong&gt; The runbook is generated, and answers were capped at 48 tokens. This measures input caching, not answer quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put everything static first and keep it byte-identical: system prompt, policies, tool definitions, reference documents. Put the time, request IDs, user names and retrieved passages after it, or in the user message.&lt;/li&gt;
&lt;li&gt;Log &lt;code&gt;cached_tokens / prompt_tokens&lt;/code&gt; per feature and model, and alert when it drops. The response does not warn you any other way.&lt;/li&gt;
&lt;li&gt;Budget at list price and treat the cache as a discount you have to keep earning. On &lt;code&gt;gpt-oss-120b&lt;/code&gt;, and on prefixes that repeat less than every few minutes, it may not be there.&lt;/li&gt;
&lt;li&gt;When you compare models for a long-prompt workload, compare cached cost and measured hit rate. On our runbook, DeepSeek V4.1 Flash had the higher input price and the lower input bill.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;prompt_cache_key&lt;/code&gt; per tenant or per prompt version, never per request.&lt;/li&gt;
&lt;li&gt;If your prompts are tens of thousands of tokens, the latency win is worth more than the discount: on our 64k prompt, a hit reached the first streamed token about 3.5 times faster on Llama 4 Maverick and 3.4 times faster on DeepSeek V4.1 Flash.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cache is free, automatic and easy to break. Five minutes with the usage block tells you whether yours is working, and the &lt;a href="https://github.com/The-DevOps-Daily/do-inference-prompt-cache" rel="noopener noreferrer"&gt;harness&lt;/a&gt; runs the same tests on your own prompt if you replace the runbook. If you want the per-feature cost side of this, &lt;a href="https://dev.to/devopsdaily/knowing-what-your-ai-feature-costs-before-finance-does-303e"&gt;Knowing What Your AI Feature Costs Before Finance Does&lt;/a&gt; shows how to tag usage by feature on one key.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/digitalocean-inference-prompt-caching-measured" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>llm</category>
      <category>caching</category>
    </item>
    <item>
      <title>Postgres 19 Slipped to November: Every Pulled Feature and Why</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/postgres-19-slipped-to-november-every-pulled-feature-and-why-3b4m</link>
      <guid>https://dev.to/devopsdaily/postgres-19-slipped-to-november-every-pulled-feature-and-why-3b4m</guid>
      <description>&lt;p&gt;PostgreSQL 19 was planned for October 2026, and the project's roadmap still says so. General availability is now planned for 19 November, with a second release candidate on 12 November. It will ship without SQL/PGQ graph queries, &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE ... FOR PORTION OF&lt;/code&gt;, online checksum changes, &lt;code&gt;ALTER TABLE ... MERGE/SPLIT PARTITION&lt;/code&gt;, and seven smaller features that were in the beta 1 release notes. The short version going around is that AI reviewers found more bugs than the developers could fix. That is part of it. Peter Eisentraut, who committed several of the reverted features, lists several possible causes and says it is probably a different combination for each feature. The mailing-list threads and the commit log fit his account, and which cause matters to you depends on what you planned to use.&lt;/p&gt;

&lt;p&gt;This post goes through every feature that was pulled, with the reason given in its revert and what to use instead. It gives you a script that finds beta-era code using them, covers a limit in REPACK CONCURRENTLY (a feature that stayed) that its reference page does not mention, and counts what the commit log actually says about AI.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;13 release-note items, covering 11 features, are gone since beta 1.&lt;/strong&gt; Ten features were reverted after beta 1 was stamped on 1 June. The eleventh was reverted in May, but its release-note entry stayed until 13 October.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The five big reverts landed between 27 August and 16 September.&lt;/strong&gt; Their revert messages list 47 commits (SQL/PGQ), 30 (online checksums, some only partly reverted), 23 (FOR PORTION OF), 14 (MERGE/SPLIT PARTITION) and 13 (&lt;code&gt;pg_get_*_ddl()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The causes, as Eisentraut describes them:&lt;/strong&gt; some code was not good enough, some features had design problems that beta could not fix, LLM-assisted review found a long tail of edge-case bugs faster than they could be handled, security work from April to August took the senior reviewers, and "everything works with everything" in Postgres makes some features very hard to finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The November date has its own reason:&lt;/strong&gt; the release team is folding the fixes from the 12 November minor releases into 19 before general availability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI in the commit log:&lt;/strong&gt; 23 of the 937 commits after beta 1 mention AI assistance, an AI tool or an AI lab. That counts only explicit credit, so it is a floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For your upgrade:&lt;/strong&gt; run the check script over migrations and ops code written against a beta, plan REPACK CONCURRENTLY runs one at a time across the whole cluster, and expect the first scheduled minor release on 11 February 2027.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Code, migrations or runbooks written against a PostgreSQL 19 beta, if you want to check them&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bash&lt;/code&gt; and GNU &lt;code&gt;grep&lt;/code&gt; for the check script&lt;/li&gt;
&lt;li&gt;Python 3 and an authenticated &lt;code&gt;gh&lt;/code&gt; CLI if you want to rebuild the counts from the source tree&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below comes from the &lt;code&gt;REL_19_STABLE&lt;/code&gt; branch at commit &lt;code&gt;221423812d&lt;/code&gt; (17 October 2026), the release notes at the &lt;code&gt;REL_19_BETA1&lt;/code&gt; tag, and the threads linked in each section. The scripts and the data snapshot are in the companion repo:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The timeline
&lt;/h2&gt;

&lt;p&gt;Dates are commit and tag dates in the source tree. Public announcements follow a few days after a tag.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&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;April 2026&lt;/td&gt;
&lt;td&gt;Feature freeze for 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11 May&lt;/td&gt;
&lt;td&gt;23 security commits for the May minor releases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 May&lt;/td&gt;
&lt;td&gt;Database-specific logical replication snapshots reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 June&lt;/td&gt;
&lt;td&gt;Beta 1 stamped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 June to 17 July&lt;/td&gt;
&lt;td&gt;Four smaller reverts: fast defaults for domains, &lt;code&gt;pg_dumpall&lt;/code&gt; formats, a lower &lt;code&gt;ALTER DOMAIN&lt;/code&gt; lock, &lt;code&gt;GROUP BY ALL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13 July&lt;/td&gt;
&lt;td&gt;Beta 2 stamped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 August&lt;/td&gt;
&lt;td&gt;Beta 3 stamped, and 35 security commits for the August minor releases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 August&lt;/td&gt;
&lt;td&gt;Robert Haas posts an LLM ranking of the riskiest PostgreSQL 19 features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27 August to 16 September&lt;/td&gt;
&lt;td&gt;MERGE/SPLIT PARTITION, SQL/PGQ, CREATE SCHEMA, &lt;code&gt;pg_get_*_ddl()&lt;/code&gt;, FOR PORTION OF and online checksums reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21 September&lt;/td&gt;
&lt;td&gt;Beta 4 stamped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 October&lt;/td&gt;
&lt;td&gt;RC1 stamped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 November&lt;/td&gt;
&lt;td&gt;RC2 planned, the same day as the minor releases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19 November&lt;/td&gt;
&lt;td&gt;General availability planned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cluster from late August to mid September is the part that made news. Five large features came out in three weeks, two of them in the week before the last beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was pulled, and why
&lt;/h2&gt;

&lt;p&gt;The list comes from comparing the release notes at beta 1 with the branch head. An item counts as removed when none of the commits it cites appear in any current item, so items that were only reworded or merged do not count. That gives 13 items. The three &lt;code&gt;pg_get_*_ddl()&lt;/code&gt; functions are one feature, so 11 features.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Reverted&lt;/th&gt;
&lt;th&gt;Reason given&lt;/th&gt;
&lt;th&gt;What to do instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL/PGQ property graphs (&lt;code&gt;CREATE PROPERTY GRAPH&lt;/code&gt;, &lt;code&gt;GRAPH_TABLE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;7 Sep&lt;/td&gt;
&lt;td&gt;Open design questions the release management team listed on 2 September: graph metadata left orphaned by &lt;code&gt;DROP ... CASCADE&lt;/code&gt; (which could make a graph impossible to dump and restore), how labels and properties are scoped, whether a graph needs a &lt;code&gt;pg_class&lt;/code&gt; entry, and &lt;code&gt;pg_dump&lt;/code&gt; dependency loops. Some fixes still needed catalog changes.&lt;/td&gt;
&lt;td&gt;Recursive CTEs, or a graph extension such as Apache AGE (check its support for your version)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE ... FOR PORTION OF&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;15 Sep&lt;/td&gt;
&lt;td&gt;Lost updates under &lt;code&gt;READ COMMITTED&lt;/code&gt; when concurrent temporal updates and deletes touched the same rows. The documented &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; workaround did not cover &lt;code&gt;DELETE&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Split the ranges in application code or a trigger. The &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; constraints from Postgres 18 still enforce no overlap.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online data checksums (&lt;code&gt;pg_enable_data_checksums()&lt;/code&gt;, &lt;code&gt;pg_disable_data_checksums()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;16 Sep&lt;/td&gt;
&lt;td&gt;The revert message says it "saw a number of postcommit fixes during the beta period" and adds: "Suspicions were raised about the risk of more issues surfacing after GA."&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_checksums --enable&lt;/code&gt; on a stopped cluster, as before. Clusters that &lt;code&gt;initdb&lt;/code&gt; creates on 18 or later have checksums on by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ALTER TABLE ... MERGE PARTITIONS&lt;/code&gt; / &lt;code&gt;SPLIT PARTITION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;27 Aug&lt;/td&gt;
&lt;td&gt;Reverted "due to multiple design issues which are too late to address in this release cycle". The thread lists moved rows decoded as plain inserts (which breaks logical replication), stored generated columns that silently get new values, partition-specific indexes and constraints that are silently lost, and open questions about publication membership.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DETACH PARTITION&lt;/code&gt;, create the new partition, &lt;code&gt;INSERT ... SELECT&lt;/code&gt;, &lt;code&gt;ATTACH PARTITION&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_get_role_ddl()&lt;/code&gt;, &lt;code&gt;pg_get_tablespace_ddl()&lt;/code&gt;, &lt;code&gt;pg_get_database_ddl()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;13 Sep&lt;/td&gt;
&lt;td&gt;Design review: role output could not recreate password hashes the way &lt;code&gt;pg_dumpall&lt;/code&gt; does, there was no dependency ordering, and it would be a second, separate implementation of translating catalog state into SQL beside &lt;code&gt;pg_dump&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_dumpall --roles-only&lt;/code&gt; or &lt;code&gt;--tablespaces-only&lt;/code&gt;, and &lt;code&gt;pg_dump --create&lt;/code&gt; for the &lt;code&gt;CREATE DATABASE&lt;/code&gt; statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_dumpall&lt;/code&gt; custom, directory and tar formats, and &lt;code&gt;pg_restore --no-globals&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;18 Jun&lt;/td&gt;
&lt;td&gt;A post-commit review recommended reverting and finishing it through a commitfest.&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_dumpall --globals-only&lt;/code&gt;, plus &lt;code&gt;pg_dump -Fc&lt;/code&gt; or &lt;code&gt;-Fd&lt;/code&gt; per database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GROUP BY ALL&lt;/code&gt; (with no columns after it)&lt;/td&gt;
&lt;td&gt;17 Jul&lt;/td&gt;
&lt;td&gt;Wrong results when &lt;code&gt;ORDER BY&lt;/code&gt; used non-default equality semantics. A clean fix "seems like too much code churn for late beta", and the revert says "We'll revert and try again in v20".&lt;/td&gt;
&lt;td&gt;List the grouping columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast default for columns on domains with constraints&lt;/td&gt;
&lt;td&gt;8 Jun&lt;/td&gt;
&lt;td&gt;Failed with an empty table and an invalid default. The revert says the fix will involve an addition to the table access method API, so it was not ready for 19.&lt;/td&gt;
&lt;td&gt;Expect a table rewrite when you add such a column, as in 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More object types inside &lt;code&gt;CREATE SCHEMA&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;11 Sep&lt;/td&gt;
&lt;td&gt;It depended on a change to the order of &lt;code&gt;CREATE SCHEMA&lt;/code&gt; subcommands, and the compatibility risk of that change was judged larger than the gain. Foreign keys in &lt;code&gt;CREATE SCHEMA&lt;/code&gt; still run at the end, because the SQL standard requires it.&lt;/td&gt;
&lt;td&gt;Create the objects in separate statements after &lt;code&gt;CREATE SCHEMA&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lower lock level for &lt;code&gt;ALTER DOMAIN ... VALIDATE CONSTRAINT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;18 Jun&lt;/td&gt;
&lt;td&gt;Unsafe: DML that had already started when a &lt;code&gt;NOT VALID&lt;/code&gt; constraint was added could still write rows that violate it.&lt;/td&gt;
&lt;td&gt;It takes &lt;code&gt;ShareLock&lt;/code&gt; on tables that use the domain, as before. Run it in a quiet window.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logical decoding plugins that skip shared catalogs (database-specific snapshots)&lt;/td&gt;
&lt;td&gt;24 May&lt;/td&gt;
&lt;td&gt;The revert says it "was determined to have fundamental flaws".&lt;/td&gt;
&lt;td&gt;Nothing to replace, but it limits REPACK CONCURRENTLY (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two smaller behavior changes that never made the release notes were reverted too: forcing &lt;code&gt;LC_COLLATE&lt;/code&gt; to &lt;code&gt;C&lt;/code&gt; in the postmaster (the commit gives the reason as "Out of an abundance of caution") and basing identifier case folding on the default locale, which behaved differently for the builtin provider with locale &lt;code&gt;C&lt;/code&gt; and a single-byte encoding.&lt;/p&gt;

&lt;p&gt;Two features that stayed were cut back. REPACK CONCURRENTLY lost the snapshot change it depended on, which is the limit covered later in this post. The new fast path for foreign key checks went back to SPI on 15 October for keys whose values need a cast function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: design problems that beta could not fix
&lt;/h2&gt;

&lt;p&gt;Eisentraut starts his own list with the plain possibility that some of the code was not good enough. The threads behind the biggest reverts show something more specific: questions that had no agreed answer before the release.&lt;/p&gt;

&lt;p&gt;FOR PORTION OF is the clearest example. On 10 September Andres Freund flagged its concurrency behavior in a code review, and the following day he showed that under &lt;code&gt;READ COMMITTED&lt;/code&gt;, concurrent temporal updates and deletes on the same rows can lose all or part of the second operation. The leftover rows one statement inserts are not chained to the original row, and &lt;code&gt;EvalPlanQual&lt;/code&gt; does not re-scan for them. With &lt;code&gt;DELETE ... FOR PORTION OF&lt;/code&gt; in the mix, even the documented &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; workaround did not prevent it. The author proposed raising a serialization failure so the client could retry. Andres replied:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For better or worse, READ COMMITTED is extremely widely used, with one reason for that being not needing to retry, and making FPO not really work that way doesn't seem convincing to me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the release management team asked what the committer, Peter Eisentraut, was proposing, he answered: "I was trying to nudge toward reverting without saying it." He reverted it the next day.&lt;/p&gt;

&lt;p&gt;SQL/PGQ went the same way. The release management team posted the list of open design issues on 2 September, and Tom Lane had already written on 25 August:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;At this point I'd be willing to bet dinner that if we ship it in v19 there will be post-release bug discoveries that are unfixable until v20.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;His reason was specific: fixes still under discussion needed parse tree or catalog changes, which bump &lt;code&gt;catversion&lt;/code&gt;. Minor releases do not change the catalog, so a bug that needs such a change after release can stay unfixed until 20.&lt;/p&gt;

&lt;p&gt;MERGE/SPLIT PARTITION, the database-specific snapshots and the &lt;code&gt;pg_get_*_ddl()&lt;/code&gt; functions were reverted for design reasons too. For the DDL functions, Noah Misch's review argued that PostgreSQL should not carry two independent implementations of the logic that translates catalog state into SQL. The fast default for domains needed an addition to the table access method API, and its revert says it was not ready for 19.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: a long tail found faster
&lt;/h2&gt;

&lt;p&gt;Eisentraut's post about the reverts is the most direct account from someone on the receiving end. On the long tail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;But with LLM-assisted code review, these kinds of things can get found much faster, and then you’re staring at a list of like forty defect reports. And even if each of them requires only a three-line fix on average, there is a fixed overhead to dealing with each of these, and then you run out of time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He adds that most of the reverted code was written before LLM-assisted review was this useful, so "the effective quality bar has been raised after the time the feature was written and committed." He also says, as far as he can tell, the problem is not vibe coding: the code predates that.&lt;/p&gt;

&lt;p&gt;A mailing-list post from 25 August shows how this fed the decisions. Robert Haas asked Claude to rank the PostgreSQL 19 features by the number and type of bugs already fixed after feature freeze, and posted the top six to ask whether any should be reverted. The counts are the model's tally of fixed bugs, not an audited total, and the ranking found no new bugs itself. Here are its six entries, sorted by fix count, next to what happened to each feature:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-freeze fixes per PostgreSQL 19 feature, as tallied by an LLM, and what happened to it&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;REPACK / REPACK CONCURRENTLY&lt;/td&gt;
&lt;td&gt;28 fixes&lt;/td&gt;
&lt;td&gt;Kept in 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online data checksums&lt;/td&gt;
&lt;td&gt;25 fixes&lt;/td&gt;
&lt;td&gt;Reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE/DELETE FOR PORTION OF&lt;/td&gt;
&lt;td&gt;17 fixes&lt;/td&gt;
&lt;td&gt;Reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL/PGQ property graphs&lt;/td&gt;
&lt;td&gt;17 fixes&lt;/td&gt;
&lt;td&gt;Reverted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign key check fast path&lt;/td&gt;
&lt;td&gt;16 fixes&lt;/td&gt;
&lt;td&gt;Kept in 19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres_fdw statistics import&lt;/td&gt;
&lt;td&gt;7 fixes&lt;/td&gt;
&lt;td&gt;Kept in 19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Counts from the LLM ranking Robert Haas posted to pgsql-hackers on 25 August 2026 ("scary patch contest"). Values marked ~ in the post are shown as the round number. Sorted by count; the post ranked the foreign key fast path first. Outcome from the REL_19_STABLE commit log, 17 October 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The feature with the most fixes, REPACK, is still in 19. The fix count did not decide anything on its own. In the threads, the reverted features had open design questions (FOR PORTION OF, SQL/PGQ) or a failure mode too dangerous to risk a long tail. For online checksums the ranking put it plainly: "A corruption-detection feature producing false positives is exactly the wrong failure mode."&lt;/p&gt;

&lt;h3&gt;
  
  
  What the commit log says about AI
&lt;/h3&gt;

&lt;p&gt;The commit messages credit AI in a few clear forms: security reports from Anthropic's and OpenAI's research teams, "Reported-by: Claude Code (via Noah Misch)", "Found by Bharath using AI assisted review with Claude", and disclosures that a draft of a patch was written with an LLM. Counting every commit whose message mentions AI assistance, an AI tool or an AI lab:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REL_19_STABLE commits that mention AI assistance, tools or labs, and security commits, per month&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;Apr&lt;/th&gt;
&lt;th&gt;May&lt;/th&gt;
&lt;th&gt;Jun&lt;/th&gt;
&lt;th&gt;Jul&lt;/th&gt;
&lt;th&gt;Aug&lt;/th&gt;
&lt;th&gt;Sep&lt;/th&gt;
&lt;th&gt;Oct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AI-credited&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;td&gt;1 commits&lt;/td&gt;
&lt;td&gt;1 commits&lt;/td&gt;
&lt;td&gt;2 commits&lt;/td&gt;
&lt;td&gt;14 commits&lt;/td&gt;
&lt;td&gt;2 commits&lt;/td&gt;
&lt;td&gt;4 commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security: CVE&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;td&gt;23 commits&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;td&gt;35 commits&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;td&gt;0 commits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;From the commit messages on REL_19_STABLE, 1 April to 17 October 2026 (1,501 commits). AI-credited = the message names Claude, Codex, an LLM, AI-assisted review, Anthropic, OpenAI or similar. Security = a 'Security: CVE-' trailer. Explicit credit only, so the AI line is a floor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After beta 1 that is 23 of 937 commits, or 2.5%. It only counts credit in the commit message. Much of the review happens on the mailing list, and a reviewer who used an LLM to find a bug and then reported it in their own words is not counted unless the commit says so. So read it as a floor, not as the share of bugs AI found.&lt;/p&gt;

&lt;p&gt;The threads add two details that the headline version leaves out. In the FOR PORTION OF thread, Andres Freund raised the concurrency problem in his own review first. His reproduction of the lost update was then an AI-generated isolation test, but he wrote that he "really needed to force both Opus 5 and Fable 5.1 to get to it, they both swore up and down that this isn't a real issue at first". And in the &lt;code&gt;pg_get_*_ddl()&lt;/code&gt; review, Noah Misch ran an Opus 4.8 review as well and wrote that it "found some minor issues that aren't worth addressing before the above". The revert came from his own design objections. In both cases the LLM was a tool in a person's review, and people made the calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: security work took the reviewers
&lt;/h2&gt;

&lt;p&gt;The third cause is in Eisentraut's post too, and the commit log shows the timing. He calls it the "Vulnpocalypse": from April, the start of feature freeze, to August, the most recent security release, many senior developers were busy fixing security issues. The branch has 58 commits with a &lt;code&gt;Security: CVE&lt;/code&gt; trailer since April, covering 40 CVEs, all on two days: 23 on 11 May and 35 on 10 August. Several of the AI-credited commits in August are those security fixes.&lt;/p&gt;

&lt;p&gt;The review and testing that normally happens during beta was reduced while that work ran, and it picked up again in August. He offers that as the possible reason so many issues surfaced, and so many reverts happened, so late. Commit counts cannot measure how busy reviewers were, but the two security dates and the late-August cluster of reverts fit it. He thinks that with about two more months, much of the long tail could have been fixed, but the community chose to keep to the schedule instead of delaying for more fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: everything works with everything
&lt;/h2&gt;

&lt;p&gt;The last cause is structural. Eisentraut's example is a new SQL feature that has to work with domains, domains over composite types, a domain field with a &lt;code&gt;NOT NULL&lt;/code&gt; constraint, a dropped and re-added column, a partition detached and reattached with a different column order, inside a view called from a security-definer function from a trigger. Nobody can test those combinations by hand. He says LLM-assisted fuzzing finds problems in them quickly and probably has to become part of feature development, and that internal interfaces should change so that each new combination does not open so many interactions.&lt;/p&gt;

&lt;p&gt;He also points to something the project does not have: a way to commit a feature as experimental and mature it in the main tree. Some of the reverted work suffered from misunderstandings about exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the date is 19 November
&lt;/h2&gt;

&lt;p&gt;The reverts explain what is missing. They do not explain the date. The RC1 announcement does: the minor releases on 12 November "will include a large number of fixes", and "the release team decided to include these fixes in PostgreSQL 19 before its general availability". RC2 ships the same day as those minor releases, and general availability follows a week later.&lt;/p&gt;

&lt;p&gt;So the extra weeks are for fixes going into 19, not for features coming back. Some of the reverted features are aimed at 20. The &lt;code&gt;GROUP BY ALL&lt;/code&gt; revert says "We'll revert and try again in v20", and Eisentraut writes "I'm confident that many of the reverted features will come back before too long", with a few "80%-ready features" already queued for 20. Nothing promises any particular one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your upgrade
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Check code written against the betas
&lt;/h3&gt;

&lt;p&gt;If your team wrote migrations, reports or runbooks against a 19 beta, they may use syntax that no longer exists. The companion repo has a check script for eight patterns: SQL/PGQ, &lt;code&gt;FOR PORTION OF&lt;/code&gt;, &lt;code&gt;MERGE&lt;/code&gt;/&lt;code&gt;SPLIT PARTITION&lt;/code&gt;, bare &lt;code&gt;GROUP BY ALL&lt;/code&gt;, the online checksum functions, &lt;code&gt;pg_get_*_ddl()&lt;/code&gt;, &lt;code&gt;pg_dumpall&lt;/code&gt; non-text formats and &lt;code&gt;pg_restore --no-globals&lt;/code&gt;. It matches per line and on purpose loosely, so read each hit. &lt;code&gt;GROUP BY ALL region, status&lt;/code&gt; has been valid since Postgres 14 (&lt;code&gt;ALL&lt;/code&gt; is the default set quantifier) and is not flagged, but a valid &lt;code&gt;GROUP BY ALL&lt;/code&gt; whose columns start on the next line is. A clean result means no pattern matched, not that the upgrade is safe. Here it is on the repo's example folders:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;check-pulled-syntax.sh&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;scripts/check-pulled-syntax.sh examples/beta-era
FOR PORTION OF examples/beta-era/migrations/20260901_orders.sql:11:UPDATE prices FOR PORTION OF valid_at FROM &lt;span class="s1"&gt;'2026-10-01'&lt;/span&gt; TO &lt;span class="s1"&gt;'2026-11-01'&lt;/span&gt;
MERGE/SPLIT examples/beta-era/migrations/20260901_orders.sql:2:ALTER TABLE orders MERGE PARTITIONS &lt;span class="o"&gt;(&lt;/span&gt;orders_2026_07, orders_2026_08&lt;span class="o"&gt;)&lt;/span&gt; INTO orders_2026_q3&lt;span class="p"&gt;;&lt;/span&gt;
GROUP BY ALL examples/beta-era/migrations/20260901_orders.sql:8:SELECT region, status, count&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; FROM orders GROUP BY ALL
online checksums examples/beta-era/ops/backup.sh:5:psql &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT pg_enable_data_checksums()"&lt;/span&gt;
pg_get_&lt;span class="k"&gt;*&lt;/span&gt;_ddl&lt;span class="o"&gt;()&lt;/span&gt; examples/beta-era/ops/backup.sh:4:psql &lt;span class="nt"&gt;-Atc&lt;/span&gt; &lt;span class="s2"&gt;"SELECT pg_get_role_ddl('app')"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/app-role.sql
pg_dumpall formats examples/beta-era/ops/backup.sh:3:pg_dumpall &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;directory &lt;span class="nt"&gt;-f&lt;/span&gt; /backups/all-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
pg_restore globals examples/beta-era/ops/backup.sh:6:pg_restore &lt;span class="nt"&gt;--no-globals&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; postgres /backups/all-2026-09-01
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&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="nb"&gt;exit &lt;/span&gt;1
&lt;span class="nv"&gt;$ &lt;/span&gt;scripts/check-pulled-syntax.sh examples/postgres-18
no pulled PostgreSQL 19 pattern matched under examples/postgres-18
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&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="nb"&gt;exit &lt;/span&gt;0

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

&lt;/div&gt;



&lt;p&gt;It exits 1 on a hit and 2 if the scan itself fails, so you can run it in CI against the repository that holds your migrations:&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;# Fails the job if a pulled-syntax pattern matches. Pinned to a commit, not main;&lt;/span&gt;
&lt;span class="c"&gt;# or copy the script into your own repo.&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; check-pulled-syntax.sh &lt;span class="se"&gt;\&lt;/span&gt;
  https://raw.githubusercontent.com/The-DevOps-Daily/pg19-revert-ledger/2588e0660651b4b6fd132a85699d12c35d16be62/scripts/check-pulled-syntax.sh
bash check-pulled-syntax.sh db/migrations

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plan REPACK CONCURRENTLY one table at a time
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;REPACK (CONCURRENTLY)&lt;/code&gt; is one of the headline features of 19, and it stayed. But the change it relied on to build logical decoding snapshots per database was the one reverted in May. The revert message says what that means: it "restricts REPACK (CONCURRENTLY) so that only one process can run it concurrently on different tables and even on different databases". The original REPACK commit lists the same limit as a loose end, and Robert Haas's August post describes 19's REPACK CONCURRENTLY as "restricted to one process cluster-wide".&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;REPACK&lt;/code&gt; reference page lists &lt;code&gt;max_repack_replication_slots&lt;/code&gt; (default 5) as a limit, but not this one, and raising the slot setting does not lift it. If your plan was to run REPACK CONCURRENTLY across several bloated tables in parallel during one maintenance window, plan a queue instead. In the source, the decoding worker waits for every transaction that already has a transaction ID before it starts, so a second run most likely waits for the first. We did not run two at once, so treat that as a reading of the code, not a measurement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Plan the upgrade window
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test on RC1 now.&lt;/strong&gt; Feature-wise it is close to what ships. RC2 on 12 November adds the minor-release fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse the upgrade on a copy of your data.&lt;/strong&gt; The pulled features are easy to find with the script; the changes that did ship are where an upgrade surprises you. If you run on Neon, a branch gives you a copy-on-write copy of production in seconds, so you can clean up beta-era migrations and test the queries the script flags against real data without touching the primary. A major version upgrade on Neon is a move to a new project on the new version, and Neon's &lt;a href="https://neon.com/docs/postgresql/postgres-upgrade" rel="noopener noreferrer"&gt;major version upgrade guide&lt;/a&gt; covers the Import Data Assistant (for databases under 10 GB), dump and restore, and logical replication. Check which major versions Neon offers before you pick a date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General availability is planned for 19 November.&lt;/strong&gt; The next scheduled minor release after that is 11 February 2027, per the project's roadmap. If your policy is to wait for the first minor release, that is when 19.1 is due, unless an out-of-cycle release comes first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not plan on a pulled feature returning in a 19.x minor release.&lt;/strong&gt; Minor releases carry fixes, not new features, so the earliest any of them can return is 20.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;13 release-note items, covering 11 features, left the PostgreSQL 19 release notes after beta 1. Ten of the features were reverted after beta 1, one in May. The big ones are SQL/PGQ, FOR PORTION OF, online data checksums and MERGE/SPLIT PARTITION.&lt;/li&gt;
&lt;li&gt;The reasons differ by feature. In the threads, design questions with no agreed answer drove the big reverts. By Eisentraut's account, a long tail of edge-case bugs found faster with LLM-assisted review filled the queue, four months of security work took reviewers away during beta, and the need for every feature to work with every other one makes some of them very hard to finish.&lt;/li&gt;
&lt;li&gt;The commit log credits AI in 23 of 937 commits after beta 1. That is a floor, and in the threads people used the tools and people made the decisions.&lt;/li&gt;
&lt;li&gt;The 19 November date is about folding the 12 November minor-release fixes into 19, not about features coming back.&lt;/li&gt;
&lt;li&gt;Before you upgrade, check beta-era code with the script, and plan REPACK CONCURRENTLY as one run at a time across the cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: Peter Eisentraut, &lt;a href="https://peter.eisentraut.org/blog/2026/10/09/the-reverts-will-continue-until-morale-improves" rel="noopener noreferrer"&gt;The reverts will continue until morale improves&lt;/a&gt;; the &lt;a href="https://www.postgresql.org/about/news/postgresql-19-rc-1-released-3409/" rel="noopener noreferrer"&gt;PostgreSQL 19 RC 1 announcement&lt;/a&gt;; Robert Haas, &lt;a href="https://www.postgresql.org/message-id/CA%2BTgmob9NY6m0YNFTQ4nFH2d0iC9SQRruDYxfndGKKzh8OC80w%40mail.gmail.com" rel="noopener noreferrer"&gt;scary patch contest&lt;/a&gt; and &lt;a href="https://www.postgresql.org/message-id/646368.1787697184%40sss.pgh.pa.us" rel="noopener noreferrer"&gt;Tom Lane's reply&lt;/a&gt;; the &lt;a href="https://www.postgresql.org/message-id/CAAKRu_bEtjWYWhYxSo0o_t3DaZYRQd5PkC0abA0g9Mp4%2BovH0w%40mail.gmail.com" rel="noopener noreferrer"&gt;SQL/PGQ design issues&lt;/a&gt;; the &lt;a href="https://www.postgresql.org/message-id/flat/62bf70cc-dc33-4188-8070-a8c177bb61de%40eisentraut.org" rel="noopener noreferrer"&gt;FOR PORTION OF thread&lt;/a&gt;; &lt;a href="https://www.postgresql.org/message-id/20260827015242.54.noahmisch%40microsoft.com" rel="noopener noreferrer"&gt;pg_get_*_ddl() needs a redesign&lt;/a&gt;; &lt;a href="https://www.postgresql.org/message-id/CAN4CZFNCU%3Dt09M%3D%2Br2t9hHLJuujdM4oQ8hCK_Sx-GpfiwMAicw%40mail.gmail.com" rel="noopener noreferrer"&gt;MERGE/SPLIT PARTITIONS issues&lt;/a&gt;; and &lt;a href="https://www.theregister.com/databases/2026/10/16/postgres-19-delayed-after-ai-reviewers-find-more-bugs-than-humans-can-fix/5303471" rel="noopener noreferrer"&gt;The Register's report&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/postgres-19-delay-pulled-features" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>upgrades</category>
    </item>
    <item>
      <title>How Docker Works Under the Hood: Watch Namespaces Isolate a Container and cgroups Stop a Fork Bomb</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:02:04 +0000</pubDate>
      <link>https://dev.to/devopsdaily/how-docker-works-under-the-hood-watch-namespaces-isolate-a-container-and-cgroups-stop-a-fork-bomb-2k22</link>
      <guid>https://dev.to/devopsdaily/how-docker-works-under-the-hood-watch-namespaces-isolate-a-container-and-cgroups-stop-a-fork-bomb-2k22</guid>
      <description>&lt;p&gt;On a Linux host, start an nginx container, then run &lt;code&gt;ps aux&lt;/code&gt;. nginx is right there in the list, with an ordinary host PID. No guest kernel booted and no hypervisor started. That one observation is the key to how Docker works under the hood: a container is a normal Linux process that the kernel agrees to lie to (namespaces) and to ration (cgroups). The rest of &lt;code&gt;docker run&lt;/code&gt; is plumbing that sets those two things up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/games/docker-under-the-hood-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%2F4htfc4n6e6m0i1778b0x.png" alt="How Docker Works Under the Hood on DevOps Daily" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post walks the plumbing with two browser simulators, then shows the same kernel features on a real machine, no Docker required. Disclosure: I help build the simulators; they are free, run in the browser, and need no signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path of one docker run
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/docker-under-the-hood-simulator" rel="noopener noreferrer"&gt;How Docker Works Under the Hood simulator&lt;/a&gt; takes one command, &lt;code&gt;docker run -p 8080:80 nginx&lt;/code&gt;, and steps down the stack in eight stages: the Docker CLI, the Docker daemon, the registry pull, containerd, the OCI bundle, runc, the running container, and the Linux kernel. Press Play, or use Prev and Next to go at your own pace. Tick "nginx already pulled" and the registry stage drops out (the counter goes from Step 1 / 8 to Step 1 / 7), the first lesson: a pull only happens on a cache miss, and only missing layers come down.&lt;/p&gt;

&lt;p&gt;The stages worth pausing on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CLI is a REST client.&lt;/strong&gt; It sends an HTTP request to dockerd over &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. The stage shows a &lt;code&gt;curl --unix-socket&lt;/code&gt; request to the daemon's &lt;code&gt;/info&lt;/code&gt; endpoint: it starts nothing, but it uses the same transport the CLI does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dockerd does not start your process.&lt;/strong&gt; It hands off to containerd, which unpacks the image layers into a snapshot and tracks the container's lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OCI bundle is the whole recipe.&lt;/strong&gt; It is a &lt;code&gt;config.json&lt;/code&gt; plus a root filesystem. The excerpt in the simulator lists the five namespaces to create, with the type names from the &lt;a href="https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md#namespaces" rel="noopener noreferrer"&gt;OCI runtime spec&lt;/a&gt;: &lt;code&gt;pid&lt;/code&gt;, &lt;code&gt;network&lt;/code&gt;, &lt;code&gt;mount&lt;/code&gt;, &lt;code&gt;uts&lt;/code&gt;, &lt;code&gt;ipc&lt;/code&gt;. The kernel shortens two of them: &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/ns&lt;/code&gt; and &lt;code&gt;lsns&lt;/code&gt; say &lt;code&gt;net&lt;/code&gt; and &lt;code&gt;mnt&lt;/code&gt;, as you will see below. The command for this stage is &lt;code&gt;runc spec&lt;/code&gt;, which writes a sample &lt;code&gt;config.json&lt;/code&gt; so you can read the real shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runc does the kernel work and leaves.&lt;/strong&gt; It creates the namespaces and the cgroup, pivots into the rootfs, drops capabilities, and execs nginx as PID 1 inside the container. Then runc exits and a shim keeps the container attached to containerd.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every stage has a copy button next to its real command. The last stage ends on four labels: namespaces, cgroups, networking, mounts. Those are the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Namespaces: what the process can see
&lt;/h2&gt;

&lt;p&gt;You can look at your own namespaces right now on any Linux box. Every process has a set, and each one is a link in &lt;code&gt;/proc&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/self/ns    &lt;span class="c"&gt;# trimmed: link targets only, 6 of 10 entries&lt;/span&gt;
cgroup -&amp;gt; cgroup:[4026531835]
ipc -&amp;gt; ipc:[4026531839]
mnt -&amp;gt; mnt:[4026531841]
net -&amp;gt; net:[4026531840]
pid -&amp;gt; pid:[4026531836]
uts -&amp;gt; uts:[4026531838]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two processes share a namespace when the numbers match; a container's process shows different ones. The &lt;code&gt;unshare&lt;/code&gt; tool from util-linux makes new ones by hand. On Debian 12, as a normal user:&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;unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; &lt;span class="nt"&gt;--pid&lt;/span&gt; &lt;span class="nt"&gt;--fork&lt;/span&gt; &lt;span class="nt"&gt;--mount-proc&lt;/span&gt; ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,user,cmd
    PID USER     CMD
      1 root     ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,user,cmd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ps&lt;/code&gt; believes it is PID 1 and that it runs as root. Both are namespace tricks: a new PID namespace starts counting at 1, and the user namespace maps your normal UID to root inside it only. The UTS namespace does the same for the hostname:&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;unshare &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--map-root-user&lt;/span&gt; &lt;span class="nt"&gt;--uts&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'hostname demo-box; hostname'&lt;/span&gt;
demo-box
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;hostname
&lt;/span&gt;raspberrypi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your distro restricts unprivileged user namespaces, run them with sudo and drop &lt;code&gt;--user --map-root-user&lt;/code&gt;. The &lt;a href="https://man7.org/linux/man-pages/man7/namespaces.7.html" rel="noopener noreferrer"&gt;namespaces(7) man page&lt;/a&gt; lists all eight types.&lt;/p&gt;

&lt;h2&gt;
  
  
  cgroups: what the process can use
&lt;/h2&gt;

&lt;p&gt;Namespaces hide things. They do not limit anything. A process in its own PID namespace can still create processes until the host runs out. The classic proof is the fork bomb: a tiny shell function named &lt;code&gt;:&lt;/code&gt; that calls itself twice, one copy piped into another in the background. Every copy makes two more, the process table fills, and the machine stops responding.&lt;/p&gt;

&lt;p&gt;Do not run it. Use the &lt;a href="https://devops-daily.com/games/fork-bomb-simulator" rel="noopener noreferrer"&gt;Fork Bomb Simulator&lt;/a&gt; instead, which draws the process tree without forking anything real. Hover the code explainer to see what each of its ten pieces does, then press Start. The process count climbs until it hits the PID limit (a slider from 64 to 1024, 512 by default), the log prints &lt;code&gt;FATAL: fork() failed - Resource temporarily unavailable&lt;/code&gt;, and the screen reads SYSTEM CRASHED.&lt;/p&gt;

&lt;p&gt;Now pick one of the three Protection cards and start again: &lt;code&gt;ulimit -u&lt;/code&gt; (a per-user process cap), cgroups (&lt;code&gt;pids.max&lt;/code&gt;), or systemd (&lt;code&gt;TasksMax&lt;/code&gt; in a unit file). In the simulation, each one caps the run at 100 processes, and it ends with SYSTEM PROTECTED instead. That only works while the PID limit slider is above 100: set it to 64 and the system runs out first, protection or not. Same bomb, same growth, stopped at a line you drew in advance. On a real system they count different things. &lt;code&gt;ulimit -u&lt;/code&gt; counts every process and thread of your user ID across the whole machine, and the kernel does not enforce it for root. When I ran &lt;code&gt;ulimit -u 50&lt;/code&gt; in a shell on the Pi, the very first &lt;code&gt;fork()&lt;/code&gt; failed, because my user already owned 191 tasks elsewhere. &lt;code&gt;pids.max&lt;/code&gt; and &lt;code&gt;TasksMax&lt;/code&gt; count tasks inside one cgroup, whoever owns them. (The cgroups card shows the cgroup v2 form: &lt;code&gt;echo 100 &amp;gt; /sys/fs/cgroup/user.slice/user-1000.slice/pids.max&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the limit for real
&lt;/h2&gt;

&lt;p&gt;On a systemd machine with cgroup v2, where the user manager has the &lt;code&gt;pids&lt;/code&gt; controller delegated to it, you can put a command in a throwaway cgroup with a task limit, no root needed. To check, &lt;code&gt;cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers&lt;/code&gt; must list &lt;code&gt;pids&lt;/code&gt; (on the Pi it prints &lt;code&gt;cpu pids&lt;/code&gt;). This is not a fork bomb. It is a loop that stops after 50 children, run inside a scope capped at 10 tasks:&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;systemd-run &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;TasksMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10 python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
import os, time
n = 0
try:
    for _ in range(50):
        if os.fork() == 0:
            time.sleep(2)
            os._exit(0)
        n += 1
except OSError as e:
    print("started", n, "children, then:", e)
'&lt;/span&gt;
Running scope as unit: run-r01155c5e085f497f94c998714d8323f5.scope
started 9 children, &lt;span class="k"&gt;then&lt;/span&gt;: &lt;span class="o"&gt;[&lt;/span&gt;Errno 11] Resource temporarily unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine children plus the Python parent is ten tasks, the value systemd wrote to the scope's &lt;code&gt;pids.max&lt;/code&gt;. The tenth &lt;code&gt;fork()&lt;/code&gt; failed with EAGAIN, which is what the &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v2.html" rel="noopener noreferrer"&gt;kernel's cgroup v2 docs&lt;/a&gt; promise. In the simulator, the same refusal shows as &lt;code&gt;cgroup: fork rejected, pids.max (100) reached&lt;/code&gt; on the cgroups card. Nothing else on the machine noticed. A version of the script that also reads its own cgroup files printed &lt;code&gt;pids.events: max 1&lt;/code&gt; at the end: that file counts refused forks, so it is the one to alert on.&lt;/p&gt;

&lt;p&gt;Docker exposes the same controller per container: &lt;code&gt;docker run --pids-limit 100 nginx&lt;/code&gt; sets that ceiling on the container's cgroup (&lt;a href="https://docs.docker.com/reference/cli/docker/container/run/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). Without that flag, a runaway container is bounded only by the limits the host already has. On the Pi, systemd's &lt;code&gt;DefaultTasksMax&lt;/code&gt; is 3914 (15% of &lt;code&gt;kernel.threads-max&lt;/code&gt;), but whether a container's unit inherits a default like that depends on the runtime. Check instead of guessing: on a cgroup v2 host, &lt;code&gt;docker run --rm alpine cat /sys/fs/cgroup/pids.max&lt;/code&gt; prints the container's limit, and &lt;code&gt;max&lt;/code&gt; means none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole trick
&lt;/h2&gt;

&lt;p&gt;Side by side, the two simulators put how Docker works under the hood in one sentence: runc asks the kernel for new namespaces so the process sees its own small world, and puts it in a cgroup where the limits you set cap what it can use. A cgroup with no limits caps nothing, and a PID cap does not cap CPU or memory. A limit also needs its controller: the Pi boots with &lt;code&gt;cgroup_disable=memory&lt;/code&gt;, and a scope with &lt;code&gt;MemoryMax=50M&lt;/code&gt; let a process allocate 100 MiB without an error. dockerd, containerd and the registry are how the recipe gets built and delivered.&lt;/p&gt;

&lt;p&gt;For the longer tour, with commands to run at each layer of a real Docker host, read &lt;a href="https://devops-daily.com/posts/how-docker-works-under-the-hood" rel="noopener noreferrer"&gt;How Docker Really Works, From docker run to the Kernel&lt;/a&gt;. For the controllers themselves, see &lt;a href="https://man7.org/linux/man-pages/man7/cgroups.7.html" rel="noopener noreferrer"&gt;cgroups(7)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What surprised you most the first time you looked under the hood of a container? Tell us in the comments.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>containers</category>
      <category>devops</category>
    </item>
    <item>
      <title>Let's Encrypt Goes to 64-Day Certificates: When Does Your ACME Client Actually Renew?</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/lets-encrypt-goes-to-64-day-certificates-when-does-your-acme-client-actually-renew-347l</link>
      <guid>https://dev.to/devopsdaily/lets-encrypt-goes-to-64-day-certificates-when-does-your-acme-client-actually-renew-347l</guid>
      <description>&lt;p&gt;Let's Encrypt will issue &lt;strong&gt;64-day certificates by default from 10 February 2027&lt;/strong&gt; , down from 90, on the way to 45 days in 2028. Its staging environment switched on 14 October 2026, and we had a watcher waiting for it: the first 64-day certificate arrived at 21:35 UTC. We issued 64-day staging certificates with five ACME client installs and recorded when each one renews, and ran the same test at 45 and 90 days for comparison. The clients that use ARI renewed or scheduled a 64-day certificate for &lt;strong&gt;day 42 or 43&lt;/strong&gt; , at the same two-thirds share of the lifetime as at 45 and 90 days. The one that breaks is old acme.sh: version 3.0.7 scheduled a 64-day certificate for day 59 with its defaults, five days before it expires, and for &lt;strong&gt;day 79 with &lt;code&gt;--days 80&lt;/code&gt;, fifteen days after it expired&lt;/strong&gt;. Upgrading to 3.1.6 moved that schedule to day 42 at the next check.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;On 64-day staging certificates, Let's Encrypt's ARI endpoint suggested renewing between &lt;strong&gt;day 42.04 and 43.32&lt;/strong&gt; , 65.7 to 67.7 percent of the lifetime. We measured the same share at 45 and 90 days. That is about 21 days before expiry.&lt;/li&gt;
&lt;li&gt;certbot 5.8 renewed inside that window (it picks a random time in it on every run), acme.sh 3.1.6 scheduled day 42.16, and lego 5.5.2 allowed renewal from day 42.67. These configurations are ready for shorter lifetimes.&lt;/li&gt;
&lt;li&gt;certbot 2.9, the version apt installed on our Ubuntu 24.04 machine, does not use ARI and became due 30 days before expiry: day 34 of 64. Early, but safe.&lt;/li&gt;
&lt;li&gt;acme.sh 3.0.7 schedules the next renewal at issue + &lt;code&gt;--days&lt;/code&gt; - 1 day, with no check against the expiry date. Its default of 60 gave day 59.04 of 64, and &lt;code&gt;--days 80&lt;/code&gt; gave day 79.04. Every acme.sh tag we scanned from 3.0.7 to 3.1.2 has the same default of 60.&lt;/li&gt;
&lt;li&gt;Search your cron jobs, wrapper scripts and monitoring for hardcoded 60, 80 and 83, as Let's Encrypt itself suggests, and still check your renewal timer, challenge path and deploy hook.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A server that renews certificates with certbot, acme.sh or lego&lt;/li&gt;
&lt;li&gt;Shell access to check the client version and its stored renewal settings&lt;/li&gt;
&lt;li&gt;To reproduce the test: a throwaway VM with port 80 reachable, and &lt;code&gt;faketime&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything we ran, and every output file, is in the repository:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What changes, and when
&lt;/h2&gt;

&lt;p&gt;From Let's Encrypt's &lt;a href="https://letsencrypt.org/2026/10/07/64-day-certs" rel="noopener noreferrer"&gt;announcement&lt;/a&gt; of 7 October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;14 October 2026&lt;/strong&gt; : staging switches to 64-day certificates. Our watcher's request at 21:25 UTC still returned a 90-day certificate, and the one at 21:35 UTC returned a 64-day certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 February 2027&lt;/strong&gt; : all subscribers move to 64-day certificates by default. The last 90-day certificates expire around 11 May 2027.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;authorization reuse period&lt;/strong&gt; drops from 30 days to 10 days, and to seven hours in 2028.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2028&lt;/strong&gt; : 45-day certificates by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advice in the announcement is short: if your renewals are automated and your client supports ARI, you should be all set; if not, renew at about two thirds of the lifetime, and look for hardcoded values such as 83, 80 or 60 in cron jobs, wrapper scripts and runbooks. We wanted to see which clients actually follow that, and what happens to the ones that do not.&lt;/p&gt;

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

&lt;p&gt;On a throwaway Ubuntu 24.04 droplet we installed five client versions side by side: certbot 2.9.0 from apt, certbot 5.8.0 from snap, acme.sh 3.0.7 and 3.1.6 from the upstream repository, and lego 5.5.2. They requested real certificates from the Let's Encrypt staging environment, using hostnames under &lt;code&gt;sslip.io&lt;/code&gt; that resolved to the droplet, at three lifetimes. Where a client could not request a lifetime, we used copied state or its source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;64-day certificates&lt;/strong&gt; from the default &lt;code&gt;classic&lt;/code&gt; profile, after staging switched. A watcher asked for a classic certificate every 10 minutes from 12:36 UTC and started the suite as soon as the lifetime dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;90-day certificates&lt;/strong&gt; from the same &lt;code&gt;classic&lt;/code&gt; profile, earlier that evening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;45-day certificates&lt;/strong&gt; from the &lt;code&gt;tlsserver&lt;/code&gt; profile, the lifetime planned for 2028. acme.sh 3.0.7 and certbot 2.9 cannot request a profile, so certbot 2.9 was tested on a copy of certbot 5.8's 45-day certificate and acme.sh 3.0.7 was read from its source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, for each certificate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;acme.sh stores its planned renewal time in its config, so we read it.&lt;/li&gt;
&lt;li&gt;lego prints, at issuance, when a renewal will be allowed; we ran it with ARI on and with &lt;code&gt;--ari-disable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;certbot only decides when &lt;code&gt;certbot renew&lt;/code&gt; runs, so we ran it under &lt;code&gt;faketime&lt;/code&gt; at chosen hours after issuance, each time on a fresh copy of the state, and recorded whether it renewed.&lt;/li&gt;
&lt;li&gt;A small script asked the ARI endpoint (&lt;a href="https://www.rfc-editor.org/rfc/rfc9773" rel="noopener noreferrer"&gt;RFC 9773&lt;/a&gt;) for the suggested window of every certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Days are counted from each certificate's &lt;code&gt;notBefore&lt;/code&gt; time, which Let's Encrypt sets about an hour before issuance. This is the certbot 2.9 probe on the 64-day certificate, as recorded:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;certbot 2.9.0 under faketime, 64-day staging certificate&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;probe-certbot.sh certbot &lt;span class="nv"&gt;$R&lt;/span&gt;/cb29 c29n.46-101-102-127.sslip.io 800 812 815 818 830
&lt;span class="c"&gt;# certbot 2.9.0 c29n.46-101-102-127.sslip.io notBefore=Oct 14 20:37:00 2026 GMT notAfter=Dec 17 20:36:59 2026 GMT&lt;/span&gt;
hour 800 &lt;span class="o"&gt;(&lt;/span&gt;day 33.33, 2026-11-17 04:37:00 UTC&lt;span class="o"&gt;)&lt;/span&gt;: not due
hour 812 &lt;span class="o"&gt;(&lt;/span&gt;day 33.83, 2026-11-17 16:37:00 UTC&lt;span class="o"&gt;)&lt;/span&gt;: not due
hour 815 &lt;span class="o"&gt;(&lt;/span&gt;day 33.96, 2026-11-17 19:37:00 UTC&lt;span class="o"&gt;)&lt;/span&gt;: not due
hour 818 &lt;span class="o"&gt;(&lt;/span&gt;day 34.08, 2026-11-17 22:37:00 UTC&lt;span class="o"&gt;)&lt;/span&gt;: RENEWED
hour 830 &lt;span class="o"&gt;(&lt;/span&gt;day 34.58, 2026-11-18 10:37:00 UTC&lt;span class="o"&gt;)&lt;/span&gt;: RENEWED

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

&lt;/div&gt;



&lt;p&gt;Not due at day 33.96, renewed at day 34.08: the threshold is between the two, consistent with 30 days before the 64-day expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;64-day&lt;/th&gt;
&lt;th&gt;45-day&lt;/th&gt;
&lt;th&gt;90-day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ARI suggested window&lt;/td&gt;
&lt;td&gt;day 42.04 to 43.32&lt;/td&gt;
&lt;td&gt;day 29.56 to 30.46&lt;/td&gt;
&lt;td&gt;day 59.11 to 60.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;certbot 5.8.0 (snap), ARI&lt;/td&gt;
&lt;td&gt;first renewal seen at day 42.50&lt;/td&gt;
&lt;td&gt;first renewal seen at day 30.29&lt;/td&gt;
&lt;td&gt;first renewal seen at day 59.92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.1.6, ARI&lt;/td&gt;
&lt;td&gt;scheduled day 42.16&lt;/td&gt;
&lt;td&gt;scheduled day 29.6 to 30.3&lt;/td&gt;
&lt;td&gt;scheduled day 60.7 to 60.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lego 5.5.2&lt;/td&gt;
&lt;td&gt;allowed from day 42.67&lt;/td&gt;
&lt;td&gt;allowed from day 30.0&lt;/td&gt;
&lt;td&gt;allowed from day 60.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;certbot 2.9.0 (apt), no ARI&lt;/td&gt;
&lt;td&gt;due between day 33.96 and 34.08&lt;/td&gt;
&lt;td&gt;due between day 14.96 and 15.08&lt;/td&gt;
&lt;td&gt;due between day 59.79 and 60.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.0.7, default &lt;code&gt;--days 60&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;scheduled day 59.04, 5 days before expiry&lt;/td&gt;
&lt;td&gt;day 59, after expiry (source)&lt;/td&gt;
&lt;td&gt;scheduled day 59.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.0.7, &lt;code&gt;--days 80&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;scheduled day 79.04, &lt;strong&gt;15 days after expiry&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;day 79, after expiry (source)&lt;/td&gt;
&lt;td&gt;scheduled day 79.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Day each client renews, or plans to renew, a 64-day certificate&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;certbot 2.9 (apt)&lt;/td&gt;
&lt;td&gt;34 days&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;certbot 5.8 (ARI)&lt;/td&gt;
&lt;td&gt;42.5 days&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.1.6 (ARI)&lt;/td&gt;
&lt;td&gt;42.16 days&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lego 5.5.2&lt;/td&gt;
&lt;td&gt;42.67 days&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.0.7, defaults&lt;/td&gt;
&lt;td&gt;59.04 days&lt;/td&gt;
&lt;td&gt;tight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.0.7, --days 80&lt;/td&gt;
&lt;td&gt;79.04 days&lt;/td&gt;
&lt;td&gt;expired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same, after upgrading to 3.1.6&lt;/td&gt;
&lt;td&gt;42.23 days&lt;/td&gt;
&lt;td&gt;safe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The certificate expires on day 64. Measured on Let's Encrypt staging on 2026-10-14, one certificate per row. certbot 2.9 became due between day 33.96 and 34.08. certbot 5.8 is its first renewal in our probes; it picks a random time inside the ARI window (day 42.04 to 43.32) on every run. acme.sh shows its stored schedule and lego the day it reported at issuance. The last bar is the --days 80 certificate after upgrading acme.sh to 3.1.6. Source: results/measured-64.txt in the repo.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ARI clients: the window does the work
&lt;/h3&gt;

&lt;p&gt;The ARI window opened at 65.7 percent of the lifetime and closed at 67.7 percent at all three lengths: day 42.04 to 43.32 of 64, day 29.56 to 30.46 of 45, and day 59.11 to 60.91 of 90. On a 64-day certificate, that is 21 to 22 days before expiry. A client that follows the window needs no change for 45 days in 2028 either.&lt;/p&gt;

&lt;p&gt;While we waited, we projected the 64-day column from the 45-day and 90-day runs and committed the projection at 21:32 UTC (commit &lt;code&gt;f909cfc&lt;/code&gt; in the repository). Our next request, at 21:35 UTC, returned the first 64-day certificate. Every 64-day result in the table matches that projection (&lt;code&gt;results/projection-64.txt&lt;/code&gt; and &lt;code&gt;results/measured-64.txt&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;certbot 5.8 does not renew at one fixed moment. Each time it runs, it asks for the window, picks a random time inside it, and renews if that time has passed. From its source (&lt;code&gt;acme/client.py&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;delta_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;random_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="n"&gt;delta_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;random_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;random_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;So a run a third of the way into the window renews about a third of the time. These are our 64-day probes, each from a fresh copy of the same state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hour 1010 (day 42.08, 2026-11-25 22:37:12 UTC): not due
hour 1014 (day 42.25, 2026-11-26 02:37:12 UTC): not due
hour 1020 (day 42.50, 2026-11-26 08:37:12 UTC): RENEWED
hour 1030 (day 42.92, 2026-11-26 18:37:12 UTC): not due

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

&lt;/div&gt;



&lt;p&gt;The run at day 42.50 had about a 36 percent chance and renewed. The run at day 42.92 had about 68 percent and did not. This spreads renewals out, as RFC 9773 intends, so a fleet does not renew all at once. Once the window has closed, every run renews: on the 45-day and 90-day certificates, every probe after the window renewed. If the ARI request fails, certbot 5.8 catches the error and gets no ARI time; it then renews at two thirds of the lifetime (day 42.67 of 64), or by &lt;code&gt;renew_before_expiry&lt;/code&gt; if that is set in the renewal config. We read this in its source (&lt;code&gt;results/certbot-5.8-ari-source.txt&lt;/code&gt;) but did not test a failed ARI request.&lt;/p&gt;

&lt;p&gt;acme.sh 3.1.6 scheduled its next renewal for day 42.16, with its default and with &lt;code&gt;--days 80&lt;/code&gt;. It also stayed inside the window with &lt;code&gt;--days 60&lt;/code&gt; at 45 days and with &lt;code&gt;--days 80&lt;/code&gt; at 90 days. lego 5.5.2, checked at issuance, gave the same answer with ARI on and off: renewal allowed from day 42.67, exactly two thirds of the lifetime.&lt;/p&gt;

&lt;p&gt;These are the clients' decisions, not your whole setup. A renewal still needs a timer or cron job that runs often enough, a working challenge path and a deploy hook that reloads the server.&lt;/p&gt;

&lt;h3&gt;
  
  
  certbot 2.9: early, and fine
&lt;/h3&gt;

&lt;p&gt;certbot added ARI support in &lt;a href="https://community.letsencrypt.org/t/certbot-4-1-0-release/238369" rel="noopener noreferrer"&gt;version 4.1.0&lt;/a&gt; (June 2025). The certbot that apt installed on our Ubuntu 24.04 machine was 2.9.0, and its probes became due 30 days before expiry at every lifetime: between day 33.96 and 34.08 of 64, between day 14.96 and 15.08 of 45, and between day 59.79 and 60.21 of 90. It renews more often than it needs to, and it leaves about 30 days to notice and fix a failed renewal, if its timer runs normally.&lt;/p&gt;

&lt;h3&gt;
  
  
  acme.sh 3.0.7: the one that breaks
&lt;/h3&gt;

&lt;p&gt;acme.sh 3.0.7 computes the next renewal when it issues a certificate, and never compares it with the expiry date. From its source:&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;Le_NextRenewTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;_math &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$Le_CertCreateTime&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; + &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$Le_RenewalDays&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\*&lt;/span&gt; 24 &lt;span class="se"&gt;\*&lt;/span&gt; 60 &lt;span class="se"&gt;\*&lt;/span&gt; 60&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;Le_NextRenewTime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;_math &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$Le_NextRenewTime&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; - 86400&lt;span class="si"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Le_RenewalDays&lt;/code&gt; is the &lt;code&gt;--days&lt;/code&gt; value, 60 by default. The 64-day staging run shows the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;acme307 a307n notBefore=Oct 14 20:37:26 2026 GMT notAfter=Dec 17 20:37:25 2026 GMT next='2026-12-12T21:36:01Z' days=
acme307 a307n80 notBefore=Oct 14 20:37:43 2026 GMT notAfter=Dec 17 20:37:42 2026 GMT next='2027-01-01T21:36:18Z' days='80'

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

&lt;/div&gt;



&lt;p&gt;Both certificates expire on 17 December. With the default, the next renewal is 12 December, day 59.04, so one failed renewal (a DNS hiccup, a firewall change, a rate limit) leaves very little time before expiry. With &lt;code&gt;--days 80&lt;/code&gt;, the next renewal is 1 January 2027, day 79.04, fifteen days after the certificate expired, and acme.sh 3.0.7 does not try before then. The 90-day run gave the same days, 59 and 79, which were safe at that lifetime. At 45 days in 2028, the default fails too.&lt;/p&gt;

&lt;p&gt;We scanned each acme.sh tag from 3.0.7 to 3.1.6 for the scheduling code (the scan is in the repo):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;acme.sh tag&lt;/th&gt;
&lt;th&gt;Tag date&lt;/th&gt;
&lt;th&gt;Default &lt;code&gt;--days&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Cap one day before expiry&lt;/th&gt;
&lt;th&gt;ARI code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3.0.7 to 3.1.2&lt;/td&gt;
&lt;td&gt;Oct 2023 to Nov 2025&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.1.3&lt;/td&gt;
&lt;td&gt;Apr 2026&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;first ARI function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.1.4&lt;/td&gt;
&lt;td&gt;Jul 2026&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;ARI used when scheduling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.1.5, 3.1.6&lt;/td&gt;
&lt;td&gt;Sep 2026&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;ARI also checked on every renewal run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We only ran 3.0.7 and 3.1.6. The rows for the versions in between come from reading the source, so check your own version's behaviour if it is one of those.&lt;/p&gt;

&lt;h3&gt;
  
  
  What upgrading does to an existing certificate
&lt;/h3&gt;

&lt;p&gt;acme.sh stores &lt;code&gt;Le_RenewalDays&lt;/code&gt; and the next renewal time in each certificate's config, so an upgrade does not by itself change a schedule written by an old version. We tested what happens next: we copied the state of the 64-day certificate that 3.0.7 had scheduled for day 79 and ran acme.sh 3.1.6 &lt;code&gt;--renew&lt;/code&gt; on it, without &lt;code&gt;--force&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== before: acme.sh 3.0.7 state
Le_RenewalDays='80'
Le_NextRenewTimeStr='2027-01-01T21:36:18Z'
notAfter=Dec 17 20:37:42 2026 GMT
== acme.sh 3.1.6 --renew on the same state (no --force), 2026-10-14T23:22:34Z
[Wed Oct 14 23:22:34 UTC 2026] Renew: a307n80.46-101-102-127.sslip.io
[Wed Oct 14 23:22:34 UTC 2026] Renewing: 'a307n80.46-101-102-127.sslip.io'
[Wed Oct 14 23:22:34 UTC 2026] Renewing using Le_API=https://acme-staging-v02.api.letsencrypt.org/directory
[Wed Oct 14 23:22:35 UTC 2026] Current renewal time: 2027-01-01T21:36:18Z
[Wed Oct 14 23:22:35 UTC 2026] ARI suggestedWindow: 2026-11-25T21:36:11Z to 2026-11-27T04:18:13Z
[Wed Oct 14 23:22:35 UTC 2026] Updating renewal time picked from ARI window: 2026-11-26T02:10:18Z
[Wed Oct 14 23:22:35 UTC 2026] Skipping. Next renewal time is: 2026-11-26T02:10:18Z
== after
Le_RenewalDays='80'
Le_NextRenewTimeStr='2026-11-26T02:10:18Z'

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

&lt;/div&gt;



&lt;p&gt;The stored &lt;code&gt;--days 80&lt;/code&gt; stays, but 3.1.6 asked the ARI endpoint on its first check and moved the renewal from 1 January, after expiry, to 26 November, day 42.23. The same test on the 90-day certificate gave the same result. Its source says it does this on every renewal check unless &lt;code&gt;NO_ARI=1&lt;/code&gt; is set. On a CA without ARI, or with &lt;code&gt;NO_ARI=1&lt;/code&gt;, the stored schedule is what counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check on your servers
&lt;/h2&gt;

&lt;p&gt;Find out which client and version renews each certificate, and what it has stored:&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;# certbot: version, and any renewal setting that overrides the default&lt;/span&gt;
certbot &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; renew_before_expiry /etc/letsencrypt/renewal/&lt;span class="k"&gt;*&lt;/span&gt;.conf

&lt;span class="c"&gt;# acme.sh: version, the stored --days, and the stored next renewal time&lt;/span&gt;
~/.acme.sh/acme.sh &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^Le_RenewalDays=|^Le_NextRenewTimeStr='&lt;/span&gt; ~/.acme.sh/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.conf

&lt;span class="c"&gt;# lego: version (ARI is on unless you pass --ari-disable)&lt;/span&gt;
lego &lt;span class="nt"&gt;--version&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade acme.sh&lt;/strong&gt; to 3.1.6 or later wherever it runs (&lt;code&gt;acme.sh --upgrade&lt;/code&gt;), and run &lt;code&gt;acme.sh --cron&lt;/code&gt; once to see the new schedule. On anything that has to stay on an older version, compare each stored &lt;code&gt;Le_NextRenewTimeStr&lt;/code&gt; with the certificate's expiry, and lower &lt;code&gt;--days&lt;/code&gt; so the next renewal comes well before it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for 60, 80 and 83&lt;/strong&gt; in cron jobs, systemd timers, wrapper scripts and runbooks, as Let's Encrypt suggests. A script that renews "when the certificate is 60 days old" leaves four days of margin at 64 days and none at 45.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your expiry alerts.&lt;/strong&gt; The ARI clients chose renewal times with about 21 days left on our 64-day certificates, so an alert at 30 days left fires on every certificate while renewal is working fine, and people learn to ignore it. Alert on failed renewals, and on expiry with a threshold below the normal renewal point, for example 14 days left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what else issues certificates.&lt;/strong&gt; The shorter authorization reuse period (10 days instead of 30) does not change a renewal every 42 days, since both periods have passed by then. It matters when you issue again 10 to 30 days after the last validation, for example a manual reissue or a renewal forced early: that order now needs a fresh challenge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse on staging before February.&lt;/strong&gt; Staging's &lt;code&gt;classic&lt;/code&gt; profile already issues 64-day certificates, and its &lt;code&gt;tlsserver&lt;/code&gt; profile issues 45-day ones. Issue a staging certificate with separate state from production and run your normal renewal and deploy path against it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this test does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One certificate per configuration.&lt;/strong&gt; We issued one 64-day certificate for each client setup, on one evening. certbot 5.8 picks a random time inside the window, so our probes show that it renews inside the window, not how its renewals spread across a fleet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ARI windows can move.&lt;/strong&gt; Let's Encrypt can change a certificate's window, for example to ask for early renewal before a mass revocation. Clients that check ARI on each run, as certbot 5.8 and acme.sh 3.1.6 do, pick up the change; we did not test that. We measured the normal window. The acme.sh 3.0.7 and certbot 2.9 rows would not change, because neither looks at ARI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;acme.sh 3.0.7 at 45 days comes from its source.&lt;/strong&gt; It cannot request the 45-day profile, so we could not issue one with it. The 64-day and 90-day runs confirm the formula.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lego was checked at issuance only.&lt;/strong&gt; We recorded when it said a renewal would be allowed, not a renewal at that time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five client installs.&lt;/strong&gt; We did not test Caddy, Traefik, cert-manager, win-acme or hosting panels that embed their own ACME code. Check those against the same two questions: does it use ARI, and if not, how does it decide?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging, not production.&lt;/strong&gt; Production should behave the same way from February, but we only issued staging certificates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The short version: in our tests, the clients with ARI enabled renewed on Let's Encrypt's window, and that window was the same share of the lifetime at 45, 64 and 90 days. If it does not, find out what number it renews on, because that number was chosen for 90 days.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/lets-encrypt-64-day-certificates-renewal-check" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>tls</category>
      <category>letsencrypt</category>
      <category>certbot</category>
    </item>
    <item>
      <title>Kubernetes RBAC Explained by Breaking It: Practice Roles and Bindings in Your Browser</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 14 Oct 2026 10:42:14 +0000</pubDate>
      <link>https://dev.to/devopsdaily/kubernetes-rbac-explained-by-breaking-it-practice-roles-and-bindings-in-your-browser-308a</link>
      <guid>https://dev.to/devopsdaily/kubernetes-rbac-explained-by-breaking-it-practice-roles-and-bindings-in-your-browser-308a</guid>
      <description>&lt;p&gt;Your CI service account needs to read pods. Someone writes a ClusterRole called &lt;code&gt;pod-reader&lt;/code&gt;, binds it with a RoleBinding in &lt;code&gt;dev&lt;/code&gt;, and it works. A month later the same job runs against &lt;code&gt;prod&lt;/code&gt; and gets &lt;code&gt;Forbidden&lt;/code&gt;. "But it's a ClusterRole," says everyone in the channel. The fix someone reaches for is a ClusterRoleBinding, and now the CI account can read pods in &lt;code&gt;kube-system&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/games/k8s-rbac-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%2F44pynjb4qg34822qze6s.png" alt="Kubernetes RBAC Simulator on DevOps Daily" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both surprises come from the same rule, and it is the one to learn first if you want Kubernetes RBAC explained in a way that sticks: the role says &lt;strong&gt;what&lt;/strong&gt;, the binding says &lt;strong&gt;where&lt;/strong&gt;. A ClusterRole is only cluster-wide if a ClusterRoleBinding makes it so.&lt;/p&gt;

&lt;p&gt;This post walks through RBAC by breaking it on purpose in the &lt;a href="https://devops-daily.com/games/k8s-rbac-simulator" rel="noopener noreferrer"&gt;Kubernetes RBAC Simulator&lt;/a&gt;. Disclosure: I help build it; it is free, runs in the browser, no signup, and needs no cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four objects, two questions
&lt;/h2&gt;

&lt;p&gt;RBAC has four object kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role&lt;/strong&gt;: a list of rules, living in one namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClusterRole&lt;/strong&gt;: a list of rules with no namespace. Reusable anywhere, and the only way to describe cluster-scoped resources such as &lt;code&gt;nodes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RoleBinding&lt;/strong&gt;: grants a Role or a ClusterRole to subjects (users, groups, service accounts) inside the binding's own namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClusterRoleBinding&lt;/strong&gt;: grants a ClusterRole in every namespace and on cluster-scoped resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rule is &lt;code&gt;apiGroups&lt;/code&gt; + &lt;code&gt;resources&lt;/code&gt; + &lt;code&gt;verbs&lt;/code&gt;, optionally narrowed by &lt;code&gt;resourceNames&lt;/code&gt;. And the line from the official docs that answers half of all RBAC questions: "Permissions are purely additive (there are no "deny" rules)."&lt;/p&gt;

&lt;p&gt;So every "can X do Y?" question comes down to: which bindings name X, and does any of them grant Y in this place?&lt;/p&gt;

&lt;h2&gt;
  
  
  How the simulator works
&lt;/h2&gt;

&lt;p&gt;The simulator has seven fixed scenarios. Each one shows its Roles and bindings as YAML in one pane. In the other you build a question: pick a subject, one of eight verbs (&lt;code&gt;get&lt;/code&gt; to &lt;code&gt;deletecollection&lt;/code&gt;), one of seven resources (&lt;code&gt;pods&lt;/code&gt; to &lt;code&gt;namespaces&lt;/code&gt;), a namespace (&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;prod&lt;/code&gt;, &lt;code&gt;kube-system&lt;/code&gt;, or cluster scope, which for a namespaced resource like pods means every namespace) and optionally a named object. The answer is &lt;strong&gt;Allowed&lt;/strong&gt; or &lt;strong&gt;Denied&lt;/strong&gt;, with a sentence naming the binding and role that granted it. Every binding that names the subject but did not grant the request is listed with the reason. Under that you get the full list of verbs the subject has on that resource, and a &lt;code&gt;kubectl auth can-i&lt;/code&gt; command for the same question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break it in seven moves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The ordinary case.&lt;/strong&gt; A Role and a RoleBinding, both in &lt;code&gt;dev&lt;/code&gt;. &lt;code&gt;list pods&lt;/code&gt; in &lt;code&gt;dev&lt;/code&gt; is allowed, "granted by RoleBinding/ci-reads-pods through Role/pod-reader, which applies to namespace dev." This is the model people then over-apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The one that surprises everyone.&lt;/strong&gt; Same service account, but the role is now a ClusterRole bound by a RoleBinding in &lt;code&gt;dev&lt;/code&gt;. The scenario asks about &lt;code&gt;prod&lt;/code&gt;, and the answer is Denied: the binding "binds ClusterRole/pod-reader, but a RoleBinding confines it to dev. The permissions are cluster-wide in definition and namespaced in effect." Switch the namespace to &lt;code&gt;dev&lt;/code&gt; and it is allowed. The official sample manifest says it in a comment: "The namespace of the RoleBinding determines where the permissions are granted."&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&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;RoleBinding&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;ci-reads-pods-in-dev&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;            &lt;span class="c1"&gt;# this line decides where&lt;/span&gt;
&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&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;ClusterRole&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;pod-reader&lt;/span&gt;
&lt;span class="na"&gt;subjects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;ServiceAccount&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;ci&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Nodes are not in a namespace.&lt;/strong&gt; A ClusterRole that allows &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;list&lt;/code&gt; on &lt;code&gt;nodes&lt;/code&gt;, bound with a RoleBinding. Denied. A namespaced binding can never grant a cluster-scoped resource, however broad the role is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. One secret, not the list.&lt;/strong&gt; A Role with &lt;code&gt;resourceNames: ["db-password"]&lt;/code&gt; and only the verb &lt;code&gt;get&lt;/code&gt;. &lt;code&gt;get secrets/db-password&lt;/code&gt; is allowed. Switch the verb to &lt;code&gt;list&lt;/code&gt; and it is denied, because the Role grants only &lt;code&gt;get&lt;/code&gt;. Clear the named-object field too and you have the question &lt;code&gt;kubectl get secrets&lt;/code&gt; asks: still denied, because a rule with &lt;code&gt;resourceNames&lt;/code&gt; does not cover a plain list. The docs add one exception: if a rule grants &lt;code&gt;list&lt;/code&gt; or &lt;code&gt;watch&lt;/code&gt; with &lt;code&gt;resourceNames&lt;/code&gt;, a request with a matching &lt;code&gt;metadata.name&lt;/code&gt; field selector is authorized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The silent typo.&lt;/strong&gt; The binding's &lt;code&gt;roleRef&lt;/code&gt; says &lt;code&gt;pod-readr&lt;/code&gt;. Denied, with the reason: the role does not exist, and "a dangling roleRef grants nothing and raises no error." Kubernetes skips the lookup for callers that hold the &lt;code&gt;bind&lt;/code&gt; verb or are in &lt;code&gt;system:masters&lt;/code&gt;, so a cluster admin can apply this binding without an error and no event fires. Anyone else gets &lt;code&gt;rolebindings.rbac.authorization.k8s.io "pod-readr" not found&lt;/code&gt; when they create it. The silence ends at request time: on a real API server, the service account's Forbidden error ends with &lt;code&gt;RBAC: role.rbac.authorization.k8s.io "pod-readr" not found&lt;/code&gt;, and &lt;code&gt;kubectl auth can-i&lt;/code&gt; prints the same reason after its &lt;code&gt;no&lt;/code&gt;. Read the whole error. And &lt;code&gt;roleRef&lt;/code&gt; is immutable after creation, so the fix is delete and recreate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. There is no deny.&lt;/strong&gt; Two bindings, one to &lt;code&gt;pod-reader&lt;/code&gt; and one to an empty Role called &lt;code&gt;read-only-audit&lt;/code&gt;. The empty Role takes nothing away. If you are looking for the rule that revoked access, there is not one; look for the grant that is missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Genuinely cluster-wide.&lt;/strong&gt; The same ClusterRole, now bound with a ClusterRoleBinding. &lt;code&gt;list pods&lt;/code&gt; in &lt;code&gt;kube-system&lt;/code&gt; is allowed. This is the one to be careful with, and the one from the opening story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission you did not mean to grant
&lt;/h2&gt;

&lt;p&gt;The simulator answers "who can do what, and why". It does not model escalation paths, and those are where a harmless-looking grant turns into much more. The Kubernetes &lt;a href="https://kubernetes.io/docs/concepts/security/rbac-good-practices/" rel="noopener noreferrer"&gt;RBAC good practices&lt;/a&gt; page lists them. Three to check in every review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;list&lt;/code&gt; or &lt;code&gt;watch&lt;/code&gt; on Secrets.&lt;/strong&gt; The docs say it plainly: these "effectively allow for users to reveal the Secret contents". A list response includes the data. A role that "only lists" secrets can read them all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creating Pods, or anything that creates Pods.&lt;/strong&gt; A Pod can mount Secrets, ConfigMaps and volumes from its namespace and run as any service account there. In the docs' words, permission to create workloads "implicitly grants access to many other resources in that namespace", and that includes &lt;code&gt;create&lt;/code&gt; on Deployments or Jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;escalate&lt;/code&gt;, &lt;code&gt;bind&lt;/code&gt; and &lt;code&gt;impersonate&lt;/code&gt; verbs.&lt;/strong&gt; The first two bypass the checks that stop you granting more than you hold. &lt;code&gt;impersonate&lt;/code&gt; lets you act as someone else. None of them appear in the simulator's verb list, so check them by reading your roles, including any wildcard &lt;code&gt;*&lt;/code&gt; in &lt;code&gt;verbs&lt;/code&gt; or &lt;code&gt;resources&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The built-in &lt;code&gt;edit&lt;/code&gt; ClusterRole already holds two of these risks: read on Secrets, and &lt;code&gt;impersonate&lt;/code&gt; on serviceaccounts. The docs say it "can be used to gain the API access levels of any ServiceAccount in the namespace." Bind it with a ClusterRoleBinding and that is every namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask a real cluster
&lt;/h2&gt;

&lt;p&gt;Every answer in the simulator comes with a &lt;code&gt;can-i&lt;/code&gt; command to try. These two are the ones to remember, adapted from the examples in &lt;code&gt;kubectl auth can-i --help&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="c"&gt;# one question, asked as the service account&lt;/span&gt;
kubectl auth can-i list pods &lt;span class="nt"&gt;-n&lt;/span&gt; prod &lt;span class="nt"&gt;--as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;system:serviceaccount:dev:ci

&lt;span class="c"&gt;# everything that account can do in a namespace&lt;/span&gt;
kubectl auth can-i &lt;span class="nt"&gt;--list&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; dev &lt;span class="nt"&gt;--as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;system:serviceaccount:dev:ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--as&lt;/code&gt; is impersonation, so you need permission to impersonate, which usually means a cluster admin. Also note the &lt;code&gt;-n&lt;/code&gt;: without it, &lt;code&gt;can-i&lt;/code&gt; answers for your current namespace, which may be a different question from the one you meant. For a single check across every namespace, use &lt;code&gt;--all-namespaces&lt;/code&gt;; &lt;code&gt;--list&lt;/code&gt; refuses that flag.&lt;/p&gt;

&lt;p&gt;One trap for cluster-scoped resources: &lt;code&gt;-n&lt;/code&gt; still goes into the question. I rebuilt move 3 on a real kube-apiserver v1.37.1. &lt;code&gt;kubectl auth can-i list nodes -n dev --as=system:serviceaccount:dev:ci&lt;/code&gt; printed &lt;code&gt;Warning: resource 'nodes' is not namespace scoped&lt;/code&gt;, then &lt;code&gt;yes&lt;/code&gt;. As the same account, &lt;code&gt;kubectl get nodes&lt;/code&gt; got Forbidden "at the cluster scope". With &lt;code&gt;--all-namespaces&lt;/code&gt; instead of &lt;code&gt;-n dev&lt;/code&gt;, can-i says &lt;code&gt;no&lt;/code&gt;. For nodes and other cluster-scoped resources, ask with &lt;code&gt;--all-namespaces&lt;/code&gt;. On that server, the real requests in all seven moves got the same verdicts as the simulator gives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Run the seven scenarios in order, and in each one change one field at a time: namespace, verb, then resource. Watching a verdict flip is faster than reading about it. The simulator is one of &lt;a href="https://devops-daily.com/games" rel="noopener noreferrer"&gt;50+ free DevOps games and simulators&lt;/a&gt;. The primary sources are the Kubernetes &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/" rel="noopener noreferrer"&gt;RBAC authorization reference&lt;/a&gt; and the good practices page linked above.&lt;/p&gt;

&lt;p&gt;What is the most surprising permission you have found granted in a cluster? Tell us in the comments.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
