<?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%2F3b4f7f10-38d4-4f4f-8351-1dcb0c1bdfc7.png</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>What Happens When You Type a URL: Answer It With Three Interactive Simulators</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 08 Sep 2026 16:43:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/what-happens-when-you-type-a-url-answer-it-with-three-interactive-simulators-44c0</link>
      <guid>https://dev.to/devopsdaily/what-happens-when-you-type-a-url-answer-it-with-three-interactive-simulators-44c0</guid>
      <description>&lt;p&gt;"What happens when you type a URL and press enter?" is the most reused interview question in infrastructure, and for good reason: the honest answer touches DNS, TCP, TLS, CDNs, load balancers and application servers in one breath. It packs a whole networking curriculum into one question.&lt;/p&gt;

&lt;p&gt;Most people prepare for it by reading one of the famous write-ups. That produces a memorized list. The version that survives interview follow-up questions ("and what if the cache is cold?", "why does the first request cost more?") comes from having watched the steps happen and poked at them. Below is a way to build that version in an evening, using three free browser simulators, each covering one chunk of the story. Disclosure up front: I help build these; genuinely good alternatives are linked at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunk 1: the name becomes an address
&lt;/h2&gt;

&lt;p&gt;Everything starts with DNS, and DNS is where memorized answers are weakest. Most people can say "the domain gets resolved"; far fewer can say who is asked, in what order, and what happens at each hop.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/dns-simulator" rel="noopener noreferrer"&gt;DNS Simulator&lt;/a&gt; steps through the resolution story interactively, on a simplified model with its own records: browser cache first, then the OS cache, then your configured resolver, and if the resolver has nothing cached, the full walk: root server, TLD server, authoritative server. Each step shows what is asked and what comes back. It is a model, not live DNS, which is the point: the walk is slowed down enough to read.&lt;/p&gt;

&lt;p&gt;The three facts to take away from playing with it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Caches answer first.&lt;/strong&gt; The browser and OS caches can resolve the name before any network question is asked, which is why the "full" DNS story you memorized often does not happen, and why cache TTLs are what stand between you and a propagated DNS change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The resolver does the walking, not you.&lt;/strong&gt; Your machine asks one question; the recursive resolver deals with root, TLD and authoritative servers on your behalf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One machine answers many questions.&lt;/strong&gt; Replaying the walk with different record types (A, AAAA, CNAME, MX, TXT) shows the same machinery serving addresses, aliases, mail routing and ownership proofs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Chunk 2: the address becomes a connection
&lt;/h2&gt;

&lt;p&gt;With an IP in hand, the browser opens a TCP connection, and here interviews love the follow-up: "why does TCP need a handshake and UDP does not?"&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/tcp-vs-udp" rel="noopener noreferrer"&gt;TCP vs UDP simulator&lt;/a&gt; answers it by letting you break the network. You watch the SYN, SYN-ACK, ACK exchange establish a TCP connection, then flip on packet loss (the simulator randomly destroys packets in transit) and watch what each protocol does about it. In the model, TCP notices the hole and retransmits the lost data; UDP keeps firing and the lost datagrams just never arrive. The counters keep score: packets sent, received, and the retries TCP needed. It is a simplified model of retransmission, not a full TCP stack, but the asymmetry it shows is the real one.&lt;/p&gt;

&lt;p&gt;After that, the interview answer is a tradeoff you have watched instead of a definition: TCP gives you an ordered, reliable byte stream at the price of handshakes and retransmissions; UDP strips out connection setup and recovery entirely, and anything that vanishes is upstream's problem. Which is also the setup for the modern footnote worth knowing: HTTP/3 runs over QUIC, which is built on UDP precisely to escape some of TCP's costs while rebuilding the guarantees it still needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunk 3: the connection becomes a response
&lt;/h2&gt;

&lt;p&gt;The last chunk is everything between "connected" and "response received", and it is the part most answers hand-wave: where TLS happens, what a CDN actually short-circuits, and which hops a request pays for on the way to the application.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/games/packet-journey" rel="noopener noreferrer"&gt;Packet Journey&lt;/a&gt; animates the full path: browser, DNS resolution, TCP handshake, TLS handshake, CDN edge, load balancer, web server, application, database. The reason it teaches more than a diagram is its four scenarios, which you can run back to back:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Basic HTTP&lt;/strong&gt;: the naive path, no encryption, straight to origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS&lt;/strong&gt;: same path plus the TLS handshake, so you see where TLS enters the sequence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS + CDN&lt;/strong&gt;: the request detours to an edge node, and on a miss still travels to origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS + CDN (cached)&lt;/strong&gt;: the edge answers directly, and the origin, load balancer, application and database never hear about it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Watching scenarios 3 and 4 side by side is the fastest way to internalize what "cache hit ratio" actually buys: not a faster server, but entire hops that stop existing for most requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assembling the interview answer
&lt;/h2&gt;

&lt;p&gt;After the three simulators, the answer is no longer a memorized list; it is a story you can enter at any point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The browser checks its own DNS cache, then the OS's; on a miss the recursive resolver walks root, TLD and authoritative servers. With an IP, TCP's three-way handshake establishes a connection, TLS negotiates encryption on top, and the HTTP request goes out. If the site uses a CDN, an edge node may answer from cache and the origin never sees the request; otherwise it flows through load balancer, web server and application, often to a database and back.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The difference is you can now survive the follow-ups, because for each stage you have seen the failure mode: the cold cache, the lost packet, the CDN miss.&lt;/p&gt;

&lt;p&gt;The three simulators are part of &lt;a href="https://devops-daily.com/games" rel="noopener noreferrer"&gt;50+ free DevOps games and simulators&lt;/a&gt;, all browser-based, no signup. For going deeper on the same material: Julia Evans' &lt;a href="https://messwithdns.net/" rel="noopener noreferrer"&gt;Mess with DNS&lt;/a&gt; gives you a real subdomain to experiment on, and the classic &lt;a href="https://github.com/alex/what-happens-when" rel="noopener noreferrer"&gt;what-happens-when&lt;/a&gt; repo is the encyclopedic written version of this whole question.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>dns</category>
      <category>tcp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Who Owns the State File, and Other Questions That Decide Your Week</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/who-owns-the-state-file-and-other-questions-that-decide-your-week-2cpe</link>
      <guid>https://dev.to/devopsdaily/who-owns-the-state-file-and-other-questions-that-decide-your-week-2cpe</guid>
      <description>&lt;p&gt;The Terraform incidents that eat a week rarely start with a bad resource block. They start with a question nobody answered early: two people ran &lt;code&gt;apply&lt;/code&gt; against the same state at the same time; production and a sandbox share one state file and someone ran &lt;code&gt;destroy&lt;/code&gt; in the wrong directory; a security group was edited in the console in March and nobody noticed until a plan in June wanted to "fix" it; a plan with 40 destroys got applied because the review looked at the HCL diff and not at the plan.&lt;/p&gt;

&lt;p&gt;Each of those is a state question, not a syntax question. This post walks through the four that matter: who owns the state file, how it is split, how you detect drift, and how a plan gets reviewed. For the drift part you get a real run with the configuration to reproduce it. Along the way it names the tools built for each problem.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One writer per state file.&lt;/strong&gt; A remote backend with locking is the floor. On S3 that now means &lt;code&gt;use_lockfile = true&lt;/code&gt;; the DynamoDB lock table is legacy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split state by ownership and failure domain&lt;/strong&gt; , not by convenience. Per environment always; per component when different teams or different lifecycles share a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift is normal.&lt;/strong&gt; Run &lt;code&gt;terraform plan -detailed-exitcode&lt;/code&gt; on a schedule and treat exit code 2 as "something changed, go look". Use &lt;code&gt;-refresh-only&lt;/code&gt; to record what you observed, then fix code or lifecycle rules so the next plan agrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review the plan, not the diff.&lt;/strong&gt; The plan output is the artifact that changes infrastructure. Put it on the pull request, and make the apply run against a plan someone approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State is sensitive.&lt;/strong&gt; It contains attribute values, including things you did not think of as secrets. Encrypt it, restrict who can read it, and use ephemeral values and write-only arguments to keep secrets out entirely.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Terraform 1.10 or newer. The examples were run with 1.15.8; &lt;code&gt;use_lockfile&lt;/code&gt; needs 1.10+, write-only arguments need 1.11+.&lt;/li&gt;
&lt;li&gt;An AWS account if you want to reproduce the S3 backend section. The drift demo runs locally with the &lt;code&gt;hashicorp/local&lt;/code&gt; provider, version 2.9.0.&lt;/li&gt;
&lt;li&gt;A CI system that can run on pull requests. The examples use GitHub Actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Question 1: who is allowed to write the state file?
&lt;/h2&gt;

&lt;p&gt;State is the map between your HCL and real resource IDs. Lose it and Terraform believes nothing exists. Corrupt it with two concurrent writes and Terraform believes the wrong things exist, which is worse. So the first decision is ownership: exactly one process may write a given state file at a time, and every human and pipeline goes through the same lock.&lt;/p&gt;

&lt;p&gt;The local backend does lock. It takes an OS-level lock on &lt;code&gt;terraform.tfstate&lt;/code&gt; while a command runs, so two commands in the same directory on the same machine cannot collide. What it cannot do is coordinate independent copies: your laptop, a colleague's laptop and a CI runner each have their own file and their own lock. The moment a second person or a pipeline touches the same resources, you have two states and no shared lock.&lt;/p&gt;

&lt;p&gt;A remote backend fixes the "where" and shared locking fixes the "one at a time". On AWS the current setup is S3 with native locking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme-terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform/network/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# S3-native lock, Terraform 1.10+&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;use_lockfile&lt;/code&gt; writes a &lt;code&gt;.tflock&lt;/code&gt; object next to the state with a conditional PUT (the write succeeds only if the object does not exist yet), so a second writer gets a lock error right away by default. Pass &lt;code&gt;-lock-timeout=5m&lt;/code&gt; and Terraform retries for that long instead. Before 1.10 the S3 backend worked without any lock; if you wanted one you added a DynamoDB table (&lt;code&gt;dynamodb_table = "terraform-locks"&lt;/code&gt;). That option still works but is deprecated, and new projects should not add the table.&lt;/p&gt;

&lt;p&gt;What the bucket and the IAM role need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Versioning on.&lt;/strong&gt; Every apply that changes state writes a new object version, and the previous version is your recovery when state is damaged. Each version is a full copy and is billed as one, so add a lifecycle rule that expires noncurrent versions after a period set by your recovery, audit and cost requirements rather than keeping every version forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions for the lock file.&lt;/strong&gt; The role needs &lt;code&gt;s3:GetObject&lt;/code&gt;, &lt;code&gt;s3:PutObject&lt;/code&gt; and &lt;code&gt;s3:DeleteObject&lt;/code&gt; on &lt;code&gt;&amp;lt;state key&amp;gt;.tflock&lt;/code&gt;. The state object itself needs &lt;code&gt;GetObject&lt;/code&gt; and &lt;code&gt;PutObject&lt;/code&gt; only; Terraform never deletes it. Both need &lt;code&gt;s3:ListBucket&lt;/code&gt; on the bucket, restricted with an &lt;code&gt;s3:prefix&lt;/code&gt; condition to the team's state keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bucket policy scoped per state key.&lt;/strong&gt; The network team's role can read and write &lt;code&gt;platform/network/*&lt;/code&gt;; the app team's role can write only &lt;code&gt;apps/checkout/*&lt;/code&gt;. State files are where over-broad IAM turns into an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption with a customer-managed key&lt;/strong&gt; if compliance asks who can decrypt state. Default SSE-S3 is fine for most teams; the point is that state is not a public artifact.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Three different things protect you here, and it helps to keep them apart. The &lt;strong&gt;lock&lt;/strong&gt; stops two writers running at once. A &lt;strong&gt;saved plan&lt;/strong&gt; (question 4) stops a stale plan from applying: &lt;code&gt;terraform apply tfplan&lt;/code&gt; refuses if the state changed after the plan was made, whoever changed it. Neither one notices a change made &lt;strong&gt;outside Terraform&lt;/strong&gt; that never touched state; that is what drift detection (question 3) is for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same shape exists on every cloud (Azure Blob with lease-based locking, GCS with native locking). The hosted platforms take the decision away from you: HCP Terraform, Spacelift and env0 put every run behind their own queue, so there is one serialized writer per stack by construction. HCP Terraform also hosts the state; Spacelift and env0 can hold it for you or work against a backend you already own. Digger is different in kind: it runs Terraform inside your existing CI with your backend, and coordinates pull request locks and plan caching from its own component. More on that split in question 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: how is state split?
&lt;/h2&gt;

&lt;p&gt;One state file for everything works until the day a plan runs for eleven minutes and a three-line change proposes destroying something you did not touch. That happens because of dependencies, not bad luck: change an attribute that forces replacement on a subnet, and every resource that references the subnet is re-evaluated, and depending on its schema may be updated in place or replaced too.&lt;/p&gt;

&lt;p&gt;The unit of state is the unit of blast radius. Two rules of thumb:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never share state across environments.&lt;/strong&gt; &lt;code&gt;prod&lt;/code&gt; and &lt;code&gt;staging&lt;/code&gt; in one file means every staging experiment refreshes and plans production, and a &lt;code&gt;destroy&lt;/code&gt; in the wrong place takes both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split by owner and by lifecycle.&lt;/strong&gt; Networking and IAM change monthly and belong to a platform team. Application infrastructure changes daily and belongs to product teams. Different owners, different permissions, different rate of change: different state files. Plan duration is a symptom of getting this wrong, not the rule for splitting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A layout that holds up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;infra/
  platform/
    network/ # VPCs, subnets, peering. Own state.
    iam/ # Roles and policies. Own state.
    clusters/ # EKS, node groups. Own state, reads network values.
  apps/
    checkout/ # Per-app resources: queues, buckets, RDS. Own state per env.
      prod/
      staging/
    search/
      prod/
      staging/

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

&lt;/div&gt;



&lt;p&gt;Each leaf directory has its own backend key. How the leaves share values is a security decision in itself. The &lt;code&gt;terraform_remote_state&lt;/code&gt; data source is the obvious tool, but to read one output it downloads the &lt;strong&gt;whole&lt;/strong&gt; source state, so the consumer role needs read access to everything in that file, including attribute values you would rather not hand to every app team. Two safer patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider data sources.&lt;/strong&gt; Look the value up from the cloud API by name or tag (&lt;code&gt;data "aws_vpc"&lt;/code&gt;, &lt;code&gt;data "aws_iam_openid_connect_provider"&lt;/code&gt;). The consumer needs read permission on that resource, not on the platform team's state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish selected outputs&lt;/strong&gt; to a store built for sharing: SSM Parameter Store, a DNS record, a small "exports" configuration. The producer writes exactly what it wants to share; consumers read that.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# platform/clusters: publish what apps are allowed to know&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"oidc_provider_arn"&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;"/platform/clusters/prod/oidc_provider_arn"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"String"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# apps/checkout/prod: read it without touching platform state&lt;/span&gt;
&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"oidc_provider_arn"&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;"/platform/clusters/prod/oidc_provider_arn"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;terraform_remote_state&lt;/code&gt; is still fine between stacks owned by the same team with the same trust level. Use it knowingly.&lt;/p&gt;

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

&lt;p&gt;Workspaces are not environment isolation. &lt;code&gt;terraform workspace&lt;/code&gt; switches between state files under the same backend prefix with the same credentials and the same code. That is fine for short-lived per-branch copies of a stack. It is not fine as the boundary between staging and production, because nothing stops a &lt;code&gt;-destroy&lt;/code&gt; in the wrong workspace except attention.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The cost of splitting is orchestration: when the network stack changes, dependents need a plan too. You need three things whatever you build it with: an order to run stacks in, a way to pass values between them, and a way to see that a downstream stack has not been planned since its upstream changed. Terragrunt models this with &lt;code&gt;dependency&lt;/code&gt; blocks on the plain CLI; a CI pipeline with explicit job dependencies does it for small graphs; Spacelift stack dependencies and env0 workflows do it as a hosted feature with output passing built in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: how do you find out state no longer matches reality?
&lt;/h2&gt;

&lt;p&gt;Two things get called drift, and they need different responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration drift&lt;/strong&gt; is the gap between what your code declares and what actually exists. Someone widened a security group in the console at 3 a.m.; the code still says the old range. The next plan will propose to close it again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State drift&lt;/strong&gt; is the gap between what the state file recorded and what the provider API now returns. The resource is fine and matches the code, but state has old attribute values because they changed outside Terraform. A refresh fixes state without touching the resource.&lt;/p&gt;

&lt;p&gt;Terraform surfaces both at the same moment: when it refreshes during a plan. Which means you only find out when someone runs a plan, and for a quiet stack that can be weeks.&lt;/p&gt;

&lt;p&gt;Here is what it looks like from Terraform's side, run for real with the &lt;code&gt;local&lt;/code&gt; provider so you can reproduce it without a cloud account. The full configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.tf&lt;/span&gt;
&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2.9.0"&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"local_file"&lt;/span&gt; &lt;span class="s2"&gt;"app_config"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;filename&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;module}&lt;/span&gt;&lt;span class="s2"&gt;/out/app.env"&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LOG_LEVEL=info&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;WORKERS=4&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;file_permission&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0644"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"local_file"&lt;/span&gt; &lt;span class="s2"&gt;"feature_flags"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;filename&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;module}&lt;/span&gt;&lt;span class="s2"&gt;/out/flags.json"&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;new_checkout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dark_mode&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="nx"&gt;file_permission&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0644"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Apply it, then edit one file by hand and delete the other, then plan again. The transcript below is abridged (the provider prints six hash attributes per resource that add nothing here); the commands, messages and exit code are as they ran with Terraform 1.15.8:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;drift demo&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
&lt;span class="go"&gt;local_file.feature_flags: Creation complete after 0s [id=497bf222e1c3c415669ba709d62873551fd34315]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;someone edits one file by hand and deletes the other
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'LOG_LEVEL=debug\nWORKERS=4\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out/app.env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm &lt;/span&gt;out/flags.json
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan &lt;span class="nt"&gt;-detailed-exitcode&lt;/span&gt;
&lt;span class="go"&gt;local_file.app_config: Refreshing state... [id=7a5c3ff122fe7ec3ef80d88617b257d9a79ed359]
local_file.feature_flags: Refreshing state... [id=497bf222e1c3c415669ba709d62873551fd34315]

Terraform will perform the following actions:

&lt;/span&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;local_file.app_config will be created
&lt;span class="go"&gt;  + resource "local_file" "app_config" {
      + content = &amp;lt;&amp;lt;-EOT
            LOG_LEVEL=info
            WORKERS=4
        EOT
      + filename = "./out/app.env"
    }

&lt;/span&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;local_file.feature_flags will be created
&lt;span class="go"&gt;  + resource "local_file" "feature_flags" {
      + filename = "./out/flags.json"
    }

Plan: 2 to add, 0 to change, 0 to destroy.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;2

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

&lt;/div&gt;



&lt;p&gt;Two things worth reading closely.&lt;/p&gt;

&lt;p&gt;First, the exit code. &lt;code&gt;-detailed-exitcode&lt;/code&gt; returns 0 for an empty plan, 1 for an error and 2 for a successful plan with changes. Exit code 2 is a &lt;strong&gt;change signal&lt;/strong&gt; , not a drift verdict: it also fires for code that was merged and never applied, for a variable that changed, or for a provider upgrade that added a default. It becomes a drift detector only when you run it against a stack whose code was fully applied and whose inputs are pinned, so that the only remaining cause of a non-empty plan is the world moving. Even then, a data source that resolved to a new value produces a plan without anyone touching the infrastructure. So treat a scheduled plan as a &lt;strong&gt;change check&lt;/strong&gt; : it tells you a stack would change if applied, and a person classifies why. The hosted platforms' drift detection runs on the same signal and adds the classification for you by comparing refreshed state with the last applied configuration.&lt;/p&gt;

&lt;p&gt;Second, what the plan wants to do. The hand-edited file shows up as "will be created" with the original &lt;code&gt;LOG_LEVEL=info&lt;/code&gt;. That is a quirk of this provider: &lt;code&gt;local_file&lt;/code&gt; identifies a resource by the hash of its content, so a changed file looks like a missing one. A cloud provider would show the same situation as an in-place update (&lt;code&gt;~ ingress { ... }&lt;/code&gt;). Either way the plan is proposing to &lt;strong&gt;undo&lt;/strong&gt; the manual change, and whether that is right depends on why the change was made. Terraform cannot know.&lt;/p&gt;

&lt;p&gt;You have two honest ways to resolve it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reality was wrong, code is right.&lt;/strong&gt; Apply the plan. The on-call widening gets closed again, and if it was needed, it gets re-added in code where it survives the next apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reality is right, code is stale.&lt;/strong&gt; Change the code to match, then confirm with a plan that shows no changes. Along the way, a refresh-only apply records what Terraform observed into state without touching any resource:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;recording what changed (abridged)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform apply &lt;span class="nt"&gt;-refresh-only&lt;/span&gt; &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
&lt;span class="go"&gt;Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

&lt;/span&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;local_file.app_config has been deleted
&lt;span class="go"&gt;  - resource "local_file" "app_config" {
      - content = &amp;lt;&amp;lt;-EOT
            LOG_LEVEL=info
            WORKERS=4
&lt;/span&gt;&lt;span class="gp"&gt;        EOT -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;null
&lt;span class="gp"&gt;      - filename = "./out/app.env" -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;null
&lt;span class="go"&gt;    }

&lt;/span&gt;&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;local_file.feature_flags has been deleted
&lt;span class="go"&gt;  - resource "local_file" "feature_flags" {
&lt;/span&gt;&lt;span class="gp"&gt;      - filename = "./out/flags.json" -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;null
&lt;span class="go"&gt;    }
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform state list
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;state holds no bindings now. out/app.env still exists on disk with the hand edit&lt;span class="p"&gt;;&lt;/span&gt; the code still declares both files, so the next plan creates flags.json and overwrites app.env.
&lt;span class="go"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the point about refresh-only: it makes state describe what Terraform saw, and nothing else. If the code still demands the old value, the next normal plan will bring it back. Refresh-only is the first half of accepting a change; editing the code, or telling Terraform to stop reconciling that attribute, is the second half:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group"&lt;/span&gt; &lt;span class="s2"&gt;"web"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;desired_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ignore_changes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;desired_capacity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# the autoscaler owns this now&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ignore_changes&lt;/code&gt; does not stop Terraform from refreshing and recording the attribute. It stops Terraform from planning an update when that attribute differs from the code, which is what you want for values another system legitimately controls.&lt;/p&gt;

&lt;p&gt;A change check that runs on a schedule:&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;# .github/workflows/change-check.yml&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;change-check&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;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="s2"&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-5"&lt;/span&gt; &lt;span class="c1"&gt;# weekday mornings, before people start applying&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;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;platform/network&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;platform/clusters&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;apps/checkout/prod&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# OIDC to AWS&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;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# to open or update the issue for the stack&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.15.8&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;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# read-only on infrastructure, plus get/put/delete on the .tflock object&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-plan&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform -chdir=infra/${{ matrix.stack }} init -input=false&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;plan&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set +e&lt;/span&gt;
          &lt;span class="s"&gt;terraform -chdir=infra/${{ matrix.stack }} plan -detailed-exitcode -input=false -lock-timeout=2m -no-color &amp;gt; plan.txt&lt;/span&gt;
          &lt;span class="s"&gt;code=$?&lt;/span&gt;
          &lt;span class="s"&gt;set -e&lt;/span&gt;
          &lt;span class="s"&gt;echo "code=$code" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;# 0 and 2 are answers; anything else is a broken check and must fail loudly&lt;/span&gt;
          &lt;span class="s"&gt;if ["$code" != "0"] &amp;amp;&amp;amp; ["$code" != "2"]; then cat plan.txt; exit "$code"; fi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.plan.outputs.code == '2'&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;open or update the issue for this stack&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
          &lt;span class="na"&gt;STACK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ matrix.stack }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;existing=$(gh issue list --label plan-changes --state open --search "in:title \"Plan changes: $STACK\"" --json number -q '.[0].number')&lt;/span&gt;
          &lt;span class="s"&gt;if [-n "$existing"]; then&lt;/span&gt;
            &lt;span class="s"&gt;gh issue comment "$existing" --body-file plan.txt&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;gh issue create --title "Plan changes: $STACK" --body-file plan.txt --label plan-changes&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two details in there are deliberate. The step fails on any exit code other than 0 or 2, so expired credentials or a broken backend cannot produce a green run that quietly stops checking. The issue says "plan changes", not "drift", because the person who opens it has to classify the cause. And the plan takes the lock with a short timeout rather than running with &lt;code&gt;-lock=false&lt;/code&gt;; skipping the lock would let the check read state while an apply is halfway through writing it, and a drift report against a half-applied state is noise. If the morning window collides with real applies, move the schedule or accept the two-minute wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: how does a plan get reviewed?
&lt;/h2&gt;

&lt;p&gt;Code review on Terraform has a specific failure mode: reviewers read the HCL diff, which looks small, and approve. Then &lt;code&gt;apply&lt;/code&gt; runs and the plan they never saw replaces a subnet, and the resources that depend on it get updated or replaced behind it. The HCL diff was three lines. The plan was 40 destroys.&lt;/p&gt;

&lt;p&gt;The plan is the artifact that changes infrastructure, so the plan is what needs review. The workflow that follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pull request&lt;/strong&gt; HCL change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;terraform plan&lt;/strong&gt; locked, saved to a file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan on the PR&lt;/strong&gt; summary + full output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval&lt;/strong&gt; of the plan, not the diff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply&lt;/strong&gt; the approved plan file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The detail that makes it safe is &lt;strong&gt;apply the saved plan&lt;/strong&gt;. &lt;code&gt;terraform plan -out=tfplan&lt;/code&gt; writes a plan file that records the planned actions together with the state it was computed from, the configuration, the provider versions and the input values. &lt;code&gt;terraform apply tfplan&lt;/code&gt; refuses to run if the state has moved since. So what was approved is what applies, or nothing applies. Two limits to keep in mind: values that were unknown at plan time are still resolved at apply time, and the plan file does not know about a change made outside Terraform after the plan ran. It also contains sensitive values in clear text, so a stored plan needs the same access controls as state.&lt;/p&gt;

&lt;p&gt;Doing this well with plain GitHub Actions is harder than it looks, and the hard part is exactly "apply the plan that was reviewed". A plan produced on the pull request lives in the pull request's workflow run; the merge to &lt;code&gt;main&lt;/code&gt; is a different run, with a different commit (the PR ran against a synthetic merge commit, &lt;code&gt;main&lt;/code&gt; now has a squash or merge commit), and &lt;code&gt;download-artifact&lt;/code&gt; only sees artifacts from its own run unless you hand it a token and the originating run ID. Teams that push through this end up storing the plan somewhere addressable (S3 keyed by PR number and head SHA), verifying at apply time that the merged tree matches the tree that was planned, and re-planning as a fallback. That is a project, not a snippet.&lt;/p&gt;

&lt;p&gt;The version below is honest about that: it reviews the plan on the pull request, and on merge it plans again in an ungated job, then applies &lt;strong&gt;that&lt;/strong&gt; plan from a gated job. The order matters: GitHub evaluates an environment's protection rules before the job starts, so a gated job that runs the plan itself would ask for approval of a plan that does not exist yet. Planning first and gating only the apply gives the approver the actual plan to read.&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;# .github/workflows/terraform.yml&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infra/apps/checkout/prod/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infra/apps/checkout/prod/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# One running and at most one waiting run per stack; a newer waiting run replaces an older one.&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tf-checkout-prod&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;TF_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.15.8&lt;/span&gt;
  &lt;span class="na"&gt;STACK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;infra/apps/checkout/prod&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;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'pull_request'&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# to post the plan comment&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.TF_VERSION&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&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;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-plan&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform -chdir=$STACK init -input=false&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;plan&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -o pipefail&lt;/span&gt;
          &lt;span class="s"&gt;terraform -chdir=$STACK plan -input=false -lock-timeout=2m -no-color | tee plan.txt&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;post the plan on the pull request&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;echo "### Plan for apps/checkout/prod"&lt;/span&gt;
            &lt;span class="s"&gt;grep -E "^Plan:|^No changes" plan.txt || true&lt;/span&gt;
            &lt;span class="s"&gt;echo&lt;/span&gt;
            &lt;span class="s"&gt;echo "&amp;lt;details&amp;gt;&amp;lt;summary&amp;gt;Full plan&amp;lt;/summary&amp;gt;"&lt;/span&gt;
            &lt;span class="s"&gt;echo&lt;/span&gt;
            &lt;span class="s"&gt;echo '```&lt;/span&gt;
&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt; &lt;span class="nv"&gt;endraw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="s1"&gt;'&lt;/span&gt;
            &lt;span class="s"&gt;cat&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;plan.txt&lt;/span&gt;
            &lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="err"&gt;%&lt;/span&gt; &lt;span class="nv"&gt;raw %&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
            &lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;/details&amp;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;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;comment.md&lt;/span&gt;
          &lt;span class="s"&gt;gh&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pr&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;comment&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;github.event.pull_request.number&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;--body-file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;comment.md&lt;/span&gt;

  &lt;span class="s"&gt;plan-for-apply:&lt;/span&gt;
    &lt;span class="s"&gt;if:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.event_name&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;'push'&lt;/span&gt;
    &lt;span class="s"&gt;runs-on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="s"&gt;permissions&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.TF_VERSION&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&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;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-plan&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform -chdir=$STACK init -input=false&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;plan&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -o pipefail&lt;/span&gt;
          &lt;span class="s"&gt;terraform -chdir=$STACK plan -input=false -lock-timeout=5m -no-color -out=tfplan | tee plan.txt&lt;/span&gt;
          &lt;span class="s"&gt;{ echo "### Plan waiting for approval"; grep -E "^Plan:|^No changes" plan.txt || true; } &amp;gt;&amp;gt; "$GITHUB_STEP_SUMMARY"&lt;/span&gt;
      &lt;span class="c1"&gt;# The plan file holds sensitive values and backend details: same run only, short retention.&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/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfplan&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;${{ env.STACK }}/tfplan&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="err"&gt;  &lt;/span&gt;&lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan-for-apply&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# The environment's protection rules (required reviewers, prevent self-review,&lt;/span&gt;
    &lt;span class="c1"&gt;# deployment branch = main) are configured in the repository settings; naming&lt;/span&gt;
    &lt;span class="c1"&gt;# it here only opts the job in. The approver reads the plan job's summary&lt;/span&gt;
    &lt;span class="c1"&gt;# and full log before approving.&lt;/span&gt;
    &lt;span class="na"&gt;environment&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.TF_VERSION&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&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;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-apply&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform -chdir=$STACK init -input=false&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/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;tfplan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;env.STACK&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apply the approved plan&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;terraform -chdir=$STACK apply -input=false tfplan&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;What this buys you: the plan is on the pull request where the reviewer is, the summary line (&lt;code&gt;Plan: 1 to add, 0 to change, 3 to destroy&lt;/code&gt;) is visible without expanding anything, the apply job applies exactly the plan file the previous job produced (same run, so &lt;code&gt;download-artifact&lt;/code&gt; finds it), the same Terraform version runs everywhere, and the environment gate puts a human in front of the real apply plan. What it does not buy you: a guarantee that the plan on the pull request and the plan at apply are the same. If someone merged another change to the same stack in between, the apply plan will differ, and the environment approver is the only one who sees it.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;permissions&lt;/code&gt; blocks: once you set any permission on a job, everything you did not list is off, so the plan job needs &lt;code&gt;pull-requests: write&lt;/code&gt; for the comment and both jobs need &lt;code&gt;id-token: write&lt;/code&gt; for OIDC. Pull requests from forks get a read-only token and cannot post comments; keep infrastructure repos to branches in the same repository.&lt;/p&gt;

&lt;p&gt;Where the tools come in, each with a different answer to "which plan applies":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Atlantis&lt;/strong&gt; (open source, self-hosted) runs as a pull request bot. &lt;code&gt;atlantis plan&lt;/code&gt; posts the plan on the PR, &lt;code&gt;atlantis apply&lt;/code&gt; applies &lt;strong&gt;that saved plan&lt;/strong&gt; while the PR is still open, and the PR is merged after the apply succeeded. It holds a lock per directory and workspace for the life of the PR so two PRs cannot plan the same stack against each other. Apply-before-merge is the whole idea: it solves plan identity by never letting a merge happen before the reviewed plan has applied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Digger&lt;/strong&gt; runs the plan and apply steps inside your existing CI (GitHub Actions, GitLab CI), with your runners and your credentials, and adds an orchestrator component that owns the pull request locks and caches plans between the plan and apply steps. State stays in your own backend. It is the option for teams that want the Atlantis workflow without operating an extra server that holds cloud credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HCP Terraform, Spacelift and env0&lt;/strong&gt; are hosted run platforms. Each run plans, waits for approval, then applies from that run's plan, so the reviewed plan and the applied plan are one object. On top of that: run queues per stack; ordering between stacks (Spacelift stack dependencies and env0 workflows also pass outputs downstream; HCP Terraform run triggers only queue the downstream run, and it reads values through data sources or &lt;code&gt;tfe_outputs&lt;/code&gt;); policy checks against the plan (Sentinel or OPA in HCP Terraform, OPA in Spacelift and env0; "a plan with more than five destroys needs a second approver" becomes a rule rather than a habit), scheduled drift detection with optional remediation runs, and access control over who may trigger what. Which of those are included depends on the plan or edition you are on, so check before assuming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision between the GitHub Actions version and one of these is not about team size. It is about whether you need any of: a guarantee that the plan reviewed on the pull request is the plan that applies, more than one PR open against the same stack at a time, dependencies between stacks, or policy that is enforced rather than reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question under all four: what is in the state file?
&lt;/h2&gt;

&lt;p&gt;Everything Terraform knows about a resource is in state, in plain JSON, including attribute values. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RDS master passwords set through &lt;code&gt;password = var.db_password&lt;/code&gt; are in state.&lt;/li&gt;
&lt;li&gt;The private key from &lt;code&gt;tls_private_key&lt;/code&gt; is in state, in full.&lt;/li&gt;
&lt;li&gt;Every &lt;code&gt;resource "random_password"&lt;/code&gt; result is in state (the newer &lt;code&gt;ephemeral "random_password"&lt;/code&gt; is not).&lt;/li&gt;
&lt;li&gt;Attributes you never set but the provider returns (connection strings, generated tokens) are in state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;sensitive = true&lt;/code&gt; hides values from plan output. It does nothing to the state file. So the last decision is treating state access as secret access: the bucket policy from question 1, encryption at rest, no &lt;code&gt;terraform.tfstate&lt;/code&gt; in a repository, ever, and the same care for saved plan files.&lt;/p&gt;

&lt;p&gt;Recent Terraform versions let you keep some secrets out of state entirely. This needs both a Terraform version and a provider version that support it; for the AWS provider, &lt;code&gt;password_wo&lt;/code&gt; on &lt;code&gt;aws_db_instance&lt;/code&gt; arrived in release 5.88.0 (the Secrets Manager ephemeral resource a little earlier). Pin the exact version you tested and commit the dependency lock file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.11"&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5.88.0"&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;# Read during the run, never written to state or plan&lt;/span&gt;
&lt;span class="nx"&gt;ephemeral&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_version"&lt;/span&gt; &lt;span class="s2"&gt;"db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod/checkout/db"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"checkout"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;password_wo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_secretsmanager_secret_version&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;secret_string&lt;/span&gt;
  &lt;span class="nx"&gt;password_wo_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;# bump to rotate&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Ephemeral resources (Terraform 1.10) are read during the run and discarded. Write-only arguments (Terraform 1.11) accept a value that the provider sends to the API but Terraform never persists; the &lt;code&gt;_wo_version&lt;/code&gt; companion is how you tell Terraform the value changed, since it cannot compare something it does not store. Not every resource has a write-only variant yet, so check the provider documentation for the ones you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;Run through these for each state file you own.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remote backend with locking, versioning on with a lifecycle rule for old versions, encryption on.&lt;/li&gt;
&lt;li&gt;IAM scoped so a team can write only its own state keys, including the &lt;code&gt;.tflock&lt;/code&gt; objects.&lt;/li&gt;
&lt;li&gt;No environment shares a state file with another environment.&lt;/li&gt;
&lt;li&gt;Components split by owner and lifecycle, with values shared through provider data sources or a parameter store rather than whole-state reads.&lt;/li&gt;
&lt;li&gt;A scheduled &lt;code&gt;plan -detailed-exitcode&lt;/code&gt; per stack that fails on errors, opens an issue on exit code 2, and lands with someone who classifies the cause (drift, unapplied code, or a moving data source).&lt;/li&gt;
&lt;li&gt;Plans posted on pull requests; applies from a saved plan; one run at a time per stack.&lt;/li&gt;
&lt;li&gt;A rule, enforced by tooling or by an approval gate, that a plan with destroys gets a second look.&lt;/li&gt;
&lt;li&gt;Secrets moved to ephemeral values and write-only arguments where the provider supports them; state and plan files treated as secret material where it does not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/who-owns-the-terraform-state-file" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>infrastructureascode</category>
      <category>cicd</category>
      <category>aws</category>
    </item>
    <item>
      <title>How Stripe Avoids Double-Charging Anyone</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/how-stripe-avoids-double-charging-anyone-2me1</link>
      <guid>https://dev.to/devopsdaily/how-stripe-avoids-double-charging-anyone-2me1</guid>
      <description>&lt;p&gt;Take Stripe's own classic example: a service sends &lt;code&gt;POST /v1/charges&lt;/code&gt; and the socket dies before a response arrives. There are three possible worlds: the request never reached the payment provider, the provider charged the card and the response was lost, or the provider is still working on it. Your code cannot tell them apart, and the customer is waiting. Retry, and you might charge twice. Give up, and you might have taken money without recording an order.&lt;/p&gt;

&lt;p&gt;Businesses running on Stripe generated $1.9 trillion in total volume in 2025, by Stripe's own count. At that scale, dropped connections are routine, and every one is a potential double charge. Idempotency keys let clients retry an ambiguous failure safely, and the pattern is small enough to copy in an afternoon. Whether the promise holds is decided by the server-side state machine: what it remembers, in what order, and around which call.&lt;/p&gt;

&lt;p&gt;This post combines Stripe's documented API behaviour with the separate Rocket Rides reference design that Brandur Leach published on his own site. We build a smaller Node and Postgres version, test it against concurrent duplicates and a mid-request crash, and look closely at the run where our first version failed.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A client generates a unique key per operation and sends it as &lt;code&gt;Idempotency-Key&lt;/code&gt;. The server stores the first result under that key and replays it for any retry with the same key and the same parameters. Stripe's API v1 keeps a key for at least 24 hours and stores the first status and body once the endpoint starts executing, including &lt;code&gt;500&lt;/code&gt;s; validation failures and concurrent conflicts are not stored.&lt;/li&gt;
&lt;li&gt;The response cache is the easy half. The hard half is a request that dies in the middle: the server has to know how far it got and resume from there without repeating the one step it cannot undo.&lt;/li&gt;
&lt;li&gt;The pattern is atomic phases and recovery points: group local database writes into transactions, put a marker after each, and treat any call to another system (a card network, an email API) as a boundary that must carry its own idempotency key.&lt;/li&gt;
&lt;li&gt;Concurrent duplicates are handled by locking the key row, not by hoping they arrive one at a time.&lt;/li&gt;
&lt;li&gt;A time-based lock is a lease. A two-second lease let our demo create three rides for one charge; ten seconds avoided the race in the recorded run, but correctness also needs lease renewal or fencing and invariants the database enforces. The output of both runs is below.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Comfort with HTTP APIs and SQL transactions&lt;/li&gt;
&lt;li&gt;Node.js 20 or newer to run the demo&lt;/li&gt;
&lt;li&gt;Any Postgres connection string; the run below used a branch on Neon so the schema could be dropped and recreated freely&lt;/li&gt;
&lt;li&gt;Familiarity with the phrase "at-least-once delivery" helps; the &lt;a href="https://devops-daily.com/games/message-queue-simulator" rel="noopener noreferrer"&gt;message queue simulator&lt;/a&gt; is a five-minute refresher&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem, stated precisely
&lt;/h2&gt;

&lt;p&gt;An operation is idempotent when doing it twice leaves the system in the same state as doing it once. &lt;code&gt;GET&lt;/code&gt; is idempotent by nature. &lt;code&gt;DELETE&lt;/code&gt; is too: deleting an already deleted thing changes nothing. &lt;code&gt;POST /charges&lt;/code&gt; is not. Send it twice and you have two charges.&lt;/p&gt;

&lt;p&gt;Retries are unavoidable. Stripe's engineering post on the subject, written by Brandur Leach in 2017, splits failures into two kinds. Some are "definitive enough that the client knows with good certainty that it's safe to simply retry": the connection was refused, DNS failed, nothing was ever sent. The dangerous kind is the failure in the middle: the request was sent, then the client timed out waiting for the answer. Now the client's knowledge of the world is stale, and a naive retry is a coin flip between "fine" and "charged twice".&lt;/p&gt;

&lt;p&gt;Idempotency keys turn the coin flip into a lookup. The client picks a unique identifier before the first attempt, sends it in the &lt;code&gt;Idempotency-Key&lt;/code&gt; header, and reuses it on every retry of that same operation. The server's job is to make sure that no matter how many times a request with that key arrives, the work happens once and every caller gets the same answer.&lt;/p&gt;

&lt;p&gt;The rules Stripe documents for its own API are worth reading closely, because each one encodes a lesson:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keys are client-generated.&lt;/strong&gt; Stripe suggests a V4 UUID or another random string with enough entropy; keys can be up to 255 characters. The other common strategy is deriving the key from a business object, such as a shopping cart id, which also protects against a user double-clicking "Pay".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results are cached whether or not the request succeeded.&lt;/strong&gt; Stripe saves the status code and body of the first request for a key "regardless of whether it succeeds or fails", and that includes &lt;code&gt;500&lt;/code&gt;s. Retrying a &lt;code&gt;500&lt;/code&gt; with the same key returns the same &lt;code&gt;500&lt;/code&gt;, because the original attempt may have had side effects that Stripe is still reconciling. The advice is to treat a &lt;code&gt;500&lt;/code&gt; as indeterminate and let webhooks tell you what really happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameters are compared.&lt;/strong&gt; Reusing a key with a different request body is treated as a client bug and rejected, not silently replayed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent conflicts are not stored.&lt;/strong&gt; If a request conflicts with another one executing at the same time, Stripe does not save an idempotent result for it, because no endpoint began executing. The client can retry it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting runs before the idempotency layer.&lt;/strong&gt; A request that was rate limited with &lt;code&gt;429&lt;/code&gt; can produce a different result on retry with the same key. The layers are ordered on purpose: a limiter that had to consult the key store would not be much of a limiter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys live at least 24 hours (API v1).&lt;/strong&gt; Stripe may prune a key once it is 24 hours old; a key reused after pruning starts a new request. Stripe's newer API v2 has its own retention and replay rules, so check the version you are on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;POST&lt;/code&gt; needs it.&lt;/strong&gt; In API v1 every &lt;code&gt;POST&lt;/code&gt; accepts a key; on &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;, which are idempotent by definition, a key has no effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replays are labelled.&lt;/strong&gt; A replayed response carries &lt;code&gt;Idempotent-Replayed: true&lt;/code&gt;, and a &lt;code&gt;Stripe-Should-Retry&lt;/code&gt; header tells well-behaved clients whether retrying is even worth it. The official SDKs generate keys and retry eligible network failures once you turn retries on (&lt;code&gt;maxNetworkRetries&lt;/code&gt; in stripe-node); your code still has to treat an indeterminate &lt;code&gt;500&lt;/code&gt; as unknown and reconcile through webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  From the client side
&lt;/h3&gt;

&lt;p&gt;Most teams meet all of this as a Stripe customer, not as an API author, so here is what the rules look like from that side. Derive the key from the business event (the order, not the attempt), send it on every attempt of that operation, and let the SDK retry the failures that are safe to retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a key with the request&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;curl&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;curl https://api.stripe.com/v1/payment_intents &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: order_8f1c2e_charge"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1900 &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eur &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"payment_method_types[]=card"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;stripe-node&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxNetworkRetries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eur&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payment_method_types&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="s2"&gt;card&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="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`order_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_charge`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Python&lt;/strong&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_network_retries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="n"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payment_method_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_charge&lt;/span&gt;&lt;span class="sh"&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;With retries turned on, stripe-node retries connection failures, concurrent &lt;code&gt;409&lt;/code&gt; conflicts and eligible &lt;code&gt;5xx&lt;/code&gt; responses with exponential backoff and jitter, and it honours &lt;code&gt;Stripe-Should-Retry&lt;/code&gt;; it deliberately does not retry a real rate-limit &lt;code&gt;429&lt;/code&gt; on its own. If you write your own policy instead, keep the same idempotency key across attempts, honour &lt;code&gt;Stripe-Should-Retry&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;, cap the backoff, add jitter, and do not stack your loop on top of the SDK's.&lt;/p&gt;

&lt;p&gt;None of this is exotic. Brandur's separate Rocket Rides post shows one way to implement those semantics on the server when a request dies halfway through, and that is the design we build next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server has to remember
&lt;/h2&gt;

&lt;p&gt;Consider what "create a ride and charge for it" means inside any service that calls a payment provider. It is never a single write. In Brandur's Rocket Rides example (a fictional jetpack rideshare), one API call records a ride, calls Stripe to create a charge, stores the charge id on the ride, and stages a receipt email. The Stripe call is the problem. It is a &lt;strong&gt;foreign state mutation&lt;/strong&gt; : it changes state in a system whose transaction you do not control. You cannot roll it back with the rest of your work, and you cannot make it happen atomically with your own writes.&lt;/p&gt;

&lt;p&gt;The design answer is to split the request into &lt;strong&gt;atomic phases&lt;/strong&gt; separated by those foreign calls, and to write a &lt;strong&gt;recovery point&lt;/strong&gt; after each phase so a retry knows where to pick up.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt; claim the key row&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt; insert ride (tx)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Charge card&lt;/strong&gt; foreign call, own key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3&lt;/strong&gt; store charge id + response (tx)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply&lt;/strong&gt; or replay on retry&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key row is the memory. In the published design it carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the key itself and the user or account it belongs to, unique together, because two customers may pick the same UUID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locked_at&lt;/code&gt;, set while a request holds the key, so a concurrent duplicate can be told to wait&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recovery_point&lt;/code&gt;, the name of the last completed phase (&lt;code&gt;started&lt;/code&gt;, &lt;code&gt;ride_created&lt;/code&gt;, &lt;code&gt;charge_created&lt;/code&gt;, &lt;code&gt;finished&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a fingerprint of the request (method, path, parameters) so a mismatched reuse can be rejected&lt;/li&gt;
&lt;li&gt;the response code and body once the request has finished&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three supporting processes complete the picture: an &lt;strong&gt;enqueuer&lt;/strong&gt; that drains staged jobs once their transaction has committed, an optional &lt;strong&gt;completer&lt;/strong&gt; that pushes unfinished requests through their remaining phases when the client has stopped retrying, and a &lt;strong&gt;reaper&lt;/strong&gt; that deletes old keys so the table does not grow without bound. Brandur suggests about 72 hours of retention for the reference design; Stripe's API v1 may prune keys once they are at least 24 hours old.&lt;/p&gt;

&lt;p&gt;As a result, a retry does not need special-case code. It claims the key, reads the recovery point, and runs whatever phases are left. If the process died after the card was charged but before the charge id was stored, the retry sees &lt;code&gt;recovery_point = ride_created&lt;/code&gt;, calls the card network again with the same downstream idempotency key, receives the same charge back, and finishes. The customer is charged once.&lt;/p&gt;

&lt;p&gt;An immediate retry cannot claim the live lease and gets &lt;code&gt;409&lt;/code&gt;. After the lease expires, a retry claims the key, sees &lt;code&gt;recovery_point = ride_created&lt;/code&gt;, skips ride creation, calls the provider with the same derived key, receives the same charge id, and completes phase 3.&lt;/p&gt;

&lt;p&gt;That last sentence hides a requirement: the downstream call must itself be idempotent, keyed by something you derive from your key. Stripe's API gives you that. If you call an API that does not, you are back to guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Postgres state machine
&lt;/h2&gt;

&lt;p&gt;We wrote a small version of this in Node with plain &lt;code&gt;pg&lt;/code&gt; and ran it against a Postgres branch. The whole thing is one server file, one schema file, and a script that tries to break it. The repo is public:&lt;/p&gt;

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

&lt;p&gt;The "payment provider" is a second endpoint in the same process that the rides API calls over HTTP. It models one Stripe property, the one that matters for this story: repeated requests with the same key return the same charge. It deliberately leaves out parameter checks, retention, cached errors and replay headers. It lives in the same database only so you need one connection string.&lt;/p&gt;

&lt;p&gt;What the demo does and does not claim, next to Stripe's documented behaviour:&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;Stripe API v1&lt;/th&gt;
&lt;th&gt;This demo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Key scope&lt;/td&gt;
&lt;td&gt;per account, up to 255 chars&lt;/td&gt;
&lt;td&gt;per user, up to 255 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;kept at least 24 hours; may be pruned afterwards&lt;/td&gt;
&lt;td&gt;never pruned (no reaper)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same key, different parameters&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;rejected with &lt;code&gt;409&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent duplicate&lt;/td&gt;
&lt;td&gt;conflict, not stored, retryable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;409&lt;/code&gt; while the lease is held&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint &lt;code&gt;500&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stored and replayed&lt;/td&gt;
&lt;td&gt;not stored; lease expires and the retry resumes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replay signal&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Idempotent-Replayed: true&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;replayed: true&lt;/code&gt; field in the body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery after an indeterminate &lt;code&gt;500&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stripe tries to reconcile and emit webhooks; not guaranteed&lt;/td&gt;
&lt;td&gt;recovery point resumes the remaining phases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External boundary&lt;/td&gt;
&lt;td&gt;depends on the operation; payment networks for card payments&lt;/td&gt;
&lt;td&gt;a second HTTP endpoint in the same process&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The tables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;char_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;request_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;recovery_point&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'started'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_code&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;response_body&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;-- keys are scoped to the account&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;rides&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;charge_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- One ride per key, enforced by the database (added after the run below).&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;rides_one_per_key&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;rides&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotency_key_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Stands in for the payments provider.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;provider_charges&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claiming the key
&lt;/h3&gt;

&lt;p&gt;The key-claim transaction is the first concurrency guard. Insert the key row if it does not exist, lock it, and then decide what this request is: a replay, a conflict, or the one that gets to do the work. The reference schema also has a unique constraint tying a ride to its key. The first version of this demo did not, which is how the expired-lease failure below became visible; the final schema has it, and the last run shows what it changes.&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;// Phase 1 (atomic): claim the key. SELECT ... FOR UPDATE serialises&lt;/span&gt;
&lt;span class="c1"&gt;// concurrent duplicates; whoever comes second sees what the first left behind.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&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;tx&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="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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO idempotency_keys (user_id, key, request_hash)
     VALUES ($1, $2, $3) ON CONFLICT (user_id, key) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestHash&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT * FROM idempotency_keys WHERE user_id = $1 AND key = $2 FOR UPDATE`&lt;/span&gt;&lt;span class="p"&gt;,&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;key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Same key, different request: a client bug, not a retry.&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;requestHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This Idempotency-Key was used with different parameters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// Already finished: replay the stored answer.&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;replayed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// Take the lock only if nobody holds a live one. clock_timestamp() moves&lt;/span&gt;
  &lt;span class="c1"&gt;// inside a transaction, unlike now(), so the lock time is real.&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;rowCount&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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`UPDATE idempotency_keys SET locked_at = clock_timestamp()
     WHERE id = $1 AND (locked_at IS NULL OR locked_at &amp;lt; clock_timestamp() - make_interval(secs =&amp;gt; $2))`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCK_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&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;rowCount&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A request with this Idempotency-Key is still in progress&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;k&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three things to notice. After loading the row, the hash of the request body is compared first, so a reused key with a different body never takes the lock. (The demo hashes &lt;code&gt;JSON.stringify(body)&lt;/code&gt;; production code should hash a canonical form that includes the endpoint and every input that changes the result, and nothing volatile.) The replay check comes next, so a finished request answers instantly. And the row lock serialises claimants, while the conditional &lt;code&gt;UPDATE&lt;/code&gt; evaluates lease expiry in database time and its &lt;code&gt;rowCount&lt;/code&gt; says whether this claimant got the lease.&lt;/p&gt;

&lt;h3&gt;
  
  
  The phases
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Phase 2 (atomic): local bookkeeping, then move the recovery point.&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recovery_point&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;started&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tx&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="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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`INSERT INTO rides (user_id, idempotency_key_id, amount_cents) VALUES ($1, $2, $3)`&lt;/span&gt;&lt;span class="p"&gt;,&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_cents&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;c&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="s2"&gt;`UPDATE idempotency_keys SET recovery_point = 'ride_created' WHERE id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recovery_point&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ride_created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Foreign state mutation: the charge. Not inside any of our transactions,&lt;/span&gt;
&lt;span class="c1"&gt;// so it carries its own idempotency key derived from ours. A retry after a&lt;/span&gt;
&lt;span class="c1"&gt;// crash asks the provider for the same charge and gets the same answer.&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recovery_point&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ride_created&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/provider/charges`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:charge`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;amount_cents&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="nx"&gt;amount_cents&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&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;r&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`provider said &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;after_charge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;simulated crash after the provider charged the card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Phase 3 (atomic): record the charge and the response, release the lock.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tx&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ride&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`UPDATE rides SET charge_id = $1 WHERE idempotency_key_id = $2 RETURNING id, amount_cents, charge_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ride_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ride&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;amount_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ride&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charge_id&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;c&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="s2"&gt;`UPDATE idempotency_keys
         SET recovery_point = 'finished', response_code = 201, response_body = $2, locked_at = NULL
       WHERE id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;crash&lt;/code&gt; query parameter exists only so the demo can die at the worst possible moment: after the provider has the money, before we have the charge id. On failure the handler returns a &lt;code&gt;500&lt;/code&gt; and leaves the row locked with its recovery point intact. This is a deliberate departure from Stripe, which stores an endpoint's &lt;code&gt;500&lt;/code&gt; and replays it; the demo treats the failure as recoverable instead, so the lease expires and the next retry resumes from &lt;code&gt;ride_created&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The provider endpoint is eight lines and one &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt;. Its whole contract is: same key, same charge.&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="n"&gt;const&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;`INSERT INTO provider_charges (id, idempotency_key, amount_cents) VALUES ($1, $2, $3)
   ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
   RETURNING id, amount_cents, (xmax = 0) AS created`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&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 &lt;code&gt;DO UPDATE&lt;/code&gt; that sets a column to itself is a Postgres idiom to make &lt;code&gt;RETURNING&lt;/code&gt; produce the existing row on conflict; &lt;code&gt;xmax = 0&lt;/code&gt; tells you whether this call inserted it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  One winner, nineteen conflicts
&lt;/h2&gt;

&lt;p&gt;The demo script fires three scenarios at the API: twenty concurrent requests with one key, a reuse of that key with a different amount, and a request that crashes after the charge followed by retries. Here is the run, unedited, against a Postgres branch on Neon from a Raspberry Pi:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm run demo&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run schema
&lt;span class="go"&gt;schema ready
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm start &amp;amp;
&lt;span class="go"&gt;rides api on :4100 (lock ttl 10000 ms)
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run demo
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;1. Twenty clients retry the same request at once &lt;span class="o"&gt;(&lt;/span&gt;same Idempotency-Key&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;statuses: {"201":1,"409":19}
201 bodies all name the same charge: true (ch_bbf48cd47263)
replayed responses: 0, first-time: 1
stats: {"rides":1,"rides_with_charge":1,"provider_charges":1,"provider_cents":1900}

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;2. Same key, different amount: a client bug, not a retry
&lt;span class="go"&gt;{"status":409,"body":{"error":"This Idempotency-Key was used with different parameters"}}

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;3. Crash after the card was charged but before we recorded it
&lt;span class="go"&gt;first attempt: {"status":500,"body":{"error":"simulated crash after the provider charged the card","recovery_point":"ride_created"}}
stats now: {"rides":2,"rides_with_charge":1,"provider_charges":2,"provider_cents":6100} &amp;lt;- provider has the money, we have no charge_id
retry at once: {"status":409,"body":{"error":"A request with this Idempotency-Key is still in progress"}}
waiting for the lock to expire (10 s)...
retry later: {"status":201,"body":{"ride_id":"2","amount_cents":4200,"charge_id":"ch_165363a6ef7d"}}
retry again: {"status":201,"body":{"ride_id":"2","charge_id":"ch_165363a6ef7d","amount_cents":4200,"replayed":true}}
stats: {"rides":2,"rides_with_charge":2,"provider_charges":2,"provider_cents":6100}

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

&lt;/div&gt;



&lt;p&gt;Reading the three scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The burst.&lt;/strong&gt; Twenty requests, one winner. The other nineteen arrived while the winner held the lease and got &lt;code&gt;409&lt;/code&gt;. Stripe likewise treats a concurrent conflict on a key as retryable and does not store a result for it. One ride, one provider charge, 1900 cents. A client that received a &lt;code&gt;409&lt;/code&gt; here should back off and retry with the same key; by then it will get the replayed &lt;code&gt;201&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reuse.&lt;/strong&gt; Same key, 2900 cents instead of 1900. Rejected at the hash check before any lock or write. Silently replaying the 1900-cent result would have been worse than an error: the client thinks it charged 2900.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The crash.&lt;/strong&gt; The first attempt charges the card (the provider now holds 6100 cents across two charges) and dies before storing the charge id. The immediate retry finds the row still locked and gets &lt;code&gt;409&lt;/code&gt;. After the lease expires, the retry resumes at &lt;code&gt;ride_created&lt;/code&gt;, asks the provider for the charge with the same derived key, receives &lt;code&gt;ch_165363a6ef7d&lt;/code&gt; again, stores it, and returns &lt;code&gt;201&lt;/code&gt;. A further retry returns the stored body plus a demo-only &lt;code&gt;replayed&lt;/code&gt; flag; Stripe keeps the body untouched and signals the replay in the &lt;code&gt;Idempotent-Replayed&lt;/code&gt; header instead. Two rides, two charges, one per customer intent. Nobody was charged twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The run that went wrong
&lt;/h2&gt;

&lt;p&gt;The output above is the second run. The first one looked like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm run demo (lock ttl 2000 ms)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run demo
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;1. Twenty clients retry the same request at once &lt;span class="o"&gt;(&lt;/span&gt;same Idempotency-Key&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;statuses: {"201":3,"409":17}
201 bodies all name the same charge: true (ch_6c15fd603155)
replayed responses: 0, first-time: 3
stats: {"rides":3,"rides_with_charge":3,"provider_charges":1,"provider_cents":1900}

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

&lt;/div&gt;



&lt;p&gt;Three first-time &lt;code&gt;201&lt;/code&gt;s and three rides for one provider charge. The row locking behaved as written; the two-second lease assumption did not. It was chosen so the crash scenario would not make readers wait. A database query afterwards showed &lt;code&gt;created_at&lt;/code&gt; values of 24.7 seconds past the minute for the key row and 28.0, 28.3 and 29.5 for the three rides. Postgres's &lt;code&gt;now()&lt;/code&gt; records transaction start rather than the exact insert instant, so these are not precise, but together with the output they are consistent with one picture: under twenty concurrent requests on a cold connection pool, the winner took longer than the lease to get from claiming the key to inserting its ride, and two waiting requests acquired the expired lease while the committed recovery point still said &lt;code&gt;started&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The provider's own idempotency saved the money: all three rides point at the same charge, and the customer paid once. The application data was still wrong, and in a system where the ride-creation phase did something with a side effect (reserved inventory, sent a confirmation), the customer would have noticed.&lt;/p&gt;

&lt;p&gt;The lesson generalises past this demo. &lt;strong&gt;A lock timeout shorter than your slowest honest request is a duplicate generator.&lt;/strong&gt; The reference design also lets a retry acquire an expired lock; its optional completer exists for unfinished requests whose clients stopped retrying, and it does not remove the risk of an old worker and a takeover running at the same time. Raising the lease to 10 seconds is what made the recorded run clean, and it is not a fix: no fixed timeout is guaranteed to outlast every pause. Production needs a conservative lease plus renewal or a fencing token, database constraints for every local invariant (here, one ride per key), and alerts for stale work.&lt;/p&gt;

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

&lt;p&gt;Prose is cheap, so we added the constraint (&lt;code&gt;CREATE UNIQUE INDEX rides_one_per_key ON rides (idempotency_key_id)&lt;/code&gt;), put the lease back to 2 seconds, and ran the burst again:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm run demo (lock ttl 2000 ms, one ride per key)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm run demo
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;1. Twenty clients retry the same request at once &lt;span class="o"&gt;(&lt;/span&gt;same Idempotency-Key&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;statuses: {"201":1,"409":17,"500":2}
201 bodies all name the same charge: true (ch_de1965ba9783)
replayed responses: 0, first-time: 1
stats: {"rides":1,"rides_with_charge":1,"provider_charges":1,"provider_cents":1900}

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

&lt;/div&gt;



&lt;p&gt;Same race, different outcome. The winner still finishes with one ride and one charge. The two requests that took over the expired lease now fail on the unique index when they try to insert their ride and return &lt;code&gt;500&lt;/code&gt;, which is the honest answer: something went wrong with their attempt, nothing was duplicated, and their client will retry with the same key and get the winner's replayed &lt;code&gt;201&lt;/code&gt;. Loud failure beat silent duplication; that is the whole point of putting the invariant where a lease cannot reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern beyond payments
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; is the reference. Current stripe-node retries eligible failures once by default; &lt;code&gt;maxNetworkRetries&lt;/code&gt; changes that count, and the library adds idempotency keys where appropriate. &lt;code&gt;Idempotent-Replayed: true&lt;/code&gt; marks a cached server response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook senders&lt;/strong&gt; need it in both directions. &lt;a href="https://link.svix.com/devopsdaily" rel="noopener noreferrer"&gt;Svix&lt;/a&gt; accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt; on its &lt;code&gt;POST&lt;/code&gt; endpoints and returns the first result for up to 12 hours; on the receiving side you deduplicate on the message id, as covered in &lt;a href="https://dev.to/devopsdaily/what-it-actually-takes-to-deliver-a-webhook-in-production-4ghe"&gt;what it actually takes to deliver a webhook in production&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional email&lt;/strong&gt; is a foreign state mutation with a human on the other end. The &lt;a href="https://smtpfa.st/" rel="noopener noreferrer"&gt;smtpfast&lt;/a&gt; send API takes an &lt;code&gt;Idempotency-Key&lt;/code&gt; and returns the original email id on a retry, which is what let us build a reply feature in that product without a "did the retry send twice?" path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job queues&lt;/strong&gt; deliver at least once. &lt;a href="https://dev.to/devopsdaily/running-a-background-job-that-must-not-be-lost-iio"&gt;Running a background job that must not be lost&lt;/a&gt; is the same idea from the worker's side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist for your own API
&lt;/h2&gt;

&lt;p&gt;If you are adding idempotency to a &lt;code&gt;POST&lt;/code&gt; endpoint, here is the list we would review against:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope keys to the caller.&lt;/strong&gt; The unique constraint is &lt;code&gt;(account, key)&lt;/code&gt;, never &lt;code&gt;key&lt;/code&gt; alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash and compare the request.&lt;/strong&gt; Reject the same key when the canonical method, path or any outcome-affecting parameter differs, and document the status you return. Include recipients, amounts and scheduling; leave out volatile transport headers such as tracing ids. A partial fingerprint turns a client bug into a silent wrong answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claim the key atomically, and let the second caller lose.&lt;/strong&gt; &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; plus a conditional update gets you there. Return &lt;code&gt;409&lt;/code&gt; for an in-flight duplicate and let clients back off and retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the lock as a lease.&lt;/strong&gt; Make it longer than your slowest request measured under load, renew it or fence it with a token, and enforce the one-operation invariant with a unique constraint so a takeover cannot duplicate work even when the lease is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a recovery point after every local phase&lt;/strong&gt; , before the next foreign call. The phase before a foreign call must be committed, or a retry will repeat it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every foreign call its own key derived from yours.&lt;/strong&gt; If the downstream API is not idempotent, you have not made your endpoint idempotent, only your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the final response and replay it verbatim&lt;/strong&gt; , including errors that were the endpoint's answer. Label replays so clients can tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what happens before the idempotency layer.&lt;/strong&gt; Authentication and rate limiting usually run first, and a &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;401&lt;/code&gt; is therefore not cached. Document it, as Stripe does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reap old keys.&lt;/strong&gt; Pick a window longer than your clients' retry and reconciliation period; Stripe's API v1 keeps keys at least 24 hours, which suits an API that gets retried in seconds and reconciled in hours. Make the window explicit in your docs so clients know how long a retry is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never put personal data in a key.&lt;/strong&gt; Keys end up in logs on both sides. Stripe's docs say this outright.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The guarantee lives in the state machine
&lt;/h2&gt;

&lt;p&gt;Idempotency keys look like a caching feature and are really a small state machine. The header buys you nothing on its own; the guarantees come from persisted progress, serialised claims, parameter matching, safe foreign calls, and invariants the database enforces. The demo above is about 200 lines because the idea is small. What is not small is the number of ways to get the details slightly wrong, and the two-second run shows why the header and a response cache are not enough on their own.&lt;/p&gt;

&lt;p&gt;To try the behaviour, break a receiver in the &lt;a href="https://devops-daily.com/games/webhook-delivery-simulator" rel="noopener noreferrer"&gt;webhook delivery simulator&lt;/a&gt; and watch retries and deduplication play out, or point the &lt;a href="https://github.com/The-DevOps-Daily/idempotency-keys-demo" rel="noopener noreferrer"&gt;demo repo&lt;/a&gt; at your own database.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/how-stripe-avoids-double-charging-idempotency-keys" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reliability</category>
      <category>systemdesign</category>
      <category>postgres</category>
      <category>api</category>
    </item>
    <item>
      <title>Learn SQL by Typing It: Three Browser Database Terminals Compared</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:51:59 +0000</pubDate>
      <link>https://dev.to/devopsdaily/learn-sql-by-typing-it-three-browser-database-terminals-compared-34mf</link>
      <guid>https://dev.to/devopsdaily/learn-sql-by-typing-it-three-browser-database-terminals-compared-34mf</guid>
      <description>&lt;p&gt;SQL is a skill you acquire by running queries against data and being wrong a lot. Reading a tutorial produces recognition ("yes, that is a LEFT JOIN"); typing sixty queries produces recall, and recall is what an interview or a production incident actually tests. The problem for beginners is that running queries traditionally starts with installing a database, and "install PostgreSQL" is where a lot of SQL journeys quietly end.&lt;/p&gt;

&lt;p&gt;Browser-based database terminals remove that step. Below is a practice path through three of them, each free and signup-free, and each covering a different layer of the skill: the SQL language itself, the &lt;code&gt;psql&lt;/code&gt; client you will meet at work, and the document-database alternative for contrast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the SQL language
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/sql-terminal-simulator" rel="noopener noreferrer"&gt;SQL Terminal Simulator&lt;/a&gt; is a guided path of 13 lessons against a small commerce dataset: &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_items&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;. It starts at &lt;code&gt;SELECT&lt;/code&gt; and ends at common table expressions and window functions.&lt;/p&gt;

&lt;p&gt;The progression, with one example of each rung, all runnable in the simulator:&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;-- reading&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Germany'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;signup_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- aggregating&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- combining&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rung after that is where the dataset earns its keep: revenue does not live in one table. There is no &lt;code&gt;orders.total&lt;/code&gt; column, so answering "how much has each customer spent" forces the three-way join that real schemas force on you:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;JOIN&lt;/code&gt; versus &lt;code&gt;LEFT JOIN&lt;/code&gt; pair is the single most valuable thing to drill. Two classic bugs account for a large share of wrong query results in application code: an inner join silently dropping rows, and an aggregate double-counting because of join fan-out. You learn to smell both by writing the queries and counting the rows that come back.&lt;/p&gt;

&lt;p&gt;An implementation detail worth knowing, because it affects what you can type: single-table queries are evaluated live against the in-memory dataset (SELECT, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT and the common aggregates), so you can experiment freely there. The multi-table joins, subqueries and window functions are the guided lessons' specific queries, and their results were captured verbatim from a real PostgreSQL instance, so what you see is exactly what Postgres returns, down to the numeric formatting. Arbitrary ad-hoc joins are not interpreted; for free-form join practice, graduate to a real database (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the client is a skill too
&lt;/h2&gt;

&lt;p&gt;Something SQL courses tend to skip: at work you often get a connection string and a terminal, and the tool at the other end is &lt;code&gt;psql&lt;/code&gt;. Knowing SQL but not the client leaves you fumbling exactly when someone is watching.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/postgres-terminal-simulator" rel="noopener noreferrer"&gt;psql Terminal Simulator&lt;/a&gt; drills the client itself across 8 lessons. The meta-commands first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\l          -- list databases
\dt         -- list the tables in your search path
\d orders   -- describe one table: columns, types, indexes
\di         -- list indexes
\x          -- toggle expanded output (wide rows become readable)
\timing     -- toggle per-query timing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\d&lt;/code&gt; is the one you will use daily: it answers "what is actually in this table" without leaving the terminal. &lt;code&gt;\x&lt;/code&gt; is the one that saves you the first time a query returns twenty wrapped columns. (In real psql, &lt;code&gt;\q&lt;/code&gt; quits; the simulator politely declines that one.)&lt;/p&gt;

&lt;p&gt;The later lessons walk the workflow that separates "can query" from "can investigate a slow query", using a deliberately large &lt;code&gt;big_events&lt;/code&gt; table:&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="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;big_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Seq Scan on big_events ...&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_big_events_user&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;big_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;big_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Bitmap Index Scan feeding a Bitmap Heap Scan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this prepared dataset the plan flips once the index exists; on a real system the planner weighs table size, selectivity and statistics, and sometimes correctly ignores your new index, which is its own lesson. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; then shows actual timings, and a transactions lesson (&lt;code&gt;BEGIN&lt;/code&gt;, &lt;code&gt;COMMIT&lt;/code&gt;, &lt;code&gt;ROLLBACK&lt;/code&gt;) covers the day you want to try a risky &lt;code&gt;UPDATE&lt;/code&gt; with an exit hatch.&lt;/p&gt;

&lt;p&gt;Watching a plan change because of something you did teaches more about indexes than any diagram of a B-tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the document side, for contrast
&lt;/h2&gt;

&lt;p&gt;You will meet MongoDB, or something shaped like it, eventually. The mental model is different enough that learning it by contrast, right after SQL, is the cheapest time to do it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/mongodb-terminal-simulator" rel="noopener noreferrer"&gt;MongoDB Terminal Simulator&lt;/a&gt; covers the shell in 10 lessons: &lt;code&gt;find()&lt;/code&gt; with query operators, projections, sort/limit/skip paging, inserts and updates, and the aggregation pipeline. The instructive part is seeing familiar questions wearing new syntax:&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;// SQL: SELECT name, country FROM customers WHERE country = 'Germany';&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;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Germany&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;_id&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="c1"&gt;// SQL: SELECT country, COUNT(*) FROM customers GROUP BY country;&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;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$group&lt;/span&gt;&lt;span class="p"&gt;:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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 aggregation pipeline is where the model clicks or does not: you express the computation as an ordered sequence of stages (&lt;code&gt;$match&lt;/code&gt;, then &lt;code&gt;$group&lt;/code&gt;, then &lt;code&gt;$sort&lt;/code&gt;), each transforming the stream. Some people find it clearer than SQL. Almost everyone finds it clarifying to hold both models, because half of understanding a tool is knowing what it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually use these
&lt;/h2&gt;

&lt;p&gt;A workable schedule: one layer per week, fifteen minutes a day, always typing rather than reading. Do the guided lessons once, then come back without the guidance and try to reproduce the queries from the lesson titles alone. Recall, not recognition, is the goal.&lt;/p&gt;

&lt;p&gt;Then graduate: install Postgres locally or grab a free hosted instance, load a real dataset, and take the muscle memory with you. The simulators are the on-ramp, not the destination.&lt;/p&gt;

&lt;p&gt;Disclosure: I help build these simulators; they are part of &lt;a href="https://devops-daily.com/games" rel="noopener noreferrer"&gt;50+ free DevOps games and simulators&lt;/a&gt;. For more practice beyond them, &lt;a href="https://sqlbolt.com/" rel="noopener noreferrer"&gt;SQLBolt&lt;/a&gt; and &lt;a href="https://pgexercises.com/" rel="noopener noreferrer"&gt;PostgreSQL Exercises&lt;/a&gt; are excellent and free, and &lt;a href="https://mystery.knightlab.com/" rel="noopener noreferrer"&gt;SQL Murder Mystery&lt;/a&gt; is the most fun anyone has ever had with a schema.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>postgres</category>
      <category>mongodb</category>
      <category>database</category>
    </item>
    <item>
      <title>Go Is Not Just for CLIs. It Runs the Cloud Native Control Plane</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/go-is-not-just-for-clis-it-runs-the-cloud-native-control-plane-1a1k</link>
      <guid>https://dev.to/devopsdaily/go-is-not-just-for-clis-it-runs-the-cloud-native-control-plane-1a1k</guid>
      <description>&lt;p&gt;There is a meme that goes around every few months: a list of infrastructure tools, each followed by "is Go", ending with "still, you think Go is just for CLIs." The list is accurate, and the reasons behind it decide what a DevOps engineer should learn to read. So instead of repeating the list, we measured it. The language statistics below come from the GitHub API for each project's main repository on September 1, 2026, and the build demo at the end was run for real.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Of 20 projects that define the cloud native stack, 19 are majority Go, most above 90%. The exception, Grafana, is a Go backend under a TypeScript frontend.&lt;/li&gt;
&lt;li&gt;The reasons are concrete: one self-contained binary, cross-compilation from one machine, goroutines for daemons that juggle thousands of connections, fast compiles, and the gravitational pull of Docker and Kubernetes having chosen Go first.&lt;/li&gt;
&lt;li&gt;Go does not own everything. The fastest data paths (nginx, HAProxy, Redis, Envoy) are C and C++, the JVM still runs Kafka, Elasticsearch, and Jenkins, Ansible is Python, and the newest proxies and pipelines are Rust (Linkerd's proxy, Vector, Cloudflare's Pingora).&lt;/li&gt;
&lt;li&gt;For DevOps engineers the practical takeaway is "learn enough Go to read the tools you operate" rather than "rewrite your scripts in Go." The step from reading Kubernetes source to writing an operator is short.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Nothing to install to follow the argument; Go 1.22+ if you want to run the build demo at the end&lt;/li&gt;
&lt;li&gt;Familiarity with at least a few of the tools named below&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The list, measured
&lt;/h2&gt;

&lt;p&gt;Everyone knows the meme list; here is what the repositories say. Percentages are bytes of code by language from the GitHub API, top language per project:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share of Go in the main repository, by bytes of code&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;CoreDNS&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terraform&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MinIO&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Helm&lt;/td&gt;
&lt;td&gt;98.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;98.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;98.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;containerd&lt;/td&gt;
&lt;td&gt;97.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;97.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker (moby)&lt;/td&gt;
&lt;td&gt;97.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;etcd&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hugo&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CockroachDB&lt;/td&gt;
&lt;td&gt;91.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;88.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cilium&lt;/td&gt;
&lt;td&gt;88.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nomad&lt;/td&gt;
&lt;td&gt;82.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Argo CD&lt;/td&gt;
&lt;td&gt;80.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consul&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault&lt;/td&gt;
&lt;td&gt;66.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grafana&lt;/td&gt;
&lt;td&gt;45.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;GitHub API language statistics, main repositories, 2026-09-01. Grafana is the one project where another language (TypeScript, 48.6%) leads.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The numbers add three things the meme leaves out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The core is Go even where the total is not.&lt;/strong&gt; Vault (66% Go) and Consul (76%) carry large JavaScript and SCSS shares because they ship web UIs; the servers are Go. Grafana is the honest outlier: the product is a TypeScript frontend and a Go backend in roughly equal measure, so "Grafana is Go" is half true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The projects are polyglot at the edges.&lt;/strong&gt; Cilium is 10% C because its datapath is eBPF programs; Hugo carries 2.5% C for a bundled library; CockroachDB has 3% Starlark for Bazel build files. Go owns the control logic, not every byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pattern holds across vendors and foundations.&lt;/strong&gt; HashiCorp, the CNCF projects, Grafana Labs, MinIO, and Cockroach Labs all landed on the same language, and the reasons below are the ones their engineers cite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Go keeps winning the control plane
&lt;/h2&gt;

&lt;p&gt;The reasons these teams give are operational: the properties of a Go program match what infrastructure software has to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One self-contained binary.&lt;/strong&gt; A Go program compiles to a single executable with the Go runtime (scheduler, garbage collector) linked in, so there is nothing to install beside it, and a pure-Go program built with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; links statically on Linux with no shared-library dependencies. &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;terraform&lt;/code&gt;, and &lt;code&gt;caddy&lt;/code&gt; are downloaded as one file and run. The demo below shows what that looks like: a working HTTP server in under 6 MB, &lt;code&gt;ldd&lt;/code&gt; reporting "not a dynamic executable". For tools that must run on a fleet of hosts you do not fully control, that matters more than any language feature. Compare distributing a Python tool (interpreter version, virtualenv, native wheels) or a JVM service (JDK, heap flags, startup time).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-compile from one laptop.&lt;/strong&gt; &lt;code&gt;GOOS=linux GOARCH=arm64 go build&lt;/code&gt; produces an ARM Linux binary from a Mac in the same command that produced the x86 one, as long as the code stays cgo-free (cgo needs a C toolchain for the target). Release pipelines for these tools are largely a matrix of environment variables rather than a fleet of build machines, which is why the CLIs among them ship darwin, linux, and windows builds for several architectures from the first release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goroutines fit daemons.&lt;/strong&gt; A control-plane component holds thousands of long-lived connections: watch streams in the API server, gossip in Consul, scrape targets in Prometheus, backends behind Traefik. Goroutines make "one lightweight thread per connection" the natural design instead of a callback pyramid or a thread pool tuned by hand, and channels give the coordination primitives. The Kubernetes controller pattern (watch, queue, reconcile) is idiomatic Go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compile loop is fast.&lt;/strong&gt; Fast compilation was an explicit design goal of the language, and it shows in day-to-day work on large codebases: a changed package rebuilds in seconds, and a full build of something the size of Kubernetes is a coffee break rather than a lunch break. Teams that ship weekly with hundreds of contributors feel this daily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A garbage collector that is good enough for the control plane.&lt;/strong&gt; Infrastructure code allocates constantly (parsing YAML, JSON, protobuf), and Go's concurrent, low-pause collector keeps latency acceptable for coordination work without manual memory management. It is not free: GC CPU time and occasional pauses are real, which is exactly why the data-path projects in the next section chose otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gravity.&lt;/strong&gt; Docker chose Go in 2013; Kubernetes was rewritten from a Java prototype into Go before its 2014 launch; client libraries, CRD tooling, controller-runtime, and much of the CNCF's shared plumbing came out Go-shaped. A few years in, starting an infrastructure project in anything else meant re-implementing a lot of that plumbing. Gravity is a real technical reason once it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Go does not run the show
&lt;/h2&gt;

&lt;p&gt;The meme stops at the control plane on purpose, because the data plane and the older layers are a different story:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primary language of infrastructure projects that are not Go&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;nginx (C)&lt;/td&gt;
&lt;td&gt;97.7%&lt;/td&gt;
&lt;td&gt;C / C++&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HAProxy (C)&lt;/td&gt;
&lt;td&gt;96.1%&lt;/td&gt;
&lt;td&gt;C / C++&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Envoy (C++)&lt;/td&gt;
&lt;td&gt;87.7%&lt;/td&gt;
&lt;td&gt;C / C++&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis (C)&lt;/td&gt;
&lt;td&gt;68.2%&lt;/td&gt;
&lt;td&gt;C / C++&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch (Java)&lt;/td&gt;
&lt;td&gt;99.2%&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka (Java)&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jenkins (Java)&lt;/td&gt;
&lt;td&gt;87.2%&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ansible (Python)&lt;/td&gt;
&lt;td&gt;86.6%&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pingora (Rust)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linkerd2 proxy (Rust)&lt;/td&gt;
&lt;td&gt;99.5%&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector (Rust)&lt;/td&gt;
&lt;td&gt;65.3%&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;GitHub API language statistics, 2026-09-01. Redis counts 28.6% Tcl because its test suite is Tcl; the server is C.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hot data path is still C and C++.&lt;/strong&gt; nginx, HAProxy, Redis, and Envoy sit where every byte and every microsecond count, and none of them accept a garbage collector on that path. Istio is the cleanest illustration inside one product: its control plane is 98% Go, its sidecar and waypoint proxies are Envoy in C++, and its newer ambient mode adds a Rust node proxy, ztunnel, for L4 traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The JVM runs the big stateful systems.&lt;/strong&gt; Kafka, Elasticsearch, and Jenkins predate the Go wave and carry ecosystems too large to move. They cost more memory and startup time, and they are not going anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python holds configuration management and glue.&lt;/strong&gt; Ansible is Python, extended by a large audience of operators who write Python modules and plugins rather than systems code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rust is taking the new data paths.&lt;/strong&gt; Linkerd's 2.x proxy was written in Rust from the start (its 1.x proxy was Scala on the JVM) for latency and memory reasons, while its control plane is Go; Vector (observability pipelines) and Cloudflare's Pingora (which replaced Cloudflare's nginx-based origin-facing proxies) chose Rust as well. Where a GC on the hot path is a cost, new projects reach for Rust; where developer throughput matters more, they still reach for Go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rough picture in 2026 is two layers: Go for the control plane (scheduling, coordination, configuration, APIs) and C, C++, or increasingly Rust for the data plane (bytes on the wire, storage engines). It is rough because Go does carry real data-path work too: MinIO serves objects, CockroachDB stores rows, and Prometheus ingests samples, all in Go. As a rule of thumb for where a DevOps engineer's reading time goes, it holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six-megabyte demonstration
&lt;/h2&gt;

&lt;p&gt;The claim about self-contained binaries is easy to check. Here is a complete HTTP service (&lt;code&gt;go.mod&lt;/code&gt; is two lines: &lt;code&gt;module healthz&lt;/code&gt; and the Go version):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hostname&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/healthz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ok from %s at %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC3339&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"listening on :8080"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListenAndServe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;We built it on a Raspberry Pi (arm64, Go 1.26), ran it, and cross-compiled it for three other targets from the same shell:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;static binaries&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 go build &lt;span class="nt"&gt;-ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-s -w"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; healthz &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; healthz | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $5" bytes"}'&lt;/span&gt;
&lt;span class="go"&gt;5374114 bytes
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;file healthz
&lt;span class="go"&gt;healthz: ELF 64-bit LSB executable, ARM aarch64, statically linked, stripped
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ldd healthz
&lt;span class="go"&gt;    not a dynamic executable
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./healthz &amp;amp; &lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8080/healthz
&lt;span class="go"&gt;listening on :8080
ok from raspberrypi at 2026-09-01T21:05:55Z
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;same &lt;span class="nb"&gt;source&lt;/span&gt;, other platforms, no other machines involved
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;t &lt;span class="k"&gt;in &lt;/span&gt;linux/amd64 darwin/arm64 windows/amd64&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;%/*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;GOARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;#*/&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 go build &lt;span class="nt"&gt;-ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-s -w"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; healthz-&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;/\//-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nb"&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;$t&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s healthz-&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;/\//-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bytes"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="go"&gt;linux/amd64 5771426 bytes
darwin/arm64 5428114 bytes
windows/amd64 5901312 bytes

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

&lt;/div&gt;



&lt;p&gt;Between 5.4 and 5.9 MB per target, nothing to install beside it, no shared libraries on the Linux build we inspected, four platforms from one directory. The CLIs and single-binary servers in the first chart (kubectl, terraform, caddy, etcd, MinIO) ship in exactly this shape, and that property explains more of the meme than any language feature does. It is also why &lt;code&gt;FROM scratch&lt;/code&gt; containers are normal in this ecosystem: the image is the binary. (Not universal: Grafana ships its frontend assets alongside the binary, and Hugo's extended build uses cgo.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you run this stack
&lt;/h2&gt;

&lt;p&gt;You do not have to write Go to benefit from the fact that your infrastructure is written in it, but reading it changes how you operate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Error messages become searchable at the source.&lt;/strong&gt; When &lt;code&gt;kubectl&lt;/code&gt; or &lt;code&gt;terraform&lt;/code&gt; prints something cryptic, the string is in a Go file you can find in seconds, with the condition that produced it right above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration semantics stop being folklore.&lt;/strong&gt; The definitive answer to "what does this Helm flag do" is a short Go function, and it is usually clearer than the docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extending the tools is the same language as the tools.&lt;/strong&gt; Kubernetes operators, Terraform providers, Prometheus exporters, Caddy modules, and Traefik plugins are written in Go against libraries the projects maintain. Our &lt;a href="https://devops-daily.com/posts/write-simple-kubernetes-operator" rel="noopener noreferrer"&gt;guide to writing a simple Kubernetes operator&lt;/a&gt; starts from exactly that position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The language is small.&lt;/strong&gt; The Go specification is short enough to read in a sitting, and reading competence comes quickly from following code in a project you already run. That is a good return for the time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meme holds, for operational reasons: the properties that make Go a good CLI language (one binary, fast start, cross-compile) are the same properties a control plane needs, plus goroutines for the daemons. The data plane keeps going to C and Rust. The layer that schedules, coordinates, and configures your infrastructure is written in Go, and it is worth being able to read.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/go-runs-the-cloud-native-control-plane" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>kubernetes</category>
      <category>docker</category>
      <category>cloudnative</category>
    </item>
    <item>
      <title>Stop Building Webhook Retries Yourself</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/stop-building-webhook-retries-yourself-1ee6</link>
      <guid>https://dev.to/devopsdaily/stop-building-webhook-retries-yourself-1ee6</guid>
      <description>&lt;p&gt;Teams that ship webhooks tend to write the code twice. First the happy path: an HTTP POST with a JSON body. Then, after the first customer outage, the real product: a retry table, a scheduler, exponential backoff, a place to store failed deliveries, a signature scheme, a way to replay a day of events for one customer, and a dashboard so support can answer "did you get it?" That second half is the expensive one, and it rarely appears in the original estimate.&lt;/p&gt;

&lt;p&gt;We took the other route for this article. We built a receiver that fails on purpose in five common ways (returns 500s for a while, answers 429 with &lt;code&gt;Retry-After&lt;/code&gt;, hangs past the timeout, stays dead, rejects bad signatures), pointed &lt;a href="https://link.svix.com/devopsdaily" rel="noopener noreferrer"&gt;Svix&lt;/a&gt; at it, and recorded what happened, attempt by attempt, with timestamps from both sides. The receiver and the driver scripts are public:&lt;/p&gt;

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

&lt;p&gt;Everything below is a real run on September 1, 2026. Where we quote a timing, it comes from the logs in that repo.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A single message create fanned out to five endpoints: one healthy control and four failure modes. Svix retried the flaky one on its schedule and it recovered on attempt three at 19:25:23, about four minutes after the first failure, with no code on our side.&lt;/li&gt;
&lt;li&gt;The receiver's &lt;code&gt;Retry-After: 60&lt;/code&gt; on a 429 was not honored: the retry arrived 11 seconds later, on the sender's schedule. If you rely on &lt;code&gt;Retry-After&lt;/code&gt;, that is a real limitation to know.&lt;/li&gt;
&lt;li&gt;A hung endpoint was recorded as &lt;code&gt;request timed out&lt;/code&gt; (Svix's documented delivery timeout is 15 seconds) and retried.&lt;/li&gt;
&lt;li&gt;Every delivery carried Standard Webhooks signature headers; the receiver verified them with a short handler using the Svix SDK and rejected a forged payload with 401.&lt;/li&gt;
&lt;li&gt;Replay is an API call, not a project: resend one message, or recover everything that failed for one endpoint since a timestamp.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Node.js 22 and a Svix account (the free tier covers this whole exercise)&lt;/li&gt;
&lt;li&gt;A public HTTPS URL for the receiver. Svix Cloud rejects plain-HTTP endpoint URLs (&lt;code&gt;Endpoint URL schemes must be https when endpoint_https_only is set&lt;/code&gt;), so on a fresh VM we used Caddy with automatic TLS on an &lt;code&gt;sslip.io&lt;/code&gt; hostname (&lt;code&gt;157-230-57-75.sslip.io&lt;/code&gt; resolves to that IP, and Let's Encrypt issues for it)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm install&lt;/code&gt; in the demo repo&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A receiver built to fail
&lt;/h2&gt;

&lt;p&gt;The receiver is one file, one HTTP server, one path per failure mode. It records every request so we can compare its view with the sender's afterwards:&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;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accepted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/flaky&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fail the first two attempts of every message, succeed on the third.&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;temporary failure&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ratelimited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Push back with 429 + Retry-After on the first attempt only.&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;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&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="s2"&gt;retry-after&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slow down&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/slow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Never answer within the sender's timeout on the first attempt.&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;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;late&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="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n&lt;/code&gt; is the attempt count for this message id on this path, which the receiver tracks in memory so it can misbehave a fixed number of times per message. The &lt;code&gt;/ok&lt;/code&gt; path also does the thing a production receiver must do with at-least-once delivery: it remembers every &lt;code&gt;svix-id&lt;/code&gt; it has processed and acknowledges a redelivery without processing it again. Each case above also calls &lt;code&gt;record(...)&lt;/code&gt; so the log at &lt;code&gt;/attempts&lt;/code&gt; matches what the sender saw (trimmed here for length; the full file is in the repo).&lt;/p&gt;

&lt;p&gt;We exercised the dedup path by sending a second message and then forcing a manual resend of it to &lt;code&gt;/ok&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;receiver-side dedup&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node sender/replay.js resend /ok msg_3Ik0Jx7HzBt7aaXpgEe09l0InQV
&lt;span class="gp"&gt;resend requested for msg_3Ik0Jx7HzBt7aaXpgEe09l0InQV -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/ok
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs receiver | &lt;span class="nb"&gt;grep &lt;/span&gt;msg_3Ik0Jx | &lt;span class="nb"&gt;grep&lt;/span&gt; /ok | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-105&lt;/span&gt;
&lt;span class="go"&gt;{"at":"20:06:31.076Z","path":"/ok","status":200,"note":"accepted and processed"}
{"at":"20:06:41.039Z","path":"/ok","status":200,"note":"duplicate svix-id, ignored"}

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

&lt;/div&gt;



&lt;p&gt;Both deliveries got a 200, because from the sender's point of view both succeeded; only the first one did work. That is the shape of correct at-least-once consumption.&lt;/p&gt;

&lt;p&gt;Before any of that runs, every request passes signature verification (more on that below). Bad signature, 401, no processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the sender: three SDK methods
&lt;/h2&gt;

&lt;p&gt;One application, one endpoint per path, then read back each endpoint's signing secret so the receiver can verify:&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;await&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retries demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retries-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PATHS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ep&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retries-demo&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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PUBLIC_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&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;key&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;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSecret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retries-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// whsec_...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Sending is one call, with two different duplicate protections that are easy to confuse. &lt;code&gt;eventId&lt;/code&gt; is a uniqueness guard: we tested it, and a second create with the same &lt;code&gt;eventId&lt;/code&gt; is rejected with &lt;code&gt;msg_exists&lt;/code&gt;. The &lt;code&gt;idempotencyKey&lt;/code&gt; option (an &lt;code&gt;Idempotency-Key&lt;/code&gt; header on the wire) is what makes the create call itself safe to retry after a network blip: we sent the same key twice and got the same message id back both times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&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;svix&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="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retries-demo&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="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// unique per business event&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_1042&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sentAt&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="p"&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;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`send-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&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;// safe to retry the call&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;That single message fans out to all five endpoints. Here is what the receiver saw in the first eleven seconds (log lines condensed to time, path, status, and note; the full JSON lines are in the repo's &lt;code&gt;RESULTS.md&lt;/code&gt;, and this first message ran against the receiver before we added the &lt;code&gt;/ok&lt;/code&gt; dedup path, hence &lt;code&gt;accepted&lt;/code&gt; rather than &lt;code&gt;accepted and processed&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;receiver log&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs receiver | &lt;span class="nb"&gt;grep &lt;/span&gt;msg_3IjuoZ &lt;span class="c"&gt;# condensed&lt;/span&gt;
&lt;span class="go"&gt;{"at":"19:21:14.552Z","path":"/flaky","status":500,"note":"attempt 1: simulated outage"}
{"at":"19:21:14.555Z","path":"/dead","status":503,"note":"attempt 1: permanently down"}
{"at":"19:21:14.567Z","path":"/ok","status":200,"note":"accepted"}
{"at":"19:21:14.575Z","path":"/slow","status":0,"note":"attempt 1: holding the connection open (will time out)"}
{"at":"19:21:14.578Z","path":"/ratelimited","status":429,"note":"attempt 1: 429 with Retry-After: 60"}
{"at":"19:21:19.059Z","path":"/flaky","status":500,"note":"attempt 2: simulated outage"}
{"at":"19:21:19.152Z","path":"/dead","status":503,"note":"attempt 2: permanently down"}
{"at":"19:21:25.710Z","path":"/ratelimited","status":200,"note":"attempt 2: accepted after backoff"}

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

&lt;/div&gt;



&lt;p&gt;Five endpoints hit within 26 milliseconds of each other, the two immediate failures retried about 4.5 seconds later, and the rate-limited endpoint accepted its second attempt 11 seconds after the 429. Nothing in our code scheduled any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retry schedule, observed
&lt;/h2&gt;

&lt;p&gt;Svix's documented schedule is immediate, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours more: eight attempts spread over roughly 27 hours. We let the run continue and pulled the sender's own attempt log per endpoint:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm run report -- msg_3IjuoZxzSCwvwWsTpkqeZCF3zjD&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node sender/report.js msg_3IjuoZxzSCwvwWsTpkqeZCF3zjD
&lt;span class="go"&gt;/ok (1 attempts)
  19:21:14 http 200 success trigger=scheduled

/flaky (3 attempts)
  19:21:14 http 500 fail trigger=scheduled
  19:21:18 http 500 fail trigger=scheduled
  19:25:23 http 200 success trigger=scheduled

/ratelimited (2 attempts)
  19:21:14 http 429 fail trigger=scheduled
  19:21:25 http 200 success trigger=scheduled

/slow (2 attempts)
  19:21:14 http - fail trigger=scheduled request timed out
  19:22:49 http 200 success trigger=scheduled

/dead (4 attempts)
  19:21:14 http 503 fail trigger=scheduled
  19:21:18 http 503 fail trigger=scheduled
  19:26:17 http 503 fail trigger=scheduled
  19:56:45 http 503 fail trigger=scheduled

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

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;/flaky&lt;/code&gt; line: two failures, then success on the third attempt, which arrived about four minutes after the second failure (the documented interval for that slot is five minutes, measured from the previous failure). The receiver's own log agrees (&lt;code&gt;attempt 3: recovered&lt;/code&gt;). That is the entire transient-outage case, and it cost zero lines of retry code.&lt;/p&gt;

&lt;p&gt;Two details matter more than the happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt; was not honored.&lt;/strong&gt; Our receiver answered the first &lt;code&gt;/ratelimited&lt;/code&gt; attempt with &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;Retry-After: 60&lt;/code&gt;. The retry came 11 seconds later, on the sender's own schedule, not 60 seconds later. Svix documents no &lt;code&gt;Retry-After&lt;/code&gt; support, and this run confirms it. What Svix offers instead is sender-side: a per-endpoint rate limit (messages per second) you configure, and as of late August 2026, receiver-side response headers &lt;code&gt;webhook-delivery: abort-message&lt;/code&gt; (stop retrying this message) and &lt;code&gt;webhook-delivery: disable&lt;/code&gt; (stop sending to this endpoint). Those solve "stop" and "slow down in general", not "come back in exactly N seconds". If your consumers lean on &lt;code&gt;Retry-After&lt;/code&gt;, know this going in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts are counted as failures.&lt;/strong&gt; The &lt;code&gt;/slow&lt;/code&gt; endpoint held the connection open. The sender gave up (its documented limit is 15 seconds; our logs record the attempt start and the failure, not the exact cutoff), logged &lt;code&gt;request timed out&lt;/code&gt; with no HTTP status, and the retry landed at 19:22:49, about 95 seconds after the first attempt began. The second attempt succeeded because our receiver only misbehaves once per message. In production, a consumer that takes 20 seconds to process a webhook and then returns 200 has still failed from the sender's point of view; acknowledge fast, process later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signatures: the handler you must not skip
&lt;/h2&gt;

&lt;p&gt;Every delivery carries three headers: &lt;code&gt;svix-id&lt;/code&gt;, &lt;code&gt;svix-timestamp&lt;/code&gt;, and &lt;code&gt;svix-signature&lt;/code&gt;. They are Svix-branded aliases of the &lt;a href="https://www.standardwebhooks.com/" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; &lt;code&gt;webhook-*&lt;/code&gt; headers with identical values, so a Standard Webhooks library verifies them once you map the names (the Svix SDK accepts both spellings):&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;Webhook&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="s2"&gt;svix&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;wh&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;Webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// whsec_... from endpoint.getSecret()&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;wh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&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="s2"&gt;svix-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;svix-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;svix-timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;svix-timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;svix-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;svix-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two rules that hand-rolled code usually gets wrong: verify the raw request body exactly as received, never a re-serialized JSON object (one reordered key and the HMAC fails), and reject timestamps outside a tolerance window so a captured request cannot be replayed later; the SDK handles the second, the first is on you. The secret is per endpoint, which is why the setup script prints one &lt;code&gt;whsec_&lt;/code&gt; per path.&lt;/p&gt;

&lt;p&gt;We tested the negative path by posting a hand-built request with a forged &lt;code&gt;svix-signature&lt;/code&gt; to &lt;code&gt;/ok&lt;/code&gt;: the receiver logged &lt;code&gt;signature rejected: No matching signature found&lt;/code&gt;, answered 401, and nothing downstream ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dead endpoints and what happens after retries run out
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/dead&lt;/code&gt; returns 503 forever. We watched it take the first four scheduled attempts on the documented cadence: 19:21:14, 19:21:18 (5 s), 19:26:17 (5 min), and 19:56:45 (30 min); the 2-hour, 5-hour, and two 10-hour attempts were still ahead when we stopped recording. After the eighth failure the message is marked failed and Svix emits an operational webhook, &lt;code&gt;message.attempt.exhausted&lt;/code&gt;, to &lt;em&gt;you&lt;/em&gt;, the sender, so your own systems can react (open a ticket, email the customer). Endpoints that keep failing get disabled automatically, with an &lt;code&gt;endpoint.disabled&lt;/code&gt; event: per the docs, once an endpoint has failures at least 12 hours apart within a 24-hour window, five further days of nothing but failures trips the switch. Both behaviors are configurable per environment.&lt;/p&gt;

&lt;p&gt;Who carries that state is the difference between the two approaches. In the do-it-yourself version, every one of those transitions is a row you update, a job you schedule, and an alert you wire. Here it is a webhook you subscribe to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay: the feature you build third and need first
&lt;/h2&gt;

&lt;p&gt;The expensive failure is rarely a single bounced webhook; it is the consumer that was misconfigured for an hour and missed thousands of them. That needs two operations, and both are one API call each:&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;// resend one message to one endpoint&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageAttempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;APP_UID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_3IjuoZ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ep-dead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// recover every failed message for this endpoint since a point in time&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;APP_UID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ep-dead&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="na"&gt;since&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-01T19:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;We ran both against the dead endpoint at 19:58, right after its 30-minute attempt. Each produced a new delivery within seconds, and the attempt log tells them apart from the schedule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;replay&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node sender/replay.js resend /dead msg_3IjuoZxzSCwvwWsTpkqeZCF3zjD
&lt;span class="gp"&gt;resend requested for msg_3IjuoZxzSCwvwWsTpkqeZCF3zjD -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/dead
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node sender/replay.js recover /dead 2026-09-01T19:00:00Z
&lt;span class="go"&gt;recover started for /dead since 2026-09-01T19:00:00Z: { task: 'endpoint.recover', status: 'running' }
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node sender/report.js msg_3IjuoZxzSCwvwWsTpkqeZCF3zjD | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A7&lt;/span&gt; /dead
&lt;span class="go"&gt;/dead (6 attempts)
  19:21:14 http 503 fail trigger=scheduled
  19:21:18 http 503 fail trigger=scheduled
  19:26:17 http 503 fail trigger=scheduled
  19:56:45 http 503 fail trigger=scheduled
  19:58:40 http 503 fail trigger=manual
  19:58:50 http 503 fail trigger=manual

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

&lt;/div&gt;



&lt;p&gt;Your customers get the same two operations in the embeddable App Portal (Resend on a message, and "Recover Failed Messages" from a date on an endpoint) without a support ticket, and the &lt;code&gt;trigger=manual&lt;/code&gt; marker separates operator-initiated deliveries from scheduled ones in the audit trail. In this run the endpoint was still dead, so the replays failed too, which is the correct outcome: recovery re-delivers, it does not pretend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you did not have to build
&lt;/h2&gt;

&lt;p&gt;Tally the run against the list from the introduction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;POST /msg&lt;/strong&gt; your code: 1 call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fan-out&lt;/strong&gt; 5 endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry schedule&lt;/strong&gt; 8 attempts / 27h&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signatures&lt;/strong&gt; Standard Webhooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay + portal&lt;/strong&gt; API + UI&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry scheduler and state machine&lt;/strong&gt; : not built. Observed working across 500, 503, 429, and timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate protection&lt;/strong&gt; : &lt;code&gt;eventId&lt;/code&gt; uniqueness and &lt;code&gt;idempotencyKey&lt;/code&gt; on the send call; &lt;code&gt;svix-id&lt;/code&gt; dedup in the receiver, which stays your job under at-least-once delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing and verification&lt;/strong&gt; : SDK, standard headers, tested negative path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure escalation&lt;/strong&gt; : &lt;code&gt;message.attempt.exhausted&lt;/code&gt; and &lt;code&gt;endpoint.disabled&lt;/code&gt; operational webhooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay and recovery&lt;/strong&gt; : two API calls, also exposed to customers in the portal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt history for support&lt;/strong&gt; : &lt;code&gt;report.js&lt;/code&gt; is a short loop over the attempts API; the portal shows the same to the customer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you still own: fast acknowledgement and &lt;code&gt;svix-id&lt;/code&gt; deduplication on the receiving side, the decision of what to do when a customer's endpoint is exhausted, and, if your consumers need &lt;code&gt;Retry-After&lt;/code&gt; semantics, that gap. What we wrote for this run was the deliberately broken receiver, the verification handler, and about sixty lines of driver scripts; none of it was retry logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build or buy, with the run in front of you
&lt;/h2&gt;

&lt;p&gt;The DIY version is not hard to start and is hard to finish: the scheduler is small, the portal is not, and the operational edge cases (what does exhausted mean, who gets told, how does a customer self-serve a replay) are the part that keeps leaking into on-call. We covered the sender's side of this in depth in &lt;a href="https://dev.to/devopsdaily/what-it-actually-takes-to-deliver-a-webhook-in-production-4ghe"&gt;what it actually takes to deliver a webhook in production&lt;/a&gt;, including a working DIY implementation, so you can compare the two approaches line by line.&lt;/p&gt;

&lt;p&gt;If you also need the other direction, receiving other people's webhooks, the tradeoffs differ; our &lt;a href="https://devops-daily.com/comparisons/svix-vs-hookdeck" rel="noopener noreferrer"&gt;Svix vs Hookdeck comparison&lt;/a&gt; covers both directions and both vendors.&lt;/p&gt;

&lt;p&gt;The demo repo takes about ten minutes to set up against a free Svix account and a throwaway VM; letting the retry schedule play out to the 30-minute slot, as we did, takes about 45. Point it at your own receiver, break things your way, and read the attempt log. The retry code you were about to write is the part you can skip.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/stop-building-webhook-retries-yourself" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>reliability</category>
      <category>eventdriven</category>
      <category>node</category>
    </item>
    <item>
      <title>DNS Detective: an Agent That Diagnoses Your Domain by Actually Probing It</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/dns-detective-an-agent-that-diagnoses-your-domain-by-actually-probing-it-5fpg</link>
      <guid>https://dev.to/devopsdaily/dns-detective-an-agent-that-diagnoses-your-domain-by-actually-probing-it-5fpg</guid>
      <description>&lt;p&gt;Ask an LLM "why does mail to my domain bounce?" and you get a plausible list of everything that has ever caused a bounce. Ask an engineer, and they do something different: they run &lt;code&gt;dig&lt;/code&gt;, look at the answer, and let the evidence pick the next question. The difference is not knowledge; it is that the engineer is allowed to touch the network.&lt;/p&gt;

&lt;p&gt;So we gave the model the network. &lt;strong&gt;DNS Detective&lt;/strong&gt; is a small agent, running on &lt;a href="https://www.digitalocean.com/products/gradient" rel="noopener noreferrer"&gt;DigitalOcean Serverless Inference&lt;/a&gt;, that diagnoses DNS, TLS and email-record problems by calling real probe tools in a loop: resolve records, shake hands with TLS endpoints, pull registration data, fetch URLs. It probes, reads, probes again, and delivers a diagnosis where every claim cites a lookup it actually ran. The whole thing is about 300 lines of Python, and this post walks the build plus three real diagnoses recorded as they happened.&lt;/p&gt;

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

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

&lt;ul&gt;
&lt;li&gt;One tool-calling loop plus four probes (&lt;code&gt;dns_lookup&lt;/code&gt;, &lt;code&gt;tls_check&lt;/code&gt;, &lt;code&gt;rdap_lookup&lt;/code&gt;, &lt;code&gt;http_check&lt;/code&gt;) turns a chat model into a diagnostician that follows evidence instead of listing possibilities.&lt;/li&gt;
&lt;li&gt;On camera it solved three real mysteries: example.com's bouncing mail (a &lt;strong&gt;null MX&lt;/strong&gt; , &lt;code&gt;0 .&lt;/code&gt;), a monitoring alert on expired.badssl.com ( &lt;strong&gt;certificate expired 2015&lt;/strong&gt; , read from the offered cert after verification failed), and dnssec-failed.org's split behavior ( &lt;strong&gt;bogus DS record&lt;/strong&gt; , and the model noticed the DS digest is literally the ASCII for "broken chain of trust send help!").&lt;/li&gt;
&lt;li&gt;The system prompt's one law: never state a record you did not probe. One model we tried broke that law by roleplaying fake probe results and was disqualified; the section below shows why that test matters more than benchmarks.&lt;/li&gt;
&lt;li&gt;DigitalOcean's inference platform made the plumbing boring in the good way: OpenAI-compatible API, function calling, a model menu you switch with one env var.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Python 3.10+, &lt;code&gt;pip install dnspython&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://www.digitalocean.com/products/gradient" rel="noopener noreferrer"&gt;DigitalOcean Serverless Inference&lt;/a&gt; API key&lt;/li&gt;
&lt;li&gt;No infrastructure: the agent is one file, the probes run from wherever you run it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture is one loop
&lt;/h2&gt;

&lt;p&gt;There is no framework here. The agent is the classic function-calling loop: send the conversation plus tool definitions, and if the model responds with tool calls, run them, append the results, repeat; when it responds with text, that is the diagnosis.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Symptom&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model picks a probe&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Probe runs for real&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence appended&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The four probes are deliberately small and deliberately honest about failure modes, because the failure modes are the diagnosis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dns_lookup&lt;/code&gt;&lt;/strong&gt; distinguishes NXDOMAIN (domain missing) from NoAnswer (domain exists, record type missing) from NoNameservers (all servers refused, the classic DNSSEC tell) from a timeout. Collapsing those into "lookup failed" would lobotomize the detective.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tls_check&lt;/code&gt;&lt;/strong&gt; does a verifying handshake, and on failure does the thing a human would: re-reads the offered certificate without verification (read-only, &lt;code&gt;openssl s_client&lt;/code&gt; style) so it can report &lt;em&gt;what the server actually presented&lt;/em&gt; and why it was rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rdap_lookup&lt;/code&gt;&lt;/strong&gt; pulls registration data (registrar, expiry, status) over RDAP, the WHOIS successor, no API key needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http_check&lt;/code&gt;&lt;/strong&gt; fetches without following redirects, because the redirect itself is often the clue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one paragraph of system prompt does the character work: form a hypothesis, run the cheapest decisive probe next, and &lt;strong&gt;never state a record you did not probe&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three mysteries, on camera
&lt;/h2&gt;

&lt;p&gt;Everything below is real recorded output, straight from the agent's own trail (it prints each probe as it runs, so the diagnosis is auditable).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1: "mail to example.com bounces immediately."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dns-detective&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 detective.py &lt;span class="s2"&gt;"Customers say they cannot email us at example.com. Mail bounces immediately. Why?"&lt;/span&gt;
&lt;span class="go"&gt;  → dns_lookup(example.com, MX)
    {"status": "ok", "records": ["0 ."], "ttl": 100}
  → dns_lookup(example.com, A)
    {"status": "ok", "records": ["104.20.23.154", "172.66.147.243"], "ttl": 265}
DIAGNOSIS: example.com is configured with a null MX record (0 .), which explicitly tells the internet that it does not accept email, so all inbound mail is rejected immediately.

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

&lt;/div&gt;



&lt;p&gt;Two probes, done. The subtle part: a human junior often misreads &lt;code&gt;0 .&lt;/code&gt; as "some weird MX". The agent correctly identified a &lt;strong&gt;null MX&lt;/strong&gt; (RFC 7505), the record whose entire meaning is "this domain refuses mail on purpose", and did not go on a goose chase through SPF and server logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2: "monitoring says the site is down, the server looks up."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dns-detective&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 detective.py &lt;span class="s2"&gt;"Monitoring says https://expired.badssl.com is down for users but the server itself seems up."&lt;/span&gt;
&lt;span class="go"&gt;  → tls_check(expired.badssl.com, 443)
    {"handshake": "certificate_verification_failed", "verify_error": "certificate has expired", "offered_certificate": "...notAfter=Apr 12 23:59:59 2015 GMT..."}
DIAGNOSIS: The HTTPS service is reachable, but the TLS certificate expired on 12 April 2015, causing browsers and monitoring to reject the connection.

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

&lt;/div&gt;



&lt;p&gt;Note what the probe design bought here: verification failed, so the tool re-read the certificate unverified and handed the model the actual &lt;code&gt;notAfter&lt;/code&gt; date. "Down" became "up, but presenting a certificate that died in 2015", which is a different incident with a different fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 3: "site loads on one old server, nowhere else."&lt;/strong&gt; The split-behavior mystery, and the agent's best moment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dns-detective&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 detective.py &lt;span class="s2"&gt;"dnssec-failed.org will not load for anyone on our office network, but a curl from one old server fetches it fine."&lt;/span&gt;
&lt;span class="go"&gt;  → dns_lookup(dnssec-failed.org, A) {"status": "ok", "records": ["96.99.227.255"]}
  → dns_lookup(dnssec-failed.org, DNSKEY) {"status": "ok", "records": [two keys, tags 256 and 257]}
  → dns_lookup(dnssec-failed.org, DS) {"status": "ok", "records": ["42069 13 2 62726f6b656e..."]}
DIAGNOSIS: The DNSSEC chain is broken: the parent .org zone publishes a bogus DS record that does not match the domain's DNSKEY, so validating resolvers (the office network) reject the zone while the old server, which skips validation, resolves fine.

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

&lt;/div&gt;



&lt;p&gt;It resolved A (zone reachable), pulled the DNSKEYs, pulled the DS from the parent, concluded they cannot match, and explained why exactly the validating resolvers fail while the legacy one sails through. It even noticed that the DS digest is not a hash at all: the hex decodes to the ASCII string "broken chain of trust send help!", which is the fixture's inside joke, spotted by the model mid-diagnosis. That is evidence-following, not pattern-matching on the domain name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model that got disqualified
&lt;/h2&gt;

&lt;p&gt;Here is the part we would want to read in anyone else's agent post. Our first model choice narrated its tool calls as text instead of calling them, and then did something worse: it &lt;strong&gt;invented probe results&lt;/strong&gt;. "Let's say the MX lookup returned NoAnswer", it wrote, and proceeded to diagnose a hypothetical, complete with a made-up IP address, while the real answer (that null MX) sat unqueried.&lt;/p&gt;

&lt;p&gt;For a diagnostic agent this is the cardinal sin. A wrong diagnosis from real evidence is a bug; a confident diagnosis from imagined evidence is a hazard. So the test that actually selected our model was not a benchmark, it was: &lt;em&gt;give it a symptom and watch whether every record it cites exists in the probe log.&lt;/em&gt; The model that shipped (&lt;code&gt;openai-gpt-oss-120b&lt;/code&gt; on DigitalOcean's platform) passed on every case; the platform's model menu meant switching candidates was a one-line env var (&lt;code&gt;DETECTIVE_MODEL&lt;/code&gt;), which turned model selection into an experiment instead of a rewrite.&lt;/p&gt;

&lt;p&gt;That is also the general lesson for agent builders: &lt;strong&gt;grounding tools only help if fabrication is treated as disqualifying, and you only catch it by auditing the trail.&lt;/strong&gt; It is why the agent prints every probe as it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the platform part was boring, complimentarily
&lt;/h2&gt;

&lt;p&gt;The DigitalOcean side of this build is the part with nothing to debug, which is the compliment: an OpenAI-compatible endpoint (&lt;code&gt;inference.do-ai.run/v1&lt;/code&gt;), standard function calling, one bearer key, and a menu of models from multiple providers behind the same API. The whole integration is a &lt;code&gt;urllib&lt;/code&gt; request; no SDK, no framework. For agent experiments where the interesting decisions are the tools and the honesty constraints, a serverless per-token endpoint is exactly the right amount of infrastructure, and swapping models to run the fabrication test across candidates cost nothing but the tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it
&lt;/h2&gt;

&lt;p&gt;The repo is MIT and the pattern extends anywhere probes exist: an SMTP probe (connect to port 25, read the banner and the rejection message) would make the mail diagnosis end-to-end; a propagation probe (query several public resolvers and compare) would catch mid-migration states; and CI could run the detective against your own domains nightly, alerting when a diagnosis changes. If you build the SMTP one, our &lt;a href="https://smtpfa.st/tools" rel="noopener noreferrer"&gt;DNS record checkers&lt;/a&gt; cover the static half of that story already.&lt;/p&gt;

&lt;p&gt;The bigger point stands on its own: the gap between "LLM that talks about infrastructure" and "agent that inspects infrastructure" is four small functions and one rule about evidence. The tools are the easy part. The rule is the product.&lt;/p&gt;

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

</description>
      <category>ai</category>
      <category>dns</category>
      <category>digitalocean</category>
      <category>agents</category>
    </item>
    <item>
      <title>Getting a Row Change Out of Postgres Without Dual-Writing</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 01 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/getting-a-row-change-out-of-postgres-without-dual-writing-16p1</link>
      <guid>https://dev.to/devopsdaily/getting-a-row-change-out-of-postgres-without-dual-writing-16p1</guid>
      <description>&lt;p&gt;Somewhere in your codebase there is probably a function that does two things: saves a row to Postgres, then publishes an event about it to Kafka, RabbitMQ, or a webhook. It works in the demo, it works for months, and then a deploy restarts the process between the two calls, and now your database says the order exists while your event stream says it never happened. Every downstream consumer is now wrong, and nothing corrects it until a human writes a reconciliation job.&lt;/p&gt;

&lt;p&gt;That is the &lt;strong&gt;dual-write problem&lt;/strong&gt; , and it is not a bug you fix with retries. It is an architecture problem: without a distributed transaction spanning both systems (possible via two-phase commit, practical almost never), code that writes to both will eventually disagree with itself. The fix is to stop writing twice: make the database the single place a change happens, and derive the event stream from the database's own record of changes. This post walks the two honest ways to do that, the failure mode the second one hides (with a live demonstration of it eating disk), and the tooling landscape around it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Dual writes fail because there is no transaction across Postgres and your broker. Some interleaving of crash and retry always produces disagreement.&lt;/li&gt;
&lt;li&gt;Fix one: the &lt;strong&gt;transactional outbox&lt;/strong&gt;. Write the event into an outbox table in the same transaction as the data; a relay publishes from that table. The transaction buys agreement; the relay still needs retries and monitoring.&lt;/li&gt;
&lt;li&gt;Fix two: &lt;strong&gt;logical decoding&lt;/strong&gt; , Postgres's built-in change stream. A replication slot plus a decoder turns every committed INSERT/UPDATE/DELETE into consumable messages; no application changes at all.&lt;/li&gt;
&lt;li&gt;The catch: a replication slot pins WAL until decoding no longer needs it. In our live demo, an idle slot went from &lt;strong&gt;1,488 bytes to 45 MB of retained WAL&lt;/strong&gt; in under a minute, from traffic that had nothing to do with the tables it watched. Unmonitored, this fills the primary's disk.&lt;/li&gt;
&lt;li&gt;Guard with a &lt;code&gt;pg_replication_slots&lt;/code&gt; alert and &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;; then choose between running Debezium yourself or paying one of the managed CDC vendors.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Comfortable SQL and a rough idea of what the write-ahead log is (our &lt;a href="https://dev.to/devopsdaily/wal-as-the-source-of-truth-what-lakebase-storage-on-s3-means-for-you-3lbf"&gt;WAL deep dive&lt;/a&gt; is the perfect warm-up; this post is its practical sequel)&lt;/li&gt;
&lt;li&gt;A Postgres you can experiment on, with &lt;code&gt;wal_level = logical&lt;/code&gt; (we ran everything below on a scratch project on Neon, where it is a project setting)&lt;/li&gt;
&lt;li&gt;No Kafka required to follow along&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why dual-writing always loses
&lt;/h2&gt;

&lt;p&gt;The failing pattern, in its natural habitat:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO orders ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# write 1
&lt;/span&gt;    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# write 2, and the lie begins
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walk the interleavings. Crash after commit, before produce: database has the order, stream does not. Produce first instead? Crash after produce, before commit: stream announces an order that does not exist. Wrap it in retries: now a timeout that actually succeeded gets retried and the event publishes twice, or the retry queue dies with the pod. No ordering of two non-transactional writes survives every crash, because the two systems share no notion of "this happened".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;INSERT order&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;COMMIT&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;publish event&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Teams discover this the slow way: a reconciliation script somebody writes "temporarily" in year one that is load-bearing by year three. The permanent fixes both follow one principle: &lt;strong&gt;the database is the only writer, the stream is derived&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: the transactional outbox
&lt;/h2&gt;

&lt;p&gt;The outbox pattern moves the second write inside the transaction:&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="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ada'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"event": "order_created", "customer": "ada", "total": 42.50}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;One transaction, so either both rows exist or neither does. A small relay process polls the outbox (or, foreshadowing, tails it via CDC), publishes each row to the broker, and marks it done. Consumers must tolerate duplicates, because the relay can crash between publishing and marking, but duplicates are a solvable problem (idempotency keys); disagreement is not.&lt;/p&gt;

&lt;p&gt;The outbox is the right first tool: no exotic infrastructure, trivially auditable, and the event schema is explicit and versioned by you rather than mirroring your table structure. Be honest about what it buys, though: the transaction guarantees the outbox row matches the data, not that broker delivery is exactly-once. The relay still needs retries, ordering rules, cleanup, and monitoring, and the pattern only captures what your application chooses to record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: the database's own change stream
&lt;/h2&gt;

&lt;p&gt;Postgres already maintains a record of every committed row change to regular tables: the WAL. &lt;strong&gt;Logical decoding&lt;/strong&gt; exposes it as a consumable stream: you create a &lt;strong&gt;replication slot&lt;/strong&gt; , attach a decoder plugin, and Postgres hands you every committed change, in commit order, exactly where you left off.&lt;/p&gt;

&lt;p&gt;This is the part worth seeing rather than reading about. Everything below is a real recorded session:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;psql, wal_level = logical&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CREATE TABLE orders_cdc&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;serial PRIMARY KEY, customer text, total numeric, status text&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;CREATE TABLE
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT slot_name, lsn FROM pg_create_logical_replication_slot&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cdc_demo'&lt;/span&gt;, &lt;span class="s1"&gt;'test_decoding'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt; cdc_demo | 0/2990C78
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;INSERT INTO orders_cdc&lt;span class="o"&gt;(&lt;/span&gt;customer, total, status&lt;span class="o"&gt;)&lt;/span&gt; VALUES &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ada'&lt;/span&gt;, 42.50, &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;INSERT 0 1
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;UPDATE orders_cdc SET status &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt; WHERE customer &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ada'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;UPDATE 1
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;DELETE FROM orders_cdc WHERE customer &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ada'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;DELETE 1
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT lsn, data FROM pg_logical_slot_peek_changes&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cdc_demo'&lt;/span&gt;, NULL, NULL&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;0/2990EC8 | BEGIN 4098
0/2990F68 | table public.orders_cdc: INSERT: id[integer]:1 customer[text]:'ada' total[numeric]:42.50 status[text]:'pending'
0/29910C8 | COMMIT 4098
0/29910C8 | BEGIN 4099
0/29910C8 | table public.orders_cdc: UPDATE: id[integer]:1 ... status[text]:'shipped'
0/2991160 | COMMIT 4099
0/2991160 | BEGIN 4100
0/2991160 | table public.orders_cdc: DELETE: id[integer]:1
0/29911D8 | COMMIT 4100

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

&lt;/div&gt;



&lt;p&gt;There it is: three ordinary SQL statements came back out as a structured, ordered, transaction-delimited change stream, without the application writing a single event.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;test_decoding&lt;/code&gt; plugin above is the built-in demo decoder; real pipelines use &lt;code&gt;pgoutput&lt;/code&gt; (the protocol-native one) or &lt;code&gt;wal2json&lt;/code&gt;. Same session with a &lt;code&gt;wal2json&lt;/code&gt; slot, and the same insert becomes machine-readable (also real output):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;wal2json: the same stream as JSON&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pg_logical_slot_peek_changes('json_demo',&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;NULL,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;NULL,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'format-version',&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;');&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"B"&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="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"I"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"orders_cdc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"grace"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="err"&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="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"C"&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;Two function families matter here: &lt;code&gt;peek_changes&lt;/code&gt; reads without consuming (we used it above so the demos are re-runnable), while &lt;code&gt;get_changes&lt;/code&gt; consumes, advancing the slot's acknowledged position, which is what a real consumer does on every poll. One honest subtlety we hit while testing: after consuming, &lt;code&gt;restart_lsn&lt;/code&gt; (and so the retained-WAL number) does not drop instantly; Postgres advances it lazily once decoding no longer needs the older segments. Do not panic-tune based on a retention figure measured seconds after a catch-up. If you read &lt;a href="https://dev.to/devopsdaily/wal-as-the-source-of-truth-what-lakebase-storage-on-s3-means-for-you-3lbf"&gt;our WAL post&lt;/a&gt;, those LSNs are old friends: the stream's cursor is just a position in the log.&lt;/p&gt;

&lt;p&gt;One more piece the stream does not give you: the past. A slot starts at creation time, so a new consumer needs the &lt;strong&gt;initial snapshot problem&lt;/strong&gt; solved: copy the existing table contents first, then apply changes from the stream without a gap. Postgres supports this handoff properly (a slot creation can export a consistent snapshot to read the baseline from), and it is precisely the fiddly part that Debezium and the managed vendors have production-hardened; if you hand-roll a consumer, this is where the subtle bugs live.&lt;/p&gt;

&lt;p&gt;CDC's superpower over the outbox is completeness: every committed change to the captured tables, including the UPDATE someone runs by hand during an incident. The fine print: DDL and sequences are not part of the stream, UPDATE/DELETE detail depends on the table's REPLICA IDENTITY, a crash can redeliver recent changes (consumers still deduplicate), and you inherit the table schema as your event schema. Plus one sharp operational edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slot that eats your primary's disk
&lt;/h2&gt;

&lt;p&gt;A replication slot is a promise: Postgres keeps every WAL segment from the slot's &lt;code&gt;restart_lsn&lt;/code&gt; forward, the point decoding would need to resume, so a slow consumer can always catch up. (That can trail the consumer's acknowledged position when long transactions are open, which is why an actively streaming slot can still pin WAL.) Read it as an ops engineer: &lt;strong&gt;a slot that is not advancing forbids WAL cleanup, no matter whose WAL it is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Watch it happen. Same session, same idle &lt;code&gt;cdc_demo&lt;/code&gt; slot, and the traffic we generate touches a completely different table (a slot is database-scoped; even consumers that filter to a publication still cause all WAL to be retained until they advance):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the retained-WAL trap, live&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT slot_name, active, pg_size_pretty&lt;span class="o"&gt;(&lt;/span&gt;pg_wal_lsn_diff&lt;span class="o"&gt;(&lt;/span&gt;pg_current_wal_lsn&lt;span class="o"&gt;()&lt;/span&gt;, restart_lsn&lt;span class="o"&gt;))&lt;/span&gt; AS retained FROM pg_replication_slots WHERE slot_name &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cdc_demo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt; cdc_demo | f | 1488 bytes
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;20,000 rows into a completely unrelated table
&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;INSERT INTO bulk_junk&lt;span class="o"&gt;(&lt;/span&gt;payload&lt;span class="o"&gt;)&lt;/span&gt; SELECT repeat&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;, 1000&lt;span class="o"&gt;)&lt;/span&gt; FROM generate_series&lt;span class="o"&gt;(&lt;/span&gt;1, 20000&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;INSERT 0 20000
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT ... retained ...&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt; cdc_demo | f | 23 MB
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;UPDATE bulk_junk SET payload &lt;span class="o"&gt;=&lt;/span&gt; repeat&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;, 1000&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt;UPDATE 20000
&lt;/span&gt;&lt;span class="gp"&gt;neondb=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SELECT ... retained ...&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="go"&gt; cdc_demo | f | 45 MB

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

&lt;/div&gt;



&lt;p&gt;From 1,488 bytes to 45 MB of pinned WAL in under a minute, on a toy workload, from unrelated traffic. Now scale that to a production write rate and a CDC consumer that crashed on Friday evening: the primary's disk fills at your full WAL generation rate all weekend, and the incident that pages you says "database out of disk", nowhere near the actual culprit. This exact anatomy, a stalled consumer plus an unmonitored slot, is one of the classic self-inflicted Postgres outages.&lt;/p&gt;

&lt;p&gt;Two guards, both cheap:&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;-- Alert on this. An inactive slot with growing retention is a countdown.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;retained_wal&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Postgres 13+: cap how much WAL slots may pin (enforced at checkpoints,&lt;/span&gt;
&lt;span class="c1"&gt;-- so treat it as a strong limit, not an exact one). A slot that exceeds it&lt;/span&gt;
&lt;span class="c1"&gt;-- is invalidated instead of the primary dying; the consumer typically&lt;/span&gt;
&lt;span class="c1"&gt;-- re-snapshots, which is a bad day but not an outage.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_slot_wal_keep_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'10GB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;When diagnosing, look past &lt;code&gt;active&lt;/code&gt;: an active-but-lagging consumer pins WAL too. &lt;code&gt;wal_status&lt;/code&gt; and &lt;code&gt;safe_wal_size&lt;/code&gt; in &lt;code&gt;pg_replication_slots&lt;/code&gt; tell you how close to the cliff each slot is, and Postgres 18 adds &lt;code&gt;idle_replication_slot_timeout&lt;/code&gt; for automatic cleanup of abandoned slots.&lt;/p&gt;

&lt;p&gt;And the operational rule behind both: &lt;strong&gt;a replication slot is a consumer contract, not a fire-and-forget resource.&lt;/strong&gt; Create it when the consumer exists, monitor it like a queue, drop it when the consumer is decommissioned. (We dropped ours right after the recording; the demo project thanks us.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The landscape: run it or rent it
&lt;/h2&gt;

&lt;p&gt;The protocol layer is standard Postgres, so the build-vs-buy question is about the pipeline around it: snapshotting existing data, schema change handling, delivery into your broker or warehouse, and babysitting the slots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it yourself: &lt;a href="https://debezium.io/" rel="noopener noreferrer"&gt;Debezium&lt;/a&gt;&lt;/strong&gt; is the open source standard: usually a Kafka Connect connector, though Debezium Server delivers to non-Kafka sinks too. It handles initial snapshots and the common schema-change cases, and has seen every edge case in production somewhere. The cost is operating that machinery, and the slot monitoring above becomes your pager's problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rent the pipeline&lt;/strong&gt; (examples, not a census; the build/rent line blurs since several offer self-hosted versions): &lt;a href="https://estuary.dev/" rel="noopener noreferrer"&gt;Estuary&lt;/a&gt; does real-time CDC into warehouses and streams with a managed backfill story; &lt;a href="https://sequinstream.com/" rel="noopener noreferrer"&gt;Sequin&lt;/a&gt; is Postgres-native CDC aimed at developers who want changes as HTTP/streams without Kafka at all; &lt;a href="https://www.artie.com/" rel="noopener noreferrer"&gt;Artie&lt;/a&gt; focuses on low-latency Postgres-to-warehouse replication; &lt;a href="https://www.striim.com/" rel="noopener noreferrer"&gt;Striim&lt;/a&gt; sells the enterprise end with decades of database-replication lineage; and &lt;a href="https://airbyte.com/" rel="noopener noreferrer"&gt;Airbyte&lt;/a&gt; wraps Debezium for the batch-leaning integration crowd. They differentiate on destinations, latency, and how much of the slot babysitting they absorb; all of them exist because that babysitting is real work. (Confluent's managed connectors and the clouds' native CDC services compete here too.)&lt;/p&gt;

&lt;p&gt;The honest decision guide: if the events feed one warehouse nightly, a plain &lt;code&gt;updated_at&lt;/code&gt; polling job is still legitimate and nobody should shame you for it. If your application needs to emit domain events it controls, start with the outbox. If you need every change, or changes from tables your code does not own, that is CDC, and the choice between Debezium and a managed pipeline is the choice of who wakes up for the slot alert.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find your dual writes.&lt;/strong&gt; Grep for commit-then-publish patterns; each one is a consistency bug with an unknown detonation date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopt the outbox for domain events.&lt;/strong&gt; Same transaction or it did not happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you deploy CDC, deploy the slot monitor the same day.&lt;/strong&gt; The &lt;code&gt;pg_replication_slots&lt;/code&gt; query above, alerted at a threshold well below your disk headroom, plus &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; as the backstop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat slots as consumer contracts&lt;/strong&gt; with a lifecycle, an owner, and a decommissioning step.&lt;/li&gt;
&lt;li&gt;And if Kafka entered the chat while you read this: &lt;a href="https://dev.to/devopsdaily/6-apache-kafka-use-cases-and-when-you-do-not-need-kafka-4bki"&gt;our guide to when you actually need it&lt;/a&gt; pairs well here, because "transport for CDC events" is one of the six cases where it genuinely earns its keep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/postgres-cdc-without-dual-writing" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>cdc</category>
      <category>kafka</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Omarchy 4 Makes the Linux Desktop Feel Like a Product, Finally</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/omarchy-4-makes-the-linux-desktop-feel-like-a-product-finally-3hm3</link>
      <guid>https://dev.to/devopsdaily/omarchy-4-makes-the-linux-desktop-feel-like-a-product-finally-3hm3</guid>
      <description>&lt;p&gt;"The year of the Linux desktop" has been a punchline for two decades, and the punchline always had the same explanation: nobody with product taste and staying power ever owned the whole experience. Distros assembled parts; nobody curated them. That is exactly the gap &lt;a href="https://omarchy.org/" rel="noopener noreferrer"&gt;Omarchy&lt;/a&gt; was built to fill, and with August's 4.0 release, "Quattro", it is getting hard to keep laughing at the old joke.&lt;/p&gt;

&lt;p&gt;Omarchy is David Heinemeier Hansson's opinionated, Arch-based Linux for developers: Hyprland tiling, one keyboard-driven workflow, every default chosen on purpose. What started in 2025 as one famous developer ricing his laptop in public has turned into something with real institutional weight, and Quattro (shipped August 14) is the release where that shows.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quattro rewrote the entire desktop shell in Quickshell&lt;/strong&gt; : bar, launcher, menus, notifications, lock screen, one coherent, themed, scriptable process instead of a federation of independent tools, running under 300 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ISO dropped under 6 GB&lt;/strong&gt; (more than a gigabyte smaller) and installs got 30%+ faster; sub-minute installs are possible on fast hardware. &lt;strong&gt;Dual boot with Windows&lt;/strong&gt; (with full LUKS encryption) finally landed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coding agents are system citizens&lt;/strong&gt; : nine pre-wired (Claude Code, Codex, Gemini CLI, Copilot CLI and more), a system-wide default you pick once, agent status in the top bar, and crash diagnosis that routes to your agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Omacom Foundation launched with $8M&lt;/strong&gt; from eight patrons including Tobi Lütke, Patrick Collison, Michael Dell, Jack Dorsey and Matthew Prince, since grown past $10M. Hardware vendors are engaging, with Framework support among the reported wins.&lt;/li&gt;
&lt;li&gt;The same simplicity philosophy extends naturally to the server side, which is where the rest of your stack gets to stay boring too.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;None to read this. To try Omarchy: a spare machine or partition, comfort with the idea of a tiling window manager, and about a minute of installation, apparently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell rewrite is the headline
&lt;/h2&gt;

&lt;p&gt;Pre-4.0 Omarchy was, under the hood, what every polished Linux setup is: a carefully configured federation. Waybar here, a launcher there, a notification daemon, each themed into agreement but still separate programs around the Hyprland compositor.&lt;/p&gt;

&lt;p&gt;Quattro replaces the federation with a single long-running shell built on &lt;a href="https://quickshell.org/" rel="noopener noreferrer"&gt;Quickshell&lt;/a&gt; (a Qt Quick toolkit for building desktop components): bar, launcher, menus, notifications, on-screen displays, control panels, lock screen and polkit agent in one coherent, IPC-scriptable process with a plugin architecture, running in under 300 MB.&lt;/p&gt;

&lt;p&gt;If you have ever maintained a hand-rolled tiling setup, you know why this matters. The federation approach means every theme change touches five config formats and every component upgrade can break the seams. One process, one theme system (expanded from 8 to 24 palette colors in this release), one scripting surface: this is the difference between a collection of dotfiles and an actual product. It is also, notably, the kind of consolidation only a project with a single opinionated owner ships, because every component it replaced has its own community that would have voted no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installs measured in seconds, and dual boot at last
&lt;/h2&gt;

&lt;p&gt;The whole install story got the product treatment too: the ISO shrank by over a gigabyte to under 6 GB, installation sped up more than 30%, and on fast hardware a full install lands in under a minute. For a distro whose pitch includes "reinstalling is cheap, your config is code", making the install nearly free is not vanity, it is the philosophy made concrete.&lt;/p&gt;

&lt;p&gt;Quattro also added the feature whose absence kept many people at the door: &lt;strong&gt;dual boot&lt;/strong&gt;. A free-space install alongside Windows, with full LUKS disk encryption, so trying Omarchy no longer means sacrificing a machine to it. (You shrink the Windows partition and disable BitLocker first; the full-disk path still wipes the drive it is pointed at.) For the "I would try it but I need my Windows partition" crowd, the excuse is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents as system citizens
&lt;/h2&gt;

&lt;p&gt;Here is the part most relevant to how development actually changed in the last two years. Every OS treats coding agents as apps you happen to run in a terminal. Omarchy 4 treats them as part of the system: nine agents pre-wired as lazy-loaded launchers (Claude Code, OpenAI Codex, OpenCode, Gemini CLI, GitHub Copilot CLI, Crush, Grok CLI, Pi, Oh My Pi), a system-wide default you set once (&lt;code&gt;omarchy default agent claude&lt;/code&gt;), and then the OS routes agent-shaped work accordingly.&lt;/p&gt;

&lt;p&gt;The details are where it gets genuinely clever: agent state lives in the top bar (including plan limits and token burn), a multiplexer tracks whether agents are idle, working, blocked or done, and when something on the system crashes, Omarchy can hand the diagnosis to your default agent, with a built-in skill that knows how to reconfigure the OS itself. That last one is quietly a big idea: the operating system shipping first-party context for the AI that maintains it.&lt;/p&gt;

&lt;p&gt;Agree or not with every choice, this is the first OS-level answer to a question every developer now has: where do agents live in my environment? Everyone else is leaving it to terminal multiplexers and muscle memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money, governance, and hardware taking it seriously
&lt;/h2&gt;

&lt;p&gt;The reason to take Omarchy seriously as more than a famous developer's dotfiles is what happened around the software in August. DHH launched the &lt;strong&gt;Omacom Foundation&lt;/strong&gt; with $8 million from eight founding patrons, and the list reads like a who's-who with skin in the developer-tools game: Tobi Lütke (Shopify), Patrick Collison (Stripe), Michael Dell, Jack Dorsey, Matthew Prince (Cloudflare), Brendan Iribe, Jason Fried, and DHH himself, with funding since passing $10 million as more patrons joined. The foundation holds the trademarks, funds infrastructure, and, importantly, supports the upstream open-source projects Omarchy depends on, Hyprland and Quickshell included.&lt;/p&gt;

&lt;p&gt;Hardware is responding too: Framework has been reported as officially supporting Omarchy, and work has surfaced on tuning for current Dell machines. A Linux desktop with a taste dictator, a war chest, upstream funding, and OEM attention is a combination the ecosystem has simply never had before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the servers fit
&lt;/h2&gt;

&lt;p&gt;One more observation, because this is a DevOps site: Omarchy's appeal is a philosophy, not just a theme pack. Fewer moving parts, defaults chosen by someone with taste, tools you can hold in your head. Developers who feel that pull on their workstation tend to want the same thing one layer up, which is why this crowd so often pairs a setup like Omarchy with deliberately simple infrastructure: a few droplets on DigitalOcean, Docker Compose, boring DNS, rather than a hyperscaler console with four hundred services. (It is the same instinct we leaned on when we &lt;a href="https://devops-daily.com/posts/coolify-self-hosted-paas-digitalocean" rel="noopener noreferrer"&gt;self-hosted a PaaS on DigitalOcean with Coolify&lt;/a&gt;: own your tools, keep the stack legible.) DHH's crusade against accidental complexity does not stop at the desktop, and neither should yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you try it?
&lt;/h2&gt;

&lt;p&gt;If you live in a terminal, like keyboard-driven everything, and have wanted a Linux desktop that feels decided rather than assembled: yes, and Quattro is the right moment, because dual boot removed the commitment problem and the sub-minute install removed the time problem. If you need mainstream desktop conventions or hate tiling, it is deliberately not for you, and Omarchy would be the first to say so; opinionated software earns its coherence by not negotiating.&lt;/p&gt;

&lt;p&gt;Either way, it is worth watching. The Linux desktop's chronic problem was never capability, it was curation, and for the first time in a long time someone with taste, money, and an audience is doing the curating in public, shipping monthly, and dragging hardware vendors along. The old joke needed retiring anyway.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/omarchy-4-quattro-developer-workstation" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>omarchy</category>
      <category>workstations</category>
      <category>ai</category>
    </item>
    <item>
      <title>5 DevOps Newsletters Actually Worth Your Inbox</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:01:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/5-devops-newsletters-actually-worth-your-inbox-5a8e</link>
      <guid>https://dev.to/devopsdaily/5-devops-newsletters-actually-worth-your-inbox-5a8e</guid>
      <description>&lt;p&gt;The problem with staying current in DevOps is not finding information, it is that the firehose never stops. Vendor blogs push product news dressed as insight, aggregators recycle the same ten links, and the genuinely important stuff (a deprecation that breaks your cluster, a postmortem worth studying) arrives mixed into the noise.&lt;/p&gt;

&lt;p&gt;A small number of newsletters have solved this by having an actual human with actual taste do the filtering. Here are the five worth your inbox, starting with ours, because we are hardly going to put it last.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. DevOps Daily
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://devops-daily.com/newsletters" rel="noopener noreferrer"&gt;devops-daily.com/newsletters&lt;/a&gt; · weekly, Mondays&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One Monday email with everything that happened week. That means hands-on posts (fact-checked release coverage like our &lt;a href="https://devops-daily.com/posts/kubernetes-1-37-garhwal-what-shipped" rel="noopener noreferrer"&gt;Kubernetes 1.37 deep dive&lt;/a&gt;, scale stories, build logs with real recorded output), plus interactive simulators, quizzes and guides, so most issues include at least one thing you can practice in the browser rather than just read. The whole &lt;a href="https://devops-daily.com/newsletters" rel="noopener noreferrer"&gt;archive is public&lt;/a&gt;, so you can judge every past issue before handing over an email address, which is how every newsletter on this list deserves to be evaluated.&lt;/p&gt;

&lt;p&gt;Best for: people who learn by doing, not just reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. DevOps Weekly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.devopsweekly.com/" rel="noopener noreferrer"&gt;devopsweekly.com&lt;/a&gt; · weekly, Sundays&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gareth Rushgrove has been curating this since 2010, which makes it one of the longest continuously running newsletters in the space, and the longevity shows in the selection. No commentary, no fluff, just eight to twelve links per issue spanning tools, practices and write-ups, chosen by someone who has watched every hype cycle come and go.&lt;/p&gt;

&lt;p&gt;Best for: broad, trend-resistant coverage of the whole field.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SRE Weekly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://sreweekly.com/" rel="noopener noreferrer"&gt;sreweekly.com&lt;/a&gt; · weekly, 530+ issues&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lex Neva's roundup leans into reliability: incident postmortems, outage analyses, on-call culture, resilience engineering. The standout feature is the outage section at the end of every issue, a running log of who went down this week, which over months becomes a quiet education in how systems actually fail. If postmortems are your favorite genre (they are ours: &lt;a href="https://devops-daily.com/posts/the-postmortem-nobody-reads" rel="noopener noreferrer"&gt;the postmortem nobody reads&lt;/a&gt;), this is that interest as a weekly habit.&lt;/p&gt;

&lt;p&gt;Best for: incident-minded engineers and anyone with a pager.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. KubeWeekly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.cncf.io/kubeweekly/" rel="noopener noreferrer"&gt;cncf.io/kubeweekly&lt;/a&gt; · weekly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The CNCF's official Kubernetes newsletter: upcoming releases, KEPs moving stages, ecosystem project updates, and community content. It is the reliable way to hear that a deprecation is coming for you before it lands in release notes you were not reading.&lt;/p&gt;

&lt;p&gt;Best for: anyone operating Kubernetes in anger.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Last Week in AWS
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.lastweekinaws.com/" rel="noopener noreferrer"&gt;lastweekinaws.com&lt;/a&gt; · weekly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Corey Quinn reads all of AWS's announcements so you do not have to, and then says what he actually thinks about them, which no vendor newsletter will ever do. The snark is the delivery mechanism; the payload is genuinely sharp analysis of pricing, product strategy and which announcements matter. Even if you are not on AWS, it is the best example of what opinionated curation looks like.&lt;/p&gt;

&lt;p&gt;Best for: AWS users, FinOps people, and anyone who enjoys their cloud news with a spine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: TLDR DevOps, for the daily skimmers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://tldr.tech/devops" rel="noopener noreferrer"&gt;tldr.tech/devops&lt;/a&gt; · daily&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your reading time is five minutes over coffee, a short daily digest with two-sentence summaries beats a weekly deep read you never get to. It pairs well with one or two of the weeklies above rather than replacing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually use newsletters
&lt;/h2&gt;

&lt;p&gt;Advice earned by unsubscribing dozens of times: pick at most one daily and two weeklies, or the backlog guilt kills the habit. Read the outage and postmortem sections first, they compound fastest. And re-evaluate quarterly: a newsletter that no longer earns its open is a newsletter to leave, including any of the ones above, including ours.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>learning</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>How Discord Stores Trillions of Messages With a Tiny Team</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Sat, 29 Aug 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/how-discord-stores-trillions-of-messages-with-a-tiny-team-2p00</link>
      <guid>https://dev.to/devopsdaily/how-discord-stores-trillions-of-messages-with-a-tiny-team-2p00</guid>
      <description>&lt;p&gt;Some engineering stories are worth studying because the numbers are absurd, and some because the lessons transfer. Discord's message-storage story, told across their own engineering posts (&lt;a href="https://discord.com/blog/how-discord-stores-billions-of-messages" rel="noopener noreferrer"&gt;2017&lt;/a&gt;, &lt;a href="https://discord.com/blog/how-discord-stores-trillions-of-messages" rel="noopener noreferrer"&gt;2023&lt;/a&gt;), is both: trillions of stored messages, migrated live in nine days, by a team small enough to fit around one table. All numbers below come from those two posts.&lt;/p&gt;

&lt;p&gt;The arc in one paragraph: in 2017 Discord ran 12 Cassandra nodes storing billions of messages. By early 2022 that had grown to 177 nodes storing trillions, and the cluster was hurting in ways that paged humans. In 2022 they moved everything to ScyllaDB, ending at 72 nodes of 9TB each, with p99 read latency dropping from a wandering 40-125ms to a steady 15ms. Fewer nodes, more data, an order of magnitude calmer tail.&lt;/p&gt;

&lt;p&gt;The migration headline is fun, but the durable lessons live in the data model, the three problems that forced the migration, and the thing they built that was not a database at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the data model that carried them
&lt;/h2&gt;

&lt;p&gt;The 2017 chapter starts where most scaling stories do: the original database hit a wall. Discord launched on a single MongoDB replica set, and by November 2015, at 100 million messages, the data and indexes no longer fit in RAM and latency went unpredictable. Their traffic made it worse than it sounds: reads and writes were roughly 50/50, and reads were highly random, which is the workload page caches hate most.&lt;/p&gt;

&lt;p&gt;The move to Cassandra came with the design decision the whole story rests on. Messages are identified by Snowflake IDs (Twitter's chronologically sortable 64-bit IDs), so the natural key was &lt;code&gt;(channel_id, message_id)&lt;/code&gt;: all of a channel's messages in one partition, sorted by time for free. Then the import taught them the classic wide-partition lesson: big channels blew past 100MB per partition, and giant partitions meant GC pressure and compaction pain. Cassandra advertises support for 2GB partitions; Discord's write-up delivers one of the great one-liners of database operations: just because it can be done does not mean it should.&lt;/p&gt;

&lt;p&gt;The fix was &lt;strong&gt;time bucketing&lt;/strong&gt;. They measured their largest channels and found that 10 days of messages stayed comfortably under 100MB, so the key became &lt;code&gt;((channel_id, bucket), message_id)&lt;/code&gt;, where the bucket is derived from the timestamp. Partition size is now bounded no matter how big a channel gets, and quiet channels just query a few sequential buckets.&lt;/p&gt;

&lt;p&gt;That key design is the most reusable artifact in the whole saga. "Partition by tenant" is where everyone starts; "partition by tenant plus a bounded time window" is where high-write systems end up, and getting there before the import, rather than six months into production, is the cheap version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the hot partition
&lt;/h2&gt;

&lt;p&gt;Discord partitions messages by channel (plus a time bucket), which distributes load beautifully as long as channels are similarly busy. They are not. A three-friend server generates orders of magnitude less traffic than a two-hundred-thousand-person community, and when something happens in a huge channel, a flood of concurrent reads lands on the one partition that holds it.&lt;/p&gt;

&lt;p&gt;That is a &lt;strong&gt;hot partition&lt;/strong&gt; , and its signature is the nasty part: the node serving the hot partition slows down, queues back up, and every other partition on that node gets slow too. Latency spreads sideways to users who have nothing to do with the busy channel. The failure is invisible in averages, obvious in the tail, and it is the same mechanism whether you run 177 nodes or a single Postgres with one viral customer row. (Our &lt;a href="https://devops-daily.com/games/latency-percentiles-simulator" rel="noopener noreferrer"&gt;latency percentiles simulator&lt;/a&gt; shows exactly this signature: a healthy median over a growing tail.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the garbage collector
&lt;/h2&gt;

&lt;p&gt;Discord's Cassandra cluster ran on the JVM, and the JVM stops the world to collect garbage. At their read/write volume, GC pauses produced latency spikes big enough to page people, and in bad cases nodes needed manual reboots to recover.&lt;/p&gt;

&lt;p&gt;The general lesson is not "avoid Java". It is that at the tail, &lt;strong&gt;your database's runtime is part of your latency budget&lt;/strong&gt;. p99 problems that correlate with nothing in your query patterns often live a layer down: GC, compaction, page cache pressure. ScyllaDB being a C++ rewrite of Cassandra with no GC was a major reason it was the destination; the shape of their p99 graph before and after says the diagnosis was right:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Message read latency, p99&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cassandra, worst observed p99&lt;/td&gt;
&lt;td&gt;125ms&lt;/td&gt;
&lt;td&gt;Cassandra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cassandra, best observed p99&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;td&gt;Cassandra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScyllaDB p99&lt;/td&gt;
&lt;td&gt;15ms&lt;/td&gt;
&lt;td&gt;ScyllaDB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Numbers from Discord's 2023 engineering post: p99 reads went from a 40-125ms range on Cassandra to a steady 15ms on ScyllaDB. Inserts went from 5-70ms to a stable 5ms.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: maintenance that becomes a lifestyle
&lt;/h2&gt;

&lt;p&gt;The third pain was compaction falling behind. Cassandra compacts SSTables in the background, and once a cluster falls behind under load, operators start doing what Discord called a gossip dance: pull a node out of rotation so it can compact in peace, bring it back, let it catch up on hints, repeat, node after node.&lt;/p&gt;

&lt;p&gt;Every ops team knows some version of this: a routine background process that quietly becomes a manual, rotating chore. The lesson is diagnostic: &lt;strong&gt;when babysitting a system becomes a recurring calendar event, the system is telling you its design no longer fits your load.&lt;/strong&gt; Discord's answer was not better runbooks; it was removing the reason the runbook existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tombstone wars
&lt;/h2&gt;

&lt;p&gt;Deletes deserve their own chapter, because in log-structured databases a delete is not a removal, it is a &lt;strong&gt;tombstone&lt;/strong&gt; : a marker written on top, reconciled at read time, cleaned up later by compaction. Discord ran into both of the classic tombstone disasters, five years apart.&lt;/p&gt;

&lt;p&gt;The first was self-inflicted and invisible: their writer sent null values for unset columns, and Cassandra treats a null write as a delete. Result: about &lt;strong&gt;12 tombstones written per average message&lt;/strong&gt; , pure overhead, fixed by simply not writing nulls. The generalizable habit is knowing what your driver actually emits, because ORMs and serializers make this class of mistake silently.&lt;/p&gt;

&lt;p&gt;The second is the famous one. Six months after launch, a node started running ten-second stop-the-world GC pauses. The cause was one channel, a Puzzles &amp;amp; Dragons subreddit server, that had deleted its way down to &lt;strong&gt;one visible message sitting on top of millions of tombstones&lt;/strong&gt;. Every load of that channel made Cassandra wade through the graveyard to find the survivor. The mitigation: cut tombstone lifetime from 10 days to 2 (with nightly repairs to make that safe) and track empty buckets so queries skip them entirely.&lt;/p&gt;

&lt;p&gt;Tombstones also close the loop on the 2022 migration: the final blocker before the ScyllaDB migrator could finish was compacting gigantic tombstone ranges in Cassandra. The deletes of 2017 were still shaping operations five years later, which is the most honest definition of technical debt you will find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part everyone skips: the layer in front
&lt;/h2&gt;

&lt;p&gt;Here is the piece that transfers to every stack, at every scale. Before migrating anything, Discord built &lt;strong&gt;data services&lt;/strong&gt; : a Rust layer that sits between the API and the database, whose star feature is &lt;strong&gt;request coalescing&lt;/strong&gt;. When a thousand users request the same message row at once (exactly what a hot channel produces), the service makes one database query and fans the result out to all thousand waiters. Consistent hash routing by channel ID sends all traffic for a channel to the same service instance, so coalescing actually catches the duplicates.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;API clients&lt;/strong&gt; 1,000 identical reads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data service&lt;/strong&gt; Rust, coalesces to 1 query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ScyllaDB&lt;/strong&gt; sees 1 read, not 1,000&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fan-out&lt;/strong&gt; one result, 1,000 answers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what this means: the hot-partition problem was partially solved &lt;strong&gt;before the database changed&lt;/strong&gt; , by making the database see less of the load. That ordering is the real architecture lesson. The database swap fixed GC and compaction; the protective layer fixed the traffic shape. Teams reach for a migration first because it feels decisive, but the layer in front is cheaper, lower-risk, and usually where the win is. At normal scale this same idea is a cache with request deduplication, or a materialized read path; the principle is identical.&lt;/p&gt;

&lt;p&gt;Their storage hardware story rhymes with this: cloud persistent disks had the durability but not the latency, so they built "super-disks": local NVMe for speed, RAID-mirrored to persistent disks for durability. Same pattern again: keep the slow-but-safe thing, put a fast layer in front of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coalescing is small enough to build yourself
&lt;/h3&gt;

&lt;p&gt;The idea sounds exotic at Discord's scale and is almost embarrassingly small in code. Here is the whole mechanism, runnable as-is: keep a map of in-flight requests per key, and make duplicate callers await the existing one.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;db_queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;db_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;db_queries&lt;/span&gt;
    &lt;span class="n"&gt;db_queries&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# one slow database read
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Coalescer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# someone already asked: wait for theirs
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;db_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task&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="n"&gt;task&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inflight&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Fire a hot-channel burst at it, with and without coalescing (this is a real run, not sketched output):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;request coalescing&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 coalesce.py
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;1,000 concurrent reads of the same key, through the coalescer
&lt;span class="go"&gt;clients served: 1000, database queries: 1
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;same 1,000 reads, no coalescing
&lt;span class="go"&gt;clients served: 1000, database queries: 1000

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

&lt;/div&gt;



&lt;p&gt;One thousand callers, one database query. In Go this is &lt;code&gt;singleflight&lt;/code&gt; from the standard extended library; in most stacks it is twenty lines. If your system has any hot-key read pattern, this is among the highest ratio of latency saved to code written that exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration itself
&lt;/h2&gt;

&lt;p&gt;The plan was to migrate with ScyllaDB's Spark-based migrator, estimated at three months. They did not want to babysit a migration for a quarter, so they rewrote the migrator in Rust, and the estimate fell to &lt;strong&gt;nine days&lt;/strong&gt; , running at up to 3.2 million messages per second, with the last obstacle being enormous tombstone ranges in Cassandra that needed compacting before they would move.&lt;/p&gt;

&lt;p&gt;Two things worth keeping from that: first, migration tooling is code, and investing engineer-weeks in it can buy back engineer-months of supervised risk. Second, the messages moved while Discord kept running; the era where a migration of this size implied a maintenance window is simply over, and your users' expectations know it.&lt;/p&gt;

&lt;p&gt;Worth stealing from the 2017 playbook too: before Cassandra went primary, they ran a &lt;strong&gt;dark launch&lt;/strong&gt; , double-writing to MongoDB and Cassandra while reads still came from the old system. It surfaced a genuinely subtle bug before users could: concurrent edits and deletes, racing under Cassandra's last-write-wins conflict resolution, could resurrect corpses of deleted messages as corrupted rows with only a primary key and text. The fix (delete any message missing required columns like the author) is less important than the pattern: double-write early, read-compare quietly, and let the race conditions introduce themselves while the blast radius is zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you are not Discord
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find your hot partitions before they find you.&lt;/strong&gt; Whatever your store, some key is orders of magnitude hotter than the median. Know which, and know what happens to neighbors when it spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chase tail latency into the runtime.&lt;/strong&gt; If p99 spikes do not correlate with queries, look at GC, compaction, and background maintenance. The database's internals are part of your SLO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the protective layer before the migration.&lt;/strong&gt; Coalescing, caching, and read-path shaping change what the database experiences, at a fraction of a migration's risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat recurring manual maintenance as a design signal&lt;/strong&gt; , not an ops failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a migration is unavoidable, make the tooling fast enough to be boring.&lt;/strong&gt; Nine supervised days beat ninety.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deeper pattern in this story is that storage-engine design decides operational reality: Discord's pain (GC, compaction, tombstones) and Discord's wins (coalescing, super-disks) all live below the query layer. If that angle interests you, we recently went deep on another example of it: &lt;a href="https://devops-daily.com/posts/wal-as-the-source-of-truth-lakebase-storage-s3" rel="noopener noreferrer"&gt;how Lakebase Postgres, the storage architecture you get on Neon, makes the WAL itself the database&lt;/a&gt;, where the same kind of architectural bet makes branching and point-in-time restore nearly free instead of heroic.&lt;/p&gt;

&lt;p&gt;Discord's own posts are worth reading in full: &lt;a href="https://discord.com/blog/how-discord-stores-billions-of-messages" rel="noopener noreferrer"&gt;2017's billions&lt;/a&gt; for the data-model thinking, and &lt;a href="https://discord.com/blog/how-discord-stores-trillions-of-messages" rel="noopener noreferrer"&gt;2023's trillions&lt;/a&gt; for everything above. For the hands-on version of the concepts, our &lt;a href="https://devops-daily.com/games/message-queue-simulator" rel="noopener noreferrer"&gt;message queue&lt;/a&gt; and &lt;a href="https://devops-daily.com/games/database-replication-sharding-scaling" rel="noopener noreferrer"&gt;database scaling&lt;/a&gt; simulators let you cause lag, hot spots and rebalances on purpose, which is considerably cheaper than learning them at a trillion messages.&lt;/p&gt;

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

</description>
      <category>architecture</category>
      <category>database</category>
      <category>scale</category>
      <category>cassandra</category>
    </item>
    <item>
      <title>Kubernetes 1.37 Really Can Flag Unused PVCs, but the Viral YAML Is Wrong</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Fri, 28 Aug 2026 21:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/kubernetes-137-really-can-flag-unused-pvcs-but-the-viral-yaml-is-wrong-549n</link>
      <guid>https://dev.to/devopsdaily/kubernetes-137-really-can-flag-unused-pvcs-but-the-viral-yaml-is-wrong-549n</guid>
      <description>&lt;p&gt;There is a post going around about Kubernetes 1.37 solving one of the quieter FinOps headaches: orphaned PersistentVolumeClaims. It comes with a YAML snippet showing a new field, &lt;code&gt;status.unusedSince&lt;/code&gt;, with a big red arrow pointing at it.&lt;/p&gt;

&lt;p&gt;The good news: the feature is real, it went beta in 1.37, and if you pay a cloud bill it is worth knowing about. The problem: the field in that screenshot does not exist. The actual API is a &lt;strong&gt;condition&lt;/strong&gt; , not a timestamp field, and if you go looking for &lt;code&gt;unusedSince&lt;/code&gt; in your cluster you will find nothing and conclude the feature is missing. We checked the enhancement against &lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-storage/5541-pvc-last-used-time-status-field" rel="noopener noreferrer"&gt;KEP-5541&lt;/a&gt; itself, the same way we checked the &lt;a href="https://dev.to/devopsdaily/kubernetes-137-garhwal-what-shipped-and-what-slipped-1cmi"&gt;1.37 release claims&lt;/a&gt; when third-party roundups disagreed. Here is what actually shipped and how to use it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The problem is real: deleting a StatefulSet or Helm release keeps its PVCs by design, and nobody remembers whose they are six months later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KEP-5541 "Report Last Used Time on a PVC"&lt;/strong&gt; : alpha in 1.36, &lt;strong&gt;beta and enabled by default in 1.37&lt;/strong&gt; , behind the &lt;code&gt;PersistentVolumeClaimUnusedSinceTime&lt;/code&gt; feature gate.&lt;/li&gt;
&lt;li&gt;The API is a new &lt;strong&gt;&lt;code&gt;Unused&lt;/code&gt; condition&lt;/strong&gt; in &lt;code&gt;status.conditions&lt;/code&gt;, managed by the PVC protection controller. There is no &lt;code&gt;status.unusedSince&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;The "unused since" timestamp is the condition's &lt;strong&gt;&lt;code&gt;lastTransitionTime&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A PVC with no &lt;code&gt;Unused&lt;/code&gt; condition at all is normal right after upgrade: the condition appears as usage transitions are observed.&lt;/li&gt;
&lt;li&gt;Unused does not mean deletable. It means no non-terminal pod references the claim.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A cluster on Kubernetes 1.37 (or 1.36 with the alpha gate enabled)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; and, for the queries below, &lt;code&gt;jq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Basic familiarity with PVCs and StatefulSets&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem this solves
&lt;/h2&gt;

&lt;p&gt;Kubernetes keeps PVCs around on purpose. Delete a StatefulSet and its claims stay, because the alternative, data vanishing with a workload object, is worse. The cost of that safety is drift: six months later the &lt;code&gt;monitoring&lt;/code&gt; namespace has a 100Gi claim named after a Prometheus that no longer exists, nobody is sure whether anything still mounts it, and the cloud provider bills for it monthly either way.&lt;/p&gt;

&lt;p&gt;Until now, answering "is anything using this PVC?" meant correlating pods to claims yourself, and answering "since when?" meant an audit trail most clusters do not have. That second question is the one 1.37 finally answers natively.&lt;/p&gt;

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

&lt;p&gt;KEP-5541 adds a condition type &lt;code&gt;Unused&lt;/code&gt; to PersistentVolumeClaim status, maintained by the PVC protection controller in kube-controller-manager:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the &lt;strong&gt;last&lt;/strong&gt; non-terminal pod referencing a PVC goes away, the condition becomes &lt;code&gt;status: "True"&lt;/code&gt; with reason &lt;code&gt;NoPodsUsingPVC&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When a pod starts referencing it again, the condition flips to &lt;code&gt;status: "False"&lt;/code&gt; with reason &lt;code&gt;PodUsingPVC&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The condition's &lt;strong&gt;&lt;code&gt;lastTransitionTime&lt;/code&gt;&lt;/strong&gt; records when that flip happened, which is exactly the "unused since" timestamp the viral post promised, living where Kubernetes actually puts such things.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real YAML looks like this:&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;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;PersistentVolumeClaim&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;prometheus-db-data&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;monitoring&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;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100Gi&lt;/span&gt;
&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bound&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&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;Unused&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;True"&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoPodsUsingPVC&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;No pods are currently referencing this PVC&lt;/span&gt;
      &lt;span class="na"&gt;lastTransitionTime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-08-01T10:00:00Z"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Same information as the screenshot, different shape: a condition you select on, not a scalar field you read. The distinction matters because every query, controller, or policy you write against this feature addresses &lt;code&gt;status.conditions[]&lt;/code&gt;, and anything written against &lt;code&gt;status.unusedSince&lt;/code&gt; silently matches nothing.&lt;/p&gt;

&lt;p&gt;(If you are wondering how a feature gate named &lt;code&gt;PersistentVolumeClaimUnusedSinceTime&lt;/code&gt; produces a condition rather than an &lt;code&gt;unusedSince&lt;/code&gt; field: gate names stick early and describe intent, not final API shape. It is a fair guess at where the confusion started.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The query you actually came for
&lt;/h2&gt;

&lt;p&gt;"Flag PVCs unused for more than 30 days" as a working pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pvc &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; cutoff &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'30 days ago'&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[]
  | . as $pvc
  | (.status.conditions // [])[]
  | select(.type == "Unused" and .status == "True" and .lastTransitionTime &amp;lt; $cutoff)
  | [$pvc.metadata.namespace, $pvc.metadata.name, .lastTransitionTime,
     $pvc.spec.resources.requests.storage]
  | @tsv'&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Output is one line per stale claim: namespace, name, unused-since, size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monitoring prometheus-db-data 2026-08-01T10:00:00Z 100Gi

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

&lt;/div&gt;



&lt;p&gt;Put that in a weekly CronJob that posts to Slack and you have the "automated cleanup visibility" the viral post promised, in about eight lines. The ISO-8601 timestamps compare correctly as strings, which is what makes the &lt;code&gt;&amp;lt;&lt;/code&gt; in jq honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caveats that keep this from biting you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No condition is not a bug.&lt;/strong&gt; Right after upgrading, PVCs carry no &lt;code&gt;Unused&lt;/code&gt; condition at all. The controller adds it as usage transitions are observed, so a claim that has not had a pod come or go since the feature turned on simply has nothing to report yet. Your tooling needs a three-state model: unused, in use, and not-yet-observed, which is why the query above selects explicitly instead of assuming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unused means unreferenced, not deletable.&lt;/strong&gt; The condition says no non-terminal pod references the claim. A monthly reporting job's PVC is "unused" for 29 days at a time. A claim kept as a manual backup is "unused" forever and load-bearing. This feature gives you a review list, not a deletion list; the human step is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The controller can lag.&lt;/strong&gt; Conditions are reconciled from a queue, so the transition timestamp can trail the actual pod event slightly. For a 30-day threshold this is irrelevant; for a 30-minute one it is not the right tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disabling the gate freezes the conditions.&lt;/strong&gt; Turn the feature off and existing &lt;code&gt;Unused&lt;/code&gt; conditions stay in etcd, stale. If you experiment with the gate, remember that a frozen condition looks exactly like a live one.&lt;/p&gt;

&lt;h2&gt;
  
  
  About that "CSI Volume Health" line
&lt;/h2&gt;

&lt;p&gt;The same viral post credits 1.37 with "new CSI Volume Health APIs". Volume health monitoring is real but it is not a 1.37 headline: it is &lt;a href="https://github.com/kubernetes/enhancements/issues/1432" rel="noopener noreferrer"&gt;KEP-1432&lt;/a&gt;, which has been developing across releases for years, with related work continuing in newer storage KEPs. Combining a genuinely-new-in-1.37 feature with a years-old one under one "1.37 fixes storage" banner is how release folklore starts, and release folklore is how upgrade plans go wrong.&lt;/p&gt;

&lt;p&gt;Which is the general lesson we keep re-learning this release cycle: for any "Kubernetes now does X" claim, thirty seconds with the KEP's own &lt;code&gt;kep.yaml&lt;/code&gt; in &lt;a href="https://github.com/kubernetes/enhancements" rel="noopener noreferrer"&gt;kubernetes/enhancements&lt;/a&gt; tells you the real stage, the real milestone, and the real API. The features are usually good news. The screenshots are usually approximate.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On 1.37, nothing to enable&lt;/strong&gt; : the gate is on by default at beta. Give the controller time to observe transitions before expecting conditions everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire the query into a schedule&lt;/strong&gt; and route it to wherever your team reviews costs. Sort by size; the top of that list is usually a few claims worth most of the money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review, then delete deliberately&lt;/strong&gt; : check snapshots, check whether a seasonal workload owns the claim, then remove claim and (depending on your reclaim policy) the underlying volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not build against &lt;code&gt;unusedSince&lt;/code&gt;&lt;/strong&gt; : it does not exist. Conditions do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/kubernetes-1-37-unused-pvc-condition" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>finops</category>
      <category>storage</category>
      <category>upgrades</category>
    </item>
  </channel>
</rss>
