<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DevOps Daily</title>
    <description>The latest articles on DEV Community by DevOps Daily (@devopsdaily).</description>
    <link>https://dev.to/devopsdaily</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F382434%2F66e04ef9-7e4f-491c-997a-30f4a999d40c.jpg</url>
      <title>DEV Community: DevOps Daily</title>
      <link>https://dev.to/devopsdaily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsdaily"/>
    <language>en</language>
    <item>
      <title>Hacktoberfest 2026 Stopped Counting PRs. Make Your First Ones Anyway, One a Day</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:04:37 +0000</pubDate>
      <link>https://dev.to/devopsdaily/hacktoberfest-2026-stopped-counting-prs-make-your-first-ones-anyway-one-a-day-377b</link>
      <guid>https://dev.to/devopsdaily/hacktoberfest-2026-stopped-counting-prs-make-your-first-ones-anyway-one-a-day-377b</guid>
      <description>&lt;p&gt;Hacktoberfest started today, and if you have taken part before, the first thing to know is that the rule everyone remembers is gone. Pull requests no longer count toward Hacktoberfest rewards. There is no PR target and no PR-based swag this year.&lt;/p&gt;

&lt;p&gt;That is a good change. It is also a slightly awkward one if Hacktoberfest was the push you needed to make your first open source contribution. This post covers what changed, why a first PR is still worth making, and a 7-day challenge we built: one small task a day, 5 to 15 minutes each, and each one ends with a real pull request that a maintainer reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in Hacktoberfest 2026
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://hacktoberfest.com/questions/" rel="noopener noreferrer"&gt;official FAQ&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pull requests and merge requests will no longer count toward Hacktoberfest rewards. It’s easier than ever to submit low-effort spam PRs to projects, so we’re listening to maintainer feedback and no longer actively incentivizing PRs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of the event changed with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New organizers.&lt;/strong&gt; MLH and DEV run Hacktoberfest this year, in partnership with DigitalOcean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New format.&lt;/strong&gt; 300+ in-person and online community events, focused on hands-on building and learning with open-source AI and open-weight models. The in-person ones are called Fests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New rewards.&lt;/strong&gt; You collect stickers. Two are required: sign in with MyMLH and add your mailing address. You earn more by checking in at a Fest, entering the code shown during a livestream, or submitting to a DEV Challenge. Fifteen stickers enter you in a raffle for a Hacktoberfest t-shirt or an Arduino Uno Q board (details on the &lt;a href="https://hacktoberfest.com/online/" rel="noopener noreferrer"&gt;online participation page&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain a repo, you know why. Every October, maintainers spent hours closing pull requests that changed one word in a README, and AI tools made those PRs even cheaper to produce. Taking the swag off the PR removes the main reason to send them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your first PR still matters
&lt;/h2&gt;

&lt;p&gt;The spam was the problem, not the pull request. The things a first contribution teaches you did not change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forking, branching and keeping a fork in sync without breaking it.&lt;/li&gt;
&lt;li&gt;Reading a codebase you did not write, well enough to change one thing in it.&lt;/li&gt;
&lt;li&gt;Running a project's tests before a reviewer has to tell you they fail.&lt;/li&gt;
&lt;li&gt;Writing a PR description that a busy maintainer can approve in one read.&lt;/li&gt;
&lt;li&gt;Taking review feedback and pushing a fix to the same branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are daily skills in any DevOps or platform job. A merged PR in a public repo is also evidence of them, which a line on a CV is not.&lt;/p&gt;

&lt;p&gt;What you need is a project that wants small contributions, says exactly how to make them, and reviews them. So we made one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge: one small PR a day for 7 days
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/hacktoberfest" rel="noopener noreferrer"&gt;DevOps Daily Hacktoberfest challenge&lt;/a&gt; is 7 daily tasks, plus a bonus, in the &lt;a href="https://github.com/The-DevOps-Daily/devops-daily" rel="noopener noreferrer"&gt;devops-daily repo&lt;/a&gt;: an open source DevOps learning site with 560+ posts, 45 quizzes and 27 flashcard decks, all stored as Markdown and JSON in the repo. Each day has its own page with the file to change and an example, and most days include a command to check your work.&lt;/p&gt;

&lt;p&gt;To be clear about what this is: it is a DevOps Daily challenge that runs during Hacktoberfest, not an official Hacktoberfest event, and these PRs do not count toward Hacktoberfest stickers. What you get is a real contribution, reviewed by a maintainer, with a test suite in CI, on your GitHub profile. The first challenge PRs came in within hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up once (about 10 minutes)
&lt;/h2&gt;

&lt;p&gt;You need Git, Node.js 22.13.1 or later (below 25), and pnpm 10.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fork https://github.com/The-DevOps-Daily/devops-daily on GitHub first, then:&lt;/span&gt;
git clone https://github.com/&amp;lt;your-username&amp;gt;/devops-daily.git
&lt;span class="nb"&gt;cd &lt;/span&gt;devops-daily
git remote add upstream https://github.com/The-DevOps-Daily/devops-daily.git

pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm &lt;span class="nb"&gt;test&lt;/span&gt;          &lt;span class="c"&gt;# the unit tests CI runs on every PR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before each day's task, start a new branch from a fresh copy of main, so your PR contains only that day's change. Change the day number each time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch upstream
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; hacktoberfest/day-1 upstream/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one exception is Day 7, which adds to your Day 1 profile. If your Day 1 PR is not merged yet, branch from your Day 1 branch instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 days
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Day 1: Add yourself to the experts directory (5 min).&lt;/strong&gt; Create a Markdown profile in &lt;code&gt;content/experts/&lt;/code&gt; with your name, a short bio and how people can reach you. It is the smallest possible PR, and it shows you the whole fork, branch, commit, push and PR flow once before the content gets harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 2: Add your favorite DevOps tool (5 min).&lt;/strong&gt; Add one entry to the toolbox page: name, one-line description, link and category. Pick a tool you use, not one you have heard of. The description is where your experience shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 3: Add a quiz question (5 min).&lt;/strong&gt; Add a question to one of the existing quizzes in &lt;code&gt;content/quizzes/&lt;/code&gt;. The tests check every question's shape, so run them before you push. Write the explanation for the wrong answers too: "why not B" teaches more than "why A".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 4: Add a flashcard (5 min).&lt;/strong&gt; Add one or two cards to a deck in &lt;code&gt;content/flashcards/&lt;/code&gt;. A good card has one fact on the front and the shortest complete answer on the back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 5: Share a tip (10 min).&lt;/strong&gt; Add a gotcha or "thing I wish I knew" to an existing post or guide. This is the first day where you edit someone else's writing, so add your tip where a reader needs it and leave the rest of the post alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 6: Find and fix something (10 min).&lt;/strong&gt; Browse &lt;a href="https://devops-daily.com" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt; and find a typo, a broken link, a deprecated command or a formatting problem. Fix exactly one thing, and put the URL where you found it in the PR description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 7: Share your stack (15 min).&lt;/strong&gt; Add a "My Stack" section to the profile you made on Day 1: what you run, why you picked it, and what you would change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus: Build something (30+ min).&lt;/strong&gt; A full quiz on a topic that is not covered yet, a tool comparison, a production checklist, or for the ambitious, an interactive game or simulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get your PR merged (and not closed as spam)
&lt;/h2&gt;

&lt;p&gt;The 2026 change is a good reminder of what maintainers are tired of. These habits make a PR easy to review in any project, not only this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One change per PR.&lt;/strong&gt; If you spot a typo in another quiz while doing Day 3, fix it in a separate PR. A small diff is quick to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the tests first.&lt;/strong&gt; Run &lt;code&gt;pnpm test&lt;/code&gt; before you push, so you find a failing check before a reviewer does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what and why.&lt;/strong&gt; Two sentences: what you changed, and why it is correct. For a fix, add the link to where you found the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read what you submit.&lt;/strong&gt; If an AI tool helped you write a quiz question or a tip, check it like you would check a stranger's code. A confident wrong answer in a quiz is worse than no question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer review on the same branch.&lt;/strong&gt; Push the fix to the same branch and the PR updates. Do not open a new PR for each round.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Collect your Hacktoberfest stickers too
&lt;/h2&gt;

&lt;p&gt;The challenge and the official event go together well. Do one small PR a day here, and collect your stickers at &lt;a href="https://hacktoberfest.com/" rel="noopener noreferrer"&gt;hacktoberfest.com&lt;/a&gt;: check in at a Fest near you, enter the code during a livestream, or submit to a DEV Challenge, since DEV co-runs the event this year.&lt;/p&gt;

&lt;p&gt;Start with Day 1 today: &lt;a href="https://devops-daily.com/hacktoberfest" rel="noopener noreferrer"&gt;devops-daily.com/hacktoberfest&lt;/a&gt;. And if you get stuck on any day, open an issue on the repo or ask in your PR. Questions are contributions too.&lt;/p&gt;

</description>
      <category>hacktoberfest</category>
      <category>opensource</category>
      <category>beginners</category>
      <category>devops</category>
    </item>
    <item>
      <title>How Kubernetes Actually Schedules Your Pod (and What the Network Does After)</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 01 Oct 2026 14:23:31 +0000</pubDate>
      <link>https://dev.to/devopsdaily/how-kubernetes-actually-schedules-your-pod-and-what-the-network-does-after-jkj</link>
      <guid>https://dev.to/devopsdaily/how-kubernetes-actually-schedules-your-pod-and-what-the-network-does-after-jkj</guid>
      <description>&lt;p&gt;At some point &lt;code&gt;kubectl apply&lt;/code&gt; stops being magic and becomes a question: something decided which node runs this pod, something wired its traffic, and I have no idea what either something did. You can run clusters for a long time in that state. You cannot debug them in it: &lt;code&gt;Pending&lt;/code&gt; pods, unreachable Services and mysteriously blocked traffic all live exactly in the parts the tutorials skim.&lt;/p&gt;

&lt;p&gt;This is a tour of those parts using three free browser simulators: one for scheduling, one for networking, one for the service mesh layer on top. Each lets you make the decisions yourself, which is the difference between having read about the scheduler and being able to predict it. Disclosure: I help build these; all free, browser-based, no signup, no cluster required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: play the scheduler
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/games/k8s-scheduler" rel="noopener noreferrer"&gt;K8s Scheduler simulator&lt;/a&gt; makes you do the scheduler's job by hand: place workloads (an API, a cache, an ETL job, a search service) onto a small fleet of nodes, against a simplified subset of the real scheduler's constraints, across four levels:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resource requests&lt;/strong&gt;: pure bin-packing. Pods declare CPU and memory requests; nodes have capacity; make it fit. Doing this by hand teaches the thing beginners miss: scheduling is based on &lt;strong&gt;requests, not actual usage&lt;/strong&gt; (limits are a kubelet enforcement matter, not a scheduling input), so a cluster can be "full" while its CPUs idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taints, dedicated nodes &amp;amp; anti-affinity&lt;/strong&gt;: some nodes repel workloads unless tolerated, and some pods must not share a node. This level is where &lt;code&gt;NoSchedule&lt;/code&gt; stops being trivia and becomes geometry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node selectors &amp;amp; affinity&lt;/strong&gt;: pods that require labeled nodes, &lt;code&gt;nodeSelector&lt;/code&gt;-style. (The game keeps to hard constraints; the real scheduler adds soft "preferred" affinity on top, in its scoring phase.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Topology spread across zones&lt;/strong&gt;: distribute replicas to shrink the blast radius of a zone failure, while still fitting everything. This is the level that feels like the real job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After placing pods by hand, the real scheduler's behavior stops being mysterious: it filters nodes that cannot take the pod (insufficient requests, untolerated taints, failed selectors), scores the survivors (spread, affinity preferences), and binds. When a pod is &lt;code&gt;Pending&lt;/code&gt;, the &lt;code&gt;FailedScheduling&lt;/code&gt; events in &lt;code&gt;kubectl describe pod&lt;/code&gt; report why candidate nodes were rejected, in these same constraint terms; after this game you can read that output as a map instead of an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: follow the packet
&lt;/h2&gt;

&lt;p&gt;The pod is placed; now its traffic needs a path. The &lt;a href="https://devops-daily.com/games/kubernetes-networking-cni-simulator" rel="noopener noreferrer"&gt;Kubernetes Networking &amp;amp; CNI simulator&lt;/a&gt; animates the paths that exist in every cluster, and the scenario list doubles as a map of what there is to know: pod-to-pod on the same node, pod-to-pod across nodes, ClusterIP services, NodePort, LoadBalancer, Ingress, service discovery and endpoint slices, egress, and network policies.&lt;/p&gt;

&lt;p&gt;Three scenarios are worth the most time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same-node versus cross-node&lt;/strong&gt;: the same pod-to-pod conversation takes a different path depending on placement (which the scheduler from part 1 just decided). Cross-node traffic goes through the CNI's encapsulation or routing, and the simulator lets you switch the dataplane between &lt;strong&gt;overlay, routed, and eBPF CNI&lt;/strong&gt; modes to see the path differences that vendors argue about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClusterIP&lt;/strong&gt;: the scenario that demystifies Services. A Service is not a proxy process sitting somewhere; a node-local dataplane translates the virtual IP into one concrete endpoint as the packet leaves. Watching that translation happen mid-flight is the moment Services make sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network policy&lt;/strong&gt;: traffic that flowed a second ago stops when a policy selects the pod. The rule underneath: pods are open until a policy selects them, and then they are isolated for that policy's direction (ingress, egress, or both), which is the part everyone otherwise learns during an incident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underrated lesson of part 2 is that parts 1 and 2 are coupled: scheduling decisions change packet paths, topology spread changes which conversations cross zones, and the two simulators together explain latency differences no dashboard will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: the mesh on top
&lt;/h2&gt;

&lt;p&gt;Once services talk, the next layer of questions is about the talking itself: encryption, retries, rollouts, failure isolation. The &lt;a href="https://devops-daily.com/games/service-mesh-simulator" rel="noopener noreferrer"&gt;Service Mesh simulator&lt;/a&gt; tells that story in sequence: the problem (plaintext service-to-service traffic), the mechanism (a sidecar proxy next to each pod), then the features the mechanism buys: automatic &lt;strong&gt;mTLS&lt;/strong&gt;, &lt;strong&gt;traffic splitting&lt;/strong&gt; for safe canary deployments, &lt;strong&gt;smart retries&lt;/strong&gt;, and the &lt;strong&gt;circuit breaker&lt;/strong&gt; that stops a dying service from taking its callers with it.&lt;/p&gt;

&lt;p&gt;The sequence matters more than the features. Meshes are usually taught as a feature list, which makes them sound like magic middleware; walked as a story, each capability is obviously "something the proxy in the path can do for you". Whether you need one becomes a cost-benefit question about proxies in request paths, which is the right question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugging payoff
&lt;/h2&gt;

&lt;p&gt;The three parts map onto the three questions of a bad day in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pod is &lt;code&gt;Pending&lt;/code&gt;? Part 1: read &lt;code&gt;kubectl describe pod&lt;/code&gt; events, in scheduler-constraint terms: requests, taints, affinity, spread.&lt;/li&gt;
&lt;li&gt;Pods run but cannot talk? Part 2: walk the path in order: same node or cross-node, Service resolution, then network policy, and check where it dies.&lt;/li&gt;
&lt;li&gt;Talking but failing weirdly under load? Part 3: retries, circuit breaking, and whatever the mesh is doing in the path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few evenings across the three simulators turns that from a checklist you found in a blog post into paths you have traced yourself. All three 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 the authoritative version afterwards, the Kubernetes docs on &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/" rel="noopener noreferrer"&gt;scheduling&lt;/a&gt; and &lt;a href="https://kubernetes.io/docs/concepts/services-networking/" rel="noopener noreferrer"&gt;Services and networking&lt;/a&gt; are the references these simulators are trying to earn you a mental model for.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>networking</category>
      <category>devops</category>
      <category>learning</category>
    </item>
    <item>
      <title>GitHub Is Removing ssh-rsa Signatures: Find What Breaks Before the November 4 Brownout</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/github-is-removing-ssh-rsa-signatures-find-what-breaks-before-the-november-4-brownout-4gbe</link>
      <guid>https://dev.to/devopsdaily/github-is-removing-ssh-rsa-signatures-find-what-breaks-before-the-november-4-brownout-4gbe</guid>
      <description>&lt;p&gt;On September 22, GitHub &lt;a href="https://github.blog/changelog/2026-09-22-security-improvements-for-ssh" rel="noopener noreferrer"&gt;announced&lt;/a&gt; that it will stop accepting two old SSH algorithms: the &lt;code&gt;ssh-rsa&lt;/code&gt; signature type, which is RSA with SHA-1, and the &lt;code&gt;diffie-hellman-group-exchange-sha256&lt;/code&gt; key exchange. A laptop with a current OpenSSH will not notice. The clients that break are the ones nobody looks at: an old CI image, a Jenkins agent, a Java tool with an old SSH library, a backup appliance. The first brownout is on November 4, a bad time to find them.&lt;/p&gt;

&lt;p&gt;This post covers what changes, which clients and keys are at risk, how to test them, and how to rotate keys.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schedule:&lt;/strong&gt; new RSA keys need at least 3072 bits from October 14, 2026. Brownouts on November 4 and December 9. Removal on January 13, 2027.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTPS remotes are not affected.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your RSA key can stay&lt;/strong&gt; if your client signs with &lt;code&gt;rsa-sha2-256&lt;/code&gt; or &lt;code&gt;rsa-sha2-512&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The keys most at risk are old.&lt;/strong&gt; Since March 2022, only RSA keys added before November 2, 2021 can sign with SHA-1 on GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key exchange is the second trap.&lt;/strong&gt; Besides the one being removed, GitHub offered us only sntrup761, curve25519 and ECDH. A client that supports none of them fails with any key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with &lt;code&gt;ssh -vT git@github.com&lt;/code&gt;&lt;/strong&gt; and read the &lt;code&gt;kex:&lt;/code&gt; lines. Add &lt;code&gt;-vvv&lt;/code&gt; to see the signature algorithm for your key.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Git remotes that use SSH (&lt;code&gt;git@github.com:...&lt;/code&gt; or &lt;code&gt;ssh://&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Shell access to the machines and images that run Git: CI agents, runners, build containers&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt; (&lt;code&gt;gh&lt;/code&gt;), with the &lt;code&gt;read:public_key&lt;/code&gt; scope for account keys and admin access for deploy keys&lt;/li&gt;
&lt;li&gt;OpenSSH's &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;ssh-keygen&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What GitHub is changing, and when
&lt;/h2&gt;

&lt;p&gt;The changelog lists four changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Removal of "RSA keys using SHA-1 in SSH (i.e., the ssh-rsa signature type, including &lt;a href="mailto:ssh-rsa-cert-v01@openssh.com"&gt;ssh-rsa-cert-v01@openssh.com&lt;/a&gt; certificates using SHA-1)".&lt;/li&gt;
&lt;li&gt;Removal of the key exchange mechanism &lt;code&gt;diffie-hellman-group-exchange-sha256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"All new RSA SSH keys uploaded after October 14, 2026 must be at least 3072 bits in size, both for signing and authentication." That includes keys you use to sign commits.&lt;/li&gt;
&lt;li&gt;A new post-quantum key exchange, &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt;, on github.com and on GitHub Enterprise Cloud with data residency, except for the U.S. region.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;GitHub SSH change schedule&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sep 22, 2026&lt;/strong&gt; changelog published&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oct 14, 2026&lt;/strong&gt; new RSA keys 3072+ bits, ML-KEM enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nov 4, 2026&lt;/strong&gt; first brownout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dec 9, 2026&lt;/strong&gt; second brownout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jan 13, 2027&lt;/strong&gt; ssh-rsa and DH group exchange removed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both brownouts cover &lt;code&gt;ssh-rsa&lt;/code&gt; and &lt;code&gt;diffie-hellman-group-exchange-sha256&lt;/code&gt;. The changelog gives no time of day or duration for them. On GitHub Enterprise Server, everything takes effect in version 3.25, except ML-KEM, which comes in 3.24. GitHub Enterprise Server users of the unauthenticated Git protocol are affected too.&lt;/p&gt;

&lt;p&gt;ML-KEM needs nothing from you: older clients "should automatically fall back", GitHub says. OpenSSH added &lt;code&gt;mlkem768x25519-sha256&lt;/code&gt; in 9.9 and made it the default in 10.0, according to its &lt;a href="https://www.openssh.com/releasenotes.html" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is actually affected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HTTPS users are not.&lt;/strong&gt; In GitHub's words: "If your Git remotes start with https://, nothing here will affect you." For SSH users, there are two questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Can your client sign with SHA-2?&lt;/strong&gt; As GitHub points out, &lt;code&gt;ssh-rsa&lt;/code&gt; is both a key type and a signature type. Every RSA key has key type &lt;code&gt;ssh-rsa&lt;/code&gt;, but it can sign with SHA-1 (&lt;code&gt;ssh-rsa&lt;/code&gt;), SHA-256 (&lt;code&gt;rsa-sha2-256&lt;/code&gt;) or SHA-512 (&lt;code&gt;rsa-sha2-512&lt;/code&gt;). The client decides which signature it sends, so the key itself is fine.&lt;/p&gt;

&lt;p&gt;In 2021 GitHub &lt;a href="https://github.blog/security/application-security/improving-git-protocol-security-github/" rel="noopener noreferrer"&gt;announced&lt;/a&gt; that from March 15, 2022, "RSA keys uploaded after the cut-off point above will work only with SHA-2 signatures", with November 2, 2021 as the cut-off. So if your RSA key was added after that date and works today, your client already signs with SHA-2. The keys at risk are RSA keys added before November 2, 2021, used by a client that still signs with SHA-1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Does your client share a key exchange with GitHub?&lt;/strong&gt; This does not depend on your key. On October 1, 2026, GitHub's SSH server offered us this list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sntrup761x25519-sha512, sntrup761x25519-sha512@openssh.com,
curve25519-sha256, curve25519-sha256@libssh.org,
ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521,
diffie-hellman-group-exchange-sha256, kex-strict-s-v00@openssh.com

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

&lt;/div&gt;



&lt;p&gt;Plain Diffie-Hellman groups such as &lt;code&gt;diffie-hellman-group14-sha256&lt;/code&gt; are not offered. When group exchange goes, a client needs sntrup761, curve25519, ECDH or, after October 14, ML-KEM. A client that only does classic Diffie-Hellman fails even with an Ed25519 key.&lt;/p&gt;

&lt;p&gt;GitHub lists these minimum versions for RSA with SHA-2 in the default configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Software&lt;/th&gt;
&lt;th&gt;Minimum version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenSSH&lt;/td&gt;
&lt;td&gt;7.2p1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSch&lt;/td&gt;
&lt;td&gt;0.1.66 from &lt;a href="https://github.com/mwiede/jsch" rel="noopener noreferrer"&gt;the mwiede fork&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TeamCity&lt;/td&gt;
&lt;td&gt;2021.2.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go SSH&lt;/td&gt;
&lt;td&gt;0.16.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;libssh2&lt;/td&gt;
&lt;td&gt;1.11.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PuTTY&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where to look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Old CI images and build containers.&lt;/strong&gt; The SSH client is whatever the base image shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jenkins agents.&lt;/strong&gt; Command-line Git uses the agent's &lt;code&gt;ssh&lt;/code&gt;. An agent set to JGit uses Java SSH code instead, so test it separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java tools on the original JSch&lt;/strong&gt; (&lt;code&gt;com.jcraft:jsch&lt;/code&gt;). The fork GitHub names is published as &lt;code&gt;com.github.mwiede:jsch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python tools on Paramiko.&lt;/strong&gt; It is not in GitHub's table. Its &lt;a href="https://www.paramiko.org/changelog.html" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; says 2.9.0 (December 2021) added RSA SHA-2 signatures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libssh2-based tools,&lt;/strong&gt; such as curl's SFTP and SCP support and many libgit2-based clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows.&lt;/strong&gt; Git for Windows ships its own &lt;code&gt;ssh.exe&lt;/code&gt;, Windows has a separate built-in OpenSSH, and some setups use PuTTY's &lt;code&gt;plink&lt;/code&gt; or TortoiseGit's PuTTY-based plink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appliances&lt;/strong&gt; such as artifact servers and backup tools, which often embed their own SSH library.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test a client now
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;ssh -V&lt;/code&gt;. Anything older than &lt;code&gt;OpenSSH_7.2&lt;/code&gt; cannot sign with SHA-2, so plan to replace it.&lt;/p&gt;

&lt;p&gt;Then connect with verbose output. Key exchange runs before authentication, so no registered key is needed. This is a real run from the Debian 12 machine we wrote this post on, on October 1, 2026:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenSSH 9.2p1 against github.com, 2026-10-01&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# -F /dev/null ignores ssh_config, no key is offered, the long server-sig-algs line is shortened&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-F&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;BatchMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;PubkeyAuthentication&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;UserKnownHostsFile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./kh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;accept-new &lt;span class="nt"&gt;-vT&lt;/span&gt; git@github.com 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^OpenSSH|kex: algorithm|host key algorithm|Server host key|server-sig-algs|Permanently|denied'&lt;/span&gt;
OpenSSH_9.2p1 Debian-2+deb12u10, OpenSSL 3.0.22 25 Aug 2026
debug1: kex: algorithm: sntrup761x25519-sha512
debug1: kex: host key algorithm: ssh-ed25519
debug1: Server host key: ssh-ed25519 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU
Warning: Permanently added &lt;span class="s1"&gt;'github.com'&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;ED25519&lt;span class="o"&gt;)&lt;/span&gt; to the list of known hosts.
debug1: kex_input_ext_info: server-sig-algs&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;ssh-ed25519-cert-v01@openssh.com,...,rsa-sha2-512,rsa-sha2-256,ssh-rsa&amp;gt;
git@github.com: Permission denied &lt;span class="o"&gt;(&lt;/span&gt;publickey&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Read three lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kex: algorithm:&lt;/code&gt;&lt;/strong&gt; is the agreed key exchange. If it says &lt;code&gt;diffie-hellman-group-exchange-sha256&lt;/code&gt;, this client breaks during the brownouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kex: host key algorithm:&lt;/code&gt;&lt;/strong&gt; is how GitHub's host key is verified. The changelog words the SHA-1 removal generally, so treat &lt;code&gt;ssh-rsa&lt;/code&gt; here as at risk too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;server-sig-algs=&lt;/code&gt;&lt;/strong&gt; lists the signature types GitHub accepts for your key. Today it still ends in &lt;code&gt;ssh-rsa&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For your own test, drop &lt;code&gt;-F /dev/null&lt;/code&gt;. Git uses your &lt;code&gt;ssh_config&lt;/code&gt;, and a stale &lt;code&gt;KexAlgorithms&lt;/code&gt; or &lt;code&gt;HostKeyAlgorithms&lt;/code&gt; line there can pin you to the old algorithms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;See both offers.&lt;/strong&gt; With &lt;code&gt;-vv&lt;/code&gt;, OpenSSH prints &lt;code&gt;debug2: KEX algorithms:&lt;/code&gt; and &lt;code&gt;debug2: host key algorithms:&lt;/code&gt; twice: first your client's offer, then GitHub's. The key exchange list above comes from GitHub's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;See the signature for your key.&lt;/strong&gt; With &lt;code&gt;-vvv&lt;/code&gt; and a key GitHub accepts, OpenSSH 9.2p1 logs a line of this form when it signs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debug3: sign_and_send_pubkey: signing using rsa-sha2-512 SHA256:&amp;lt;your key fingerprint&amp;gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rsa-sha2-512&lt;/code&gt; or &lt;code&gt;rsa-sha2-256&lt;/code&gt; is safe. &lt;code&gt;ssh-rsa&lt;/code&gt; breaks. Our machine has no key registered on GitHub, so we confirmed this line against a local test server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test without the old algorithms.&lt;/strong&gt; Remove both from your client's defaults. If this still authenticates, the brownout will not affect this client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;KexAlgorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-diffie-hellman-group-exchange-sha256&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;HostKeyAlgorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-ssh-rsa&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;PubkeyAcceptedAlgorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-ssh-rsa&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-T&lt;/span&gt; git@github.com

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

&lt;/div&gt;



&lt;p&gt;A leading &lt;code&gt;-&lt;/code&gt; removes algorithms from the default list. Before OpenSSH 8.5, &lt;code&gt;PubkeyAcceptedAlgorithms&lt;/code&gt; was called &lt;code&gt;PubkeyAcceptedKeyTypes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what failure looks like.&lt;/strong&gt; We forced a key exchange GitHub does not offer, and OpenSSH printed this (GitHub's offer list trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to negotiate with 140.82.121.4 port 22: no matching key exchange method found. Their offer: sntrup761x25519-sha512,...

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

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;HostKeyAlgorithms=ssh-dss&lt;/code&gt;, it printed &lt;code&gt;no matching host key type found&lt;/code&gt;. Search CI logs for these strings on November 4. Libraries word their errors differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List what the binary supports.&lt;/strong&gt; &lt;code&gt;ssh -Q kex&lt;/code&gt; lists key exchanges and &lt;code&gt;ssh -Q sig&lt;/code&gt; lists signature algorithms (added in OpenSSH 7.9). For the settings your config actually applies to GitHub, run &lt;code&gt;ssh -G github.com&lt;/code&gt; and read &lt;code&gt;kexalgorithms&lt;/code&gt;, &lt;code&gt;hostkeyalgorithms&lt;/code&gt; and &lt;code&gt;pubkeyacceptedalgorithms&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the clients that are not your shell's ssh
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Which ssh does Git run?&lt;/strong&gt; &lt;code&gt;core.sshCommand&lt;/code&gt;, &lt;code&gt;GIT_SSH&lt;/code&gt; and &lt;code&gt;GIT_SSH_COMMAND&lt;/code&gt; can point Git at another program. Ask Git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GIT_TRACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 git ls-remote git@github.com:your-org/your-repo.git 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep &lt;/span&gt;run_command

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

&lt;/div&gt;



&lt;p&gt;On our machine the &lt;code&gt;run_command&lt;/code&gt; line contained &lt;code&gt;ssh -o SendEnv=GIT_PROTOCOL git@github.com 'git-upload-pack ...'&lt;/code&gt;, so Git used the &lt;code&gt;ssh&lt;/code&gt; on the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java.&lt;/strong&gt; Find the original JSch in your dependency tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn dependency:tree &lt;span class="nt"&gt;-Dincludes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.jcraft:jsch
./gradlew dependencyInsight &lt;span class="nt"&gt;--dependency&lt;/span&gt; com.jcraft &lt;span class="nt"&gt;--configuration&lt;/span&gt; runtimeClasspath

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

&lt;/div&gt;



&lt;p&gt;The fork's README shows how to exclude &lt;code&gt;com.jcraft:jsch&lt;/code&gt; when it arrives as a transitive dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt; &lt;code&gt;python3 -m pip show paramiko&lt;/code&gt; prints the installed version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;libssh2.&lt;/strong&gt; &lt;code&gt;curl -V&lt;/code&gt; shows the libssh2 version curl links against. On our Debian 12 machine it printed &lt;code&gt;libssh2/1.10.0&lt;/code&gt;, below GitHub's 1.11.0. We did not test whether it fails against GitHub, and distributions sometimes backport fixes, so test the tool rather than trusting the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go.&lt;/strong&gt; GitHub does not name the module behind "Go SSH". If it means &lt;code&gt;golang.org/x/crypto&lt;/code&gt;, check its version in &lt;code&gt;go.mod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Windows.&lt;/strong&gt; In PowerShell, &lt;code&gt;Get-Command ssh&lt;/code&gt; shows which &lt;code&gt;ssh.exe&lt;/code&gt; comes first on the path. Also check &lt;code&gt;git config --show-origin --get core.sshCommand&lt;/code&gt; and &lt;code&gt;GIT_SSH&lt;/code&gt;. If they point at plink, compare its version with PuTTY 0.82.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit your keys
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh-keygen -l -f ~/.ssh/id_rsa.pub&lt;/code&gt; prints a key's size in bits, its fingerprint and its type, such as &lt;code&gt;(RSA)&lt;/code&gt; or &lt;code&gt;(ED25519)&lt;/code&gt;. With &lt;code&gt;-f -&lt;/code&gt; it reads keys from standard input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account keys.&lt;/strong&gt; This lists every authentication key on your account with its upload date, size and type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Needs the read:public_key scope: gh auth refresh -h github.com -s read:public_key&lt;/span&gt;
gh api /user/keys &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [.created_at[0:10], .title, .key] | @tsv'&lt;/span&gt; |
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; created title key&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;size_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ssh-keygen &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; - | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1, $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %-14s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$created&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size_type&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;RSA keys dated before 2021-11-02 are the only ones GitHub still lets sign with SHA-1. Check the clients that use them first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy keys.&lt;/strong&gt; The deploy keys API returns &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;last_used&lt;/code&gt;. This loop needs admin access to each repository and silently skips any it cannot read, so check the count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ORG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-org
gh repo list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt; 1000 &lt;span class="nt"&gt;--json&lt;/span&gt; nameWithOwner &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[].nameWithOwner'&lt;/span&gt; |
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; repo&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt;/keys"&lt;/span&gt; &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s2"&gt;".[] | [&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, .id, .created_at[0:10], (.last_used // &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;never&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;)[0:10], .title, .key] | @tsv"&lt;/span&gt; 2&amp;gt;/dev/null
  &lt;span class="k"&gt;done&lt;/span&gt; |
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; repo &lt;span class="nb"&gt;id &lt;/span&gt;created used title key&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;size_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ssh-keygen &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; - | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1, $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\t%s\tadded %s\tused %s\t%s\t%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$created&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$used&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size_type&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;A key that was never used is a candidate for deletion, not rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine users.&lt;/strong&gt; Public authentication keys need no token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://github.com/your-machine-user.keys | ssh-keygen &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; -

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

&lt;/div&gt;



&lt;p&gt;For upload dates, run the account key audit with the machine user's token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotate to Ed25519
&lt;/h2&gt;

&lt;p&gt;GitHub recommends "an Ed25519 key whenever possible". For an account key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create the key. Add -N '' only for unattended CI keys.&lt;/span&gt;
ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"ci-runner-2026-10"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519_github

&lt;span class="c"&gt;# 2. Register it. If the token lacks the scope, gh prints the refresh command.&lt;/span&gt;
gh ssh-key add ~/.ssh/id_ed25519_github.pub &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"ci-runner 2026-10"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Prove the new key works on its own.&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519_github &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;IdentitiesOnly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; git@github.com

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

&lt;/div&gt;



&lt;p&gt;Then point Git at it in &lt;code&gt;~/.ssh/config&lt;/code&gt; (our post on &lt;a href="https://devops-daily.com/posts/specify-private-ssh-key-for-git-commands" rel="noopener noreferrer"&gt;using a specific SSH key for Git&lt;/a&gt; has other ways):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; github.com
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519_github
  &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;When nothing uses the old key, find its ID with &lt;code&gt;gh ssh-key list&lt;/code&gt; and remove it with &lt;code&gt;gh ssh-key delete &amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For deploy keys, &lt;code&gt;gh repo deploy-key add key.pub --title "deploy 2026-10" -R your-org/your-repo&lt;/code&gt; adds a read-only key, and &lt;code&gt;--allow-write&lt;/code&gt; grants push access. Its help text warns that the key is "associated with the current authentication token" and is removed if that token is de-authorized. Add long-lived deploy keys in the repository settings instead.&lt;/p&gt;

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

&lt;p&gt;A new key type does not fix a key exchange problem. If a client's only key exchange in common with GitHub is &lt;code&gt;diffie-hellman-group-exchange-sha256&lt;/code&gt;, it fails on January 13 with any key. Upgrade the client or library first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Where Ed25519 is not supported:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ECDSA.&lt;/strong&gt; GitHub says "All Ed25519 and ECDSA keys we support are strong, secure, and will continue to work for the indefinite future." Use &lt;code&gt;ssh-keygen -t ecdsa -b 256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RSA with 3072 bits or more,&lt;/strong&gt; if another service needs RSA: &lt;code&gt;ssh-keygen -t rsa -b 4096&lt;/code&gt;. The client must still sign with SHA-2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old key parsers.&lt;/strong&gt; Since OpenSSH 7.8, &lt;code&gt;ssh-keygen&lt;/code&gt; writes its own private key format. Some older libraries only read PEM, which &lt;code&gt;-m PEM&lt;/code&gt; produces for RSA and ECDSA keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist before November 4
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CI images and agents&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List every image, runner and agent that runs Git over SSH, including container jobs.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;ssh -V&lt;/code&gt; in each. Replace anything older than OpenSSH 7.2.&lt;/li&gt;
&lt;li&gt;Run the test without old algorithms inside each image, with the job's credentials.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;/etc/ssh/ssh_config&lt;/code&gt;, &lt;code&gt;/etc/ssh/ssh_config.d/&lt;/code&gt; and baked-in &lt;code&gt;~/.ssh/config&lt;/code&gt; files for &lt;code&gt;KexAlgorithms&lt;/code&gt;, &lt;code&gt;HostKeyAlgorithms&lt;/code&gt;, &lt;code&gt;PubkeyAcceptedAlgorithms&lt;/code&gt; and &lt;code&gt;PubkeyAcceptedKeyTypes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Find JSch, Paramiko, libssh2 and Go SSH in your build tools.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Deploy keys and machine users&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the deploy key audit for each organization and delete unused keys.&lt;/li&gt;
&lt;li&gt;For RSA keys added before November 2, 2021, test the client that uses them, or switch to Ed25519.&lt;/li&gt;
&lt;li&gt;Audit each machine user's keys with its own token, and test from the host that uses it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;On the day&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Watch CI for &lt;code&gt;Unable to negotiate&lt;/code&gt; and &lt;code&gt;Permission denied (publickey)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Treat any failure as a real finding, even if it stops after the brownout.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brownout timing.&lt;/strong&gt; The changelog gives dates, not times or durations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing small RSA keys.&lt;/strong&gt; The 3072-bit rule covers "new RSA SSH keys uploaded after October 14, 2026". The changelog does not say existing 2048-bit keys stop working. We assume deploy keys and re-uploaded old keys count as new uploads, but it does not say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Libraries.&lt;/strong&gt; We did not test JSch, Paramiko, libssh2, Go or PuTTY. The table is GitHub's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real SHA-1 signature.&lt;/strong&gt; We had no RSA key from before November 2021 to test with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server lists.&lt;/strong&gt; These are what GitHub offered on October 1, 2026. They can change.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;GitHub removes RSA with SHA-1 signatures and one Diffie-Hellman key exchange on January 13, 2027, with brownouts on November 4 and December 9. HTTPS remotes are not affected. Your RSA key can stay, but the client must sign with SHA-2 and share a modern key exchange with GitHub.&lt;/p&gt;

&lt;p&gt;Before November 4, test every place that runs Git over SSH with the old algorithms removed, and audit account keys, deploy keys and machine users. Move to Ed25519 where you can, and to ECDSA or 3072-bit RSA where you cannot. For another client-side SSH risk in some of the same libraries, see our post on &lt;a href="https://devops-daily.com/posts/libssh2-cve-2026-55200-client-side-ssh" rel="noopener noreferrer"&gt;libssh2 CVE-2026-55200&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>git</category>
      <category>github</category>
      <category>ssh</category>
      <category>security</category>
    </item>
    <item>
      <title>GitHub Actions Removed Node 20. Find Every node20 Action You Still Run</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 01 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/github-actions-removed-node-20-find-every-node20-action-you-still-run-1eai</link>
      <guid>https://dev.to/devopsdaily/github-actions-removed-node-20-find-every-node20-action-you-still-run-1eai</guid>
      <description>&lt;p&gt;On September 23, 2026, GitHub removed Node 20 from GitHub Actions runners. The &lt;a href="https://github.blog/changelog/2026-09-23-node-20-is-no-longer-available-in-github-actions" rel="noopener noreferrer"&gt;final changelog post&lt;/a&gt; is short: runners now use Node 24 for JavaScript actions, and the temporary opt-out is gone. What it does not spell out is what happens to the actions whose &lt;code&gt;action.yml&lt;/code&gt; still says &lt;code&gt;node20&lt;/code&gt;. They do not stop. The runner starts them on Node 24 instead, adds a warning to the job, and moves on. If the action works on Node 24, you get a yellow annotation. If it does not, the step fails or misbehaves, and there is no setting left that brings Node 20 back.&lt;/p&gt;

&lt;p&gt;That makes the change quiet, which is the problem. This post shows how to list every action your workflows use, read the runtime each one declares, and find the node20 actions hidden behind SHA pins and composite actions. Then it covers what to do about each one, and the self-hosted runner hosts that GitHub no longer supports.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 20 is gone from GitHub Actions runners as of September 23, 2026.&lt;/strong&gt; JavaScript actions run on Node 24, and &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION&lt;/code&gt; no longer brings Node 20 back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;node20 actions are not rejected.&lt;/strong&gt; The runner forces them onto Node 24 and adds a job warning that names them. Jobs we checked on September 28 passed with that warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The risk is the action that breaks on Node 24,&lt;/strong&gt; because you can no longer fall back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floating major tags do not save you.&lt;/strong&gt; On October 1, &lt;code&gt;actions/checkout@v4&lt;/code&gt;, &lt;code&gt;actions/cache@v4&lt;/code&gt;, &lt;code&gt;actions/setup-node@v4&lt;/code&gt; and &lt;code&gt;actions/upload-artifact@v5&lt;/code&gt; all still declared &lt;code&gt;node20&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SHA pins inside other people's composite actions are invisible to Dependabot,&lt;/strong&gt; because the pin lives in a repository you do not own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 100-line Python script&lt;/strong&gt; lists every &lt;code&gt;uses:&lt;/code&gt; reference in a repository, follows composite actions, and prints each one's &lt;code&gt;runs.using&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-hosted runners on macOS 13.4 or older, or on ARM32, are no longer supported.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A repository with GitHub Actions workflows, checked out locally&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt; (&lt;code&gt;gh&lt;/code&gt;), logged in. The script uses it to read &lt;code&gt;action.yml&lt;/code&gt; files from other repositories, including private ones your account can read.&lt;/li&gt;
&lt;li&gt;Python 3.8 or newer&lt;/li&gt;
&lt;li&gt;For the runner section: admin access to list self-hosted runners, or shell access to the runner hosts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What GitHub removed, and when
&lt;/h2&gt;

&lt;p&gt;GitHub &lt;a href="https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/" rel="noopener noreferrer"&gt;announced the deprecation&lt;/a&gt; on September 19, 2025, because Node 20 reached end of life in April 2026. The plan moved several times, and the editor's notes on that post record each move. The final timeline was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Runner v2.328.0&lt;/strong&gt; added Node 24 next to Node 20, with Node 20 still the default. Setting &lt;code&gt;FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true&lt;/code&gt; let you test Node 24 early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;June 16, 2026:&lt;/strong&gt; runners started using Node 24 by default. &lt;code&gt;ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true&lt;/code&gt; let you opt back into Node 20.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;September 23, 2026:&lt;/strong&gt; Node 20 was removed, and that opt-out stopped working.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The September 23 post says: "This is the final notification that Node 20 is no longer available on GitHub Actions runners. Runners now use Node 24 for JavaScript actions. The temporary ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION opt-out is no longer available." It applies to github.com and GitHub with Data Residency. Maintainers should set &lt;code&gt;runs.using&lt;/code&gt; to &lt;code&gt;node24&lt;/code&gt; and release; workflow authors should move to versions that support Node 24.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to a node20 action now
&lt;/h3&gt;

&lt;p&gt;The changelog says runners now use Node 24 for JavaScript actions. The &lt;a href="https://github.com/actions/runner/blob/v2.337.0/src/Runner.Common/Util/NodeUtil.cs" rel="noopener noreferrer"&gt;runner source&lt;/a&gt; shows what that means for an action that declares &lt;code&gt;node20&lt;/code&gt;. In the final phase, the runner picks Node 24 for any action that declares &lt;code&gt;node20&lt;/code&gt;, whatever environment variables you set. Actions that declare &lt;code&gt;node12&lt;/code&gt; or &lt;code&gt;node16&lt;/code&gt; are first mapped to &lt;code&gt;node20&lt;/code&gt;, so they end up on Node 24 too. At the end of the job, the runner adds a warning that lists them.&lt;/p&gt;

&lt;p&gt;We looked at the annotations of three jobs that ran on GitHub-hosted runners on September 28, in an open-source ebook repository that still pins old actions. All three jobs passed. One of them carried this warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/cache@v4, actions/checkout@v4. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/

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

&lt;/div&gt;



&lt;p&gt;Another job used &lt;code&gt;actions/checkout@v2&lt;/code&gt;, which declares &lt;code&gt;node12&lt;/code&gt;, and its warning listed &lt;code&gt;actions/checkout@v2&lt;/code&gt; as a Node.js 20 action forced onto Node 24. That matches the mapping in the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the runner does with an old JavaScript action&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;runs.using: node12 or node16&lt;/strong&gt; mapped to node20 first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runs.using: node20&lt;/strong&gt; action.yml&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runner picks Node 24&lt;/strong&gt; no opt-out since Sept 23&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code works on Node 24&lt;/strong&gt; step passes, job gets a warning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code breaks on Node 24&lt;/strong&gt; step fails, no way back to Node 20&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What can break? Node 24 is two major versions after Node 20. The &lt;a href="https://nodejs.org/en/blog/release/v24.0.0" rel="noopener noreferrer"&gt;Node.js 24.0.0 release notes&lt;/a&gt; list removals such as &lt;code&gt;tls.createSecurePair&lt;/code&gt; and &lt;code&gt;fs.Dirent&lt;/code&gt;'s &lt;code&gt;path&lt;/code&gt; property, and runtime deprecations such as &lt;code&gt;url.parse()&lt;/code&gt;. Whether an action hits one of these depends on its code and its dependencies. Until you have run it on Node 24, treat a node20 action as untested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SHA pins and unmaintained actions are the main risk
&lt;/h2&gt;

&lt;p&gt;Pinning actions to a full commit SHA is good supply-chain practice. It also freezes the runtime. &lt;code&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/code&gt; is v4.2.2, and it will declare &lt;code&gt;node20&lt;/code&gt; forever. The pin works as intended. It hides the update.&lt;/p&gt;

&lt;p&gt;Floating major tags do not help much either. The first-party actions we checked moved to Node 24 in new major versions, so the old major tags stay on Node 20. We read the &lt;code&gt;action.yml&lt;/code&gt; of each tag on October 1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Still &lt;code&gt;node20&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;First major on &lt;code&gt;node24&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions/checkout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions/setup-node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions/cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;actions/upload-artifact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;v4&lt;/code&gt; and &lt;code&gt;v5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note &lt;code&gt;upload-artifact&lt;/code&gt;: &lt;code&gt;v5&lt;/code&gt; still declares &lt;code&gt;node20&lt;/code&gt;. A newer major does not always mean Node 24, so read the file.&lt;/p&gt;

&lt;p&gt;Dependabot helps less than you might expect. Per &lt;a href="https://docs.github.com/en/code-security/dependabot/ecosystems-supported-by-dependabot/supported-ecosystems-and-repositories#github-actions" rel="noopener noreferrer"&gt;GitHub's docs&lt;/a&gt;, version updates for actions only run when &lt;code&gt;dependabot.yml&lt;/code&gt; lists the &lt;code&gt;github-actions&lt;/code&gt; ecosystem. Dependabot only supports the &lt;code&gt;owner/repo@ref&lt;/code&gt; syntax, ignores actions and reusable workflows referenced by a local path, and does not support &lt;code&gt;docker://&lt;/code&gt; references. It also only edits files in your repository. When a third-party composite action pins a node20 action inside its own &lt;code&gt;action.yml&lt;/code&gt;, no pull request in your repository can fix it.&lt;/p&gt;

&lt;p&gt;That case is easy to find in the wild. The latest release of &lt;code&gt;dominikh/staticcheck-action&lt;/code&gt;, v1.4.1 from March 12, 2026, is a composite action. Inside, it pins &lt;code&gt;actions/cache&lt;/code&gt; to the commit for v4.3.0, which declares &lt;code&gt;node20&lt;/code&gt;. Your workflow says &lt;code&gt;dominikh/staticcheck-action@v1.4.1&lt;/code&gt;, and nothing in it mentions Node.&lt;/p&gt;

&lt;p&gt;Then there are unmaintained actions. If an action has not had a release in years, no node24 version is coming. You need a plan for it, not a version bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find every node20 action
&lt;/h2&gt;

&lt;p&gt;The method is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List every &lt;code&gt;uses:&lt;/code&gt; value in &lt;code&gt;.github/workflows/*.yml&lt;/code&gt; and in any &lt;code&gt;action.yml&lt;/code&gt; in the repository.&lt;/li&gt;
&lt;li&gt;Split each value into owner, repository, optional path, and ref.&lt;/li&gt;
&lt;li&gt;Read that action's &lt;code&gt;action.yml&lt;/code&gt; or &lt;code&gt;action.yaml&lt;/code&gt; at that ref, and look at &lt;code&gt;runs.using&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it says &lt;code&gt;composite&lt;/code&gt;, repeat for the steps inside it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can do step 3 by hand with &lt;code&gt;gh&lt;/code&gt; or with &lt;code&gt;raw.githubusercontent.com&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;read runs.using by hand&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/actions/checkout/contents/action.yml?ref=v4"&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; .content | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;using:
  using: node20
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://raw.githubusercontent.com/actions/checkout/v5/action.yml | &lt;span class="nb"&gt;grep &lt;/span&gt;using:
  using: node24

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;gh&lt;/code&gt; route works for private repositories and accepts any ref: a tag, a branch, or a SHA. For every reference in every workflow, use the script below.&lt;/p&gt;

&lt;h3&gt;
  
  
  A quick signal from job annotations
&lt;/h3&gt;

&lt;p&gt;If a workflow ran recently, its jobs already carry the runner's warning. You can read it without opening the web UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Job IDs for one run&lt;/span&gt;
gh run view RUN_ID &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="nb"&gt;jobs&lt;/span&gt; &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.jobs[].databaseId'&lt;/span&gt;

&lt;span class="c"&gt;# The Node 20 warning for one job, if it has one&lt;/span&gt;
gh api repos/OWNER/REPO/check-runs/JOB_ID/annotations &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.message | startswith("Node.js 20")) | .message'&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This only covers what ran. A release workflow that runs once a quarter will not show up until it fails. The scan below reads what the workflows declare instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The script
&lt;/h3&gt;

&lt;p&gt;It reads workflows and local actions from disk and fetches everything else through the GitHub API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Print the runtime (runs.using) of every action a repository&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s workflows use.

Usage: find-node20-actions.py [path-to-repo] (needs python3 and a logged-in gh)
Exit codes: 0 all clear, 1 node12/16/20 actions found, 2 some refs could not be checked.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;USES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;^\s*(?:-\s+)?uses:\s*[&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;]?([^&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;\s#]+)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;USING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;^\s+using:\s*[&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;]?([A-Za-z0-9_-]+)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OLD&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;node12&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# all of these now run on Node 24
&lt;/span&gt;&lt;span class="n"&gt;PROBLEMS&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;not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unparsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="c1"&gt;# ref -&amp;gt; (runtime, text), so each action is fetched and walked once
&lt;/span&gt;&lt;span class="n"&gt;found_old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;found_problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;uses_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;USES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&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;runtime_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&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;m&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;USING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nb"&gt;file&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;name&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;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&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;gh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repos/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/contents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?ref=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--jq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&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="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return (runtime, action.yml text or None) for one uses: value.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\.ya?ml(@|$)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reusable workflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# scan that workflow's repo too
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&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;runtime_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^/@]+)/([^/@]+)(?:/([^@]+))?@(.+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unparsed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_remote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&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;text&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;runtime_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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;found_old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found_problem&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;uses_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;found_old&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;OLD&lt;/span&gt;
        &lt;span class="n"&gt;found_problem&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROBLEMS&lt;/span&gt;
        &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;OLD&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# A remote composite action can wrap a node20 action. Local ones are scanned as files below.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;composite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;action_text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/*.y*ml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dirpath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dirnames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filenames&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dirnames&lt;/span&gt;&lt;span class="p"&gt;[:]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dirnames&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node_modules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.git&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dirpath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filenames&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;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;relative_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&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;f&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="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runtime_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;found_old&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;OLD&lt;/span&gt;
        &lt;span class="n"&gt;found_problem&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROBLEMS&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="err"&gt;!!&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; if runtime in OLD else &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(this action)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found_problem&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;found_old&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;What it handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;./local-action&lt;/code&gt; paths&lt;/strong&gt; are read from disk, relative to the repository root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker://&lt;/code&gt; references&lt;/strong&gt; are reported as Docker images, which do not use the runner's Node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composite actions&lt;/strong&gt; in other repositories are followed, so a node20 action two levels down still shows up. Local ones are scanned as files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;action.yml&lt;/code&gt; and &lt;code&gt;action.yaml&lt;/code&gt;&lt;/strong&gt; are both tried, in that order. GitHub's &lt;a href="https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions" rel="noopener noreferrer"&gt;metadata docs&lt;/a&gt; allow either name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subdirectory actions&lt;/strong&gt; such as &lt;code&gt;github/codeql-action/init@v4.38.2&lt;/code&gt; resolve to that path in the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusable workflows&lt;/strong&gt; are labeled, not followed. Scan their repositories separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reference it cannot read prints &lt;code&gt;not found&lt;/code&gt; and makes the exit code 2, so an expired token or a deleted repository never reads as a clean result.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we ran
&lt;/h3&gt;

&lt;p&gt;We built a test repository whose workflow mixes every case: a SHA-pinned action, two local actions (one composite, one JavaScript with an &lt;code&gt;action.yaml&lt;/code&gt;), a &lt;code&gt;docker://&lt;/code&gt; image, two third-party actions, a subdirectory action, and a reusable workflow. The &lt;code&gt;octo-org&lt;/code&gt; reference is a made-up name, which the script labels without fetching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/span&gt; &lt;span class="c1"&gt;# v4.2.2&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;./.github/actions/setup&lt;/span&gt; &lt;span class="c1"&gt;# composite, uses actions/setup-node@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;./.github/actions/legacy-js&lt;/span&gt; &lt;span class="c1"&gt;# action.yaml, runs.using: 'node20'&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;docker://alpine:3.20&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;JS-DevTools/npm-publish@v4.1.5&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;dominikh/staticcheck-action@v1.4.1&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;github/codeql-action/init@v4.38.2&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;octo-org/shared/.github/workflows/deploy.yml@main&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Here is the run, on October 1, 2026:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan the test repository&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 find-node20-actions.py demo-repo; echo "exit code: $?"
!! .github/workflows/ci.yml actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 node20
   .github/workflows/ci.yml ./.github/actions/setup composite
!! .github/workflows/ci.yml ./.github/actions/legacy-js node20
   .github/workflows/ci.yml docker://alpine:3.20 docker image
   .github/workflows/ci.yml JS-DevTools/npm-publish@v4.1.5 node24
   .github/workflows/ci.yml dominikh/staticcheck-action@v1.4.1 composite
   dominikh/staticcheck-action@v1.4.1 actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 node24
!! dominikh/staticcheck-action@v1.4.1 actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 node20
   .github/workflows/ci.yml github/codeql-action/init@v4.38.2 node24
   .github/workflows/ci.yml octo-org/shared/.github/workflows/deploy.yml@main reusable workflow
!! .github/actions/legacy-js/action.yaml (this action) node20
   .github/actions/setup/action.yml (this action) composite
!! .github/actions/setup/action.yml actions/setup-node@v4 node20
exit code: 1

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

&lt;/div&gt;



&lt;p&gt;Rows marked &lt;code&gt;!!&lt;/code&gt; need work. The &lt;code&gt;actions/cache&lt;/code&gt; row is the one Dependabot cannot fix: it comes from inside &lt;code&gt;staticcheck-action&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We also ran the script on two real repositories. On this site's own repository it printed 20 rows across six workflows, all &lt;code&gt;node24&lt;/code&gt; or &lt;code&gt;docker&lt;/code&gt;, and exited 0. On the ebook repository from earlier, it flagged &lt;code&gt;actions/checkout@v4&lt;/code&gt;, &lt;code&gt;actions/checkout@v2&lt;/code&gt; (&lt;code&gt;node12&lt;/code&gt;), and the &lt;code&gt;actions/cache@v4&lt;/code&gt; inside a composite build action. Those are the same three actions that GitHub's job annotations listed for its September 28 runs.&lt;/p&gt;

&lt;p&gt;To scan a whole organization, run the script in each checked-out repository. Remember that it only sees the branch you have checked out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix each affected action
&lt;/h2&gt;

&lt;p&gt;For each &lt;code&gt;!!&lt;/code&gt; row you have three options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrade to a node24 release.&lt;/strong&gt; This is the usual fix. Find the newest release, read its &lt;code&gt;action.yml&lt;/code&gt; to confirm &lt;code&gt;node24&lt;/code&gt;, and update the reference. For SHA pins, update the SHA and the version comment together, so Dependabot and humans can still read it. Check the release notes for breaking changes, because the Node 24 releases were often new major versions with other changes too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replace it.&lt;/strong&gt; If the action is unmaintained, switch to a maintained one, or drop it for a &lt;code&gt;run:&lt;/code&gt; step. Many small actions wrap one CLI command, and &lt;code&gt;gh release create&lt;/code&gt; or &lt;code&gt;aws s3 sync&lt;/code&gt; in a &lt;code&gt;run:&lt;/code&gt; step has no Node runtime to go stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fork it and bump &lt;code&gt;runs.using&lt;/code&gt;.&lt;/strong&gt; For an action with no node24 release, fork it, change &lt;code&gt;runs.using&lt;/code&gt; to &lt;code&gt;node24&lt;/code&gt;, run its tests on Node 24, rebuild any bundled &lt;code&gt;dist/&lt;/code&gt; folder, and pin your fork by SHA. The runner already runs the action on Node 24, so the edit alone only removes the warning. The value is in the testing and in owning the fix. You also own the fork's security updates now.&lt;/p&gt;

&lt;p&gt;For composite actions you do not own, such as the &lt;code&gt;staticcheck-action&lt;/code&gt; case, the fix belongs upstream. Open an issue or a pull request, and fork in the meantime if the wrapped action breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Internal JavaScript actions
&lt;/h2&gt;

&lt;p&gt;Your own actions need the same change, and nobody else will make it. To find them across an organization, GitHub code search works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh search code node20 &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class="nt"&gt;--filename&lt;/span&gt; action.yml
gh search code node20 &lt;span class="nt"&gt;--owner&lt;/span&gt; YOUR_ORG &lt;span class="nt"&gt;--filename&lt;/span&gt; action.yaml

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

&lt;/div&gt;



&lt;p&gt;Search for the bare word. In our tests, the phrase &lt;code&gt;"using: node20"&lt;/code&gt; returned nothing, even in an organization where the bare word found files that contain exactly that line. Code search only covers default branches, and the word can also match comments and test fixtures, so confirm each hit with the script. For each real one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;runs.using: node24&lt;/code&gt; in its &lt;code&gt;action.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run its tests on Node 24, and set the same version in its own CI.&lt;/li&gt;
&lt;li&gt;Rebuild any bundled output, such as a &lt;code&gt;dist/&lt;/code&gt; folder built with &lt;code&gt;ncc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish a new tag, and move the major tag if your consumers use one.&lt;/li&gt;
&lt;li&gt;Update the repositories that pin the old SHA. The script above finds them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Self-hosted runners need a runner version that knows &lt;code&gt;node24&lt;/code&gt;. The announcement names v2.328.0 as the release that added it. GitHub now enforces much newer runner versions anyway, as we covered in our post on &lt;a href="https://dev.to/devopsdaily/github-started-enforcing-self-hosted-runner-versions-we-tested-what-happens-16j1-temp-slug-8937657"&gt;the self-hosted runner version window&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosted runner hosts that lose support
&lt;/h2&gt;

&lt;p&gt;The September 23 post also says: "Node 24 is incompatible with macOS 13.4 and earlier, and it doesn't officially support ARM32. Self-hosted runners using these operating systems or architectures are no longer supported." The Node.js 24.0.0 release notes agree: the minimum macOS version went up to 13.5, and armv7 support was downgraded to experimental.&lt;/p&gt;

&lt;p&gt;The runner source has a kill switch for this. On Linux ARM32, when GitHub turns it on, a JavaScript action step fails with "Linux ARM32 runners are no longer supported. Please migrate to a supported platform." That message is from the runner source. We have no ARM32 runner and did not see it in a real job.&lt;/p&gt;

&lt;p&gt;To find these hosts, start with the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/OWNER/REPO/actions/runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.runners[] | [.name, .os, ([.labels[].name] | join(","))] | @tsv'&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;orgs/YOUR_ORG/actions/runners&lt;/code&gt; for organization runners. Do not trust the labels alone. &lt;a href="https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/apply-labels" rel="noopener noreferrer"&gt;GitHub's docs&lt;/a&gt; note that when default labels such as &lt;code&gt;x64&lt;/code&gt; are set with the configuration script, GitHub "does not validate that the runner is actually using that operating system or architecture." On the hosts themselves, &lt;code&gt;uname -m&lt;/code&gt; shows the architecture (&lt;code&gt;armv7l&lt;/code&gt; or &lt;code&gt;armv6l&lt;/code&gt; means ARM32), and &lt;code&gt;sw_vers -productVersion&lt;/code&gt; shows the macOS version.&lt;/p&gt;

&lt;p&gt;There is no Node-only fix for these hosts. Move ARM32 runners to arm64 hardware or an arm64 OS image, and upgrade old Macs or retire them. A workflow with only &lt;code&gt;run:&lt;/code&gt; steps and Docker actions needs no JavaScript action, but almost every workflow starts with &lt;code&gt;actions/checkout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The script is a line scanner, not a YAML parser.&lt;/strong&gt; It can miss &lt;code&gt;uses:&lt;/code&gt; in flow-style YAML, and it can pick up a line that starts with &lt;code&gt;uses:&lt;/code&gt; inside a multi-line &lt;code&gt;run:&lt;/code&gt; block. It takes the first indented &lt;code&gt;using:&lt;/code&gt; line as the runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reads one branch.&lt;/strong&gt; Workflows that exist only on other branches are not scanned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not test anything.&lt;/strong&gt; A &lt;code&gt;node20&lt;/code&gt; row means the action declares Node 20. Whether it works on Node 24 is a separate question.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reusable workflows from other repositories are not followed.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our runtime check is one snapshot.&lt;/strong&gt; The tag table and the scan output are from October 1, 2026. Tags move, and maintainers ship releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not test GitHub Enterprise Server.&lt;/strong&gt; The September 23 post names github.com and GitHub with Data Residency only.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Node 20 left GitHub Actions on September 23, 2026, and the opt-out went with it. Actions that declare &lt;code&gt;node20&lt;/code&gt;, &lt;code&gt;node16&lt;/code&gt; or &lt;code&gt;node12&lt;/code&gt; now run on Node 24, with a warning that names them. The ones that still work are easy to ignore, and the first one that breaks has no fallback.&lt;/p&gt;

&lt;p&gt;Read the job annotations for a quick signal, then scan what your workflows declare: every &lt;code&gt;uses:&lt;/code&gt; reference, resolved to its &lt;code&gt;action.yml&lt;/code&gt;, with composite actions followed. Watch SHA pins, old major tags, and node20 actions pinned inside third-party composite actions, because no automated update reaches those. Upgrade, replace, or fork each one, move your own JavaScript actions to &lt;code&gt;node24&lt;/code&gt;, and retire any self-hosted runner on ARM32 or macOS 13.4 or older.&lt;/p&gt;

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

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>node</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Free Egress Has Fine Print: S3 vs R2, B2, Wasabi and More</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 30 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/free-egress-has-fine-print-s3-vs-r2-b2-wasabi-and-more-ghf</link>
      <guid>https://dev.to/devopsdaily/free-egress-has-fine-print-s3-vs-r2-b2-wasabi-and-more-ghf</guid>
      <description>&lt;p&gt;Serving 50 TB a month out of an S3 bucket in us-east-1 costs about $4,379 at list price, almost all of it data transfer. The same month on Cloudflare R2 costs about $62. That gap is why "zero egress" object storage is a whole product category now: Cloudflare R2, Backblaze B2, Wasabi, Tigris, and bundles from DigitalOcean, Hetzner and others.&lt;/p&gt;

&lt;p&gt;The cheapest number in our comparison, though, is one you cannot rely on. Wasabi comes out at $15.60 for that 50 TB month, and that month is outside Wasabi's free-egress policy. Every provider in this category has fine print, and it is different for each one: a ratio to your stored data, a fair-use policy, a price per read, a minimum storage duration, a bundle that runs out. We put nine storage offerings from eight providers into a small calculator with their September 2026 prices and the main fine print applied, and ran four workloads through it.&lt;/p&gt;

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For egress-heavy workloads, the zero-egress providers win by one or two orders of magnitude.&lt;/strong&gt; Our 50 TB media month: R2 $62, Hetzner $76, Tigris $90, against $4,379 on S3 at list price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasabi's free egress is a policy, not a price.&lt;/strong&gt; Egress should stay at or below the data you store. Above that there is no overage rate; Wasabi reserves the right to limit or suspend the service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backblaze B2 is free only up to 3x your stored data,&lt;/strong&gt; then $0.01/GB, unless you go through a partner CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2 egress is free, but reads are not.&lt;/strong&gt; In the media workload, GET requests were about half of the R2 bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum storage durations beat egress for backups.&lt;/strong&gt; With 30-day retention, Wasabi's 90-day minimum triples its storage bill and puts it next to S3 at the bottom of the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS changed the math in November 2025&lt;/strong&gt; with flat-rate CloudFront plans. A direct per-GB comparison against S3 is no longer the whole story.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A rough idea of your workload per month: data stored, data served to the internet, number of GET and PUT requests, and how long objects live.&lt;/li&gt;
&lt;li&gt;Python 3, if you want to run the calculator. It has no dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The headline prices
&lt;/h2&gt;

&lt;p&gt;This is what the pricing pages lead with, as of 28 September 2026. Every price comes from the provider's own pages. The &lt;a href="https://github.com/The-DevOps-Daily/object-storage-egress-calc/blob/main/providers.json" rel="noopener noreferrer"&gt;repository's &lt;code&gt;providers.json&lt;/code&gt;&lt;/a&gt; links the sources for every price the calculator uses, and other claims link their sources where they appear. Scenario totals are the calculator's estimates, not quotes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Storage per GB-month&lt;/th&gt;
&lt;th&gt;Internet egress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Standard (us-east-1)&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;td&gt;100 GB free per account, then $0.09/GB, falling to $0.05/GB above 150 TB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 Standard&lt;/td&gt;
&lt;td&gt;$0.015&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 Infrequent Access&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;Free, but $0.01/GB retrieval on every read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;$6.95 per TB&lt;/td&gt;
&lt;td&gt;Free up to 3x stored, then $0.01/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wasabi&lt;/td&gt;
&lt;td&gt;$7.99 per TB&lt;/td&gt;
&lt;td&gt;Free, subject to policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tigris Standard&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Spaces&lt;/td&gt;
&lt;td&gt;$5/month for 250 GiB, then $0.02/GiB&lt;/td&gt;
&lt;td&gt;1,024 GiB included, then $0.01/GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner Object Storage&lt;/td&gt;
&lt;td&gt;$7.99/month for about 1 TB&lt;/td&gt;
&lt;td&gt;About 1 TB included, then $1.20/TB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storj Standard&lt;/td&gt;
&lt;td&gt;$7 per TB&lt;/td&gt;
&lt;td&gt;$7 per TB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For context, the other two big clouds are in the same place as S3: &lt;a href="https://cloud.google.com/storage/pricing" rel="noopener noreferrer"&gt;Google Cloud Storage&lt;/a&gt; lists $0.12/GiB for the first 10 TiB to most destinations, and &lt;a href="https://azure.microsoft.com/en-us/pricing/details/bandwidth/" rel="noopener noreferrer"&gt;Azure&lt;/a&gt; includes 100 GB a month, then charges $0.087/GB for the next 10 TB from North America or Europe over its Premium Global Network.&lt;/p&gt;

&lt;p&gt;Several of these prices are new. Backblaze went from $6 to $6.95/TB on 1 May 2026 and made standard API calls free (event notifications still cost). Wasabi went from $6.99 to $7.99/TB on 1 July. Hetzner raised its base price from EUR 4.99 to EUR 6.49 on 1 April. Storj moved to its "Simplified" pricing on 1 July. Comparisons written in 2025 are out of date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four workloads
&lt;/h2&gt;

&lt;p&gt;The calculator prices one month for each provider and prints a note wherever the workload breaks a rule. Here are the four workloads from &lt;code&gt;scenarios.sh&lt;/code&gt;, with the results exactly as recorded in &lt;code&gt;runs/scenarios.txt&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Downloads site&lt;/th&gt;
&lt;th&gt;Backups&lt;/th&gt;
&lt;th&gt;Media&lt;/th&gt;
&lt;th&gt;Side project&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workload&lt;/td&gt;
&lt;td&gt;500 GB stored, 5 TB egress, 20M GETs&lt;/td&gt;
&lt;td&gt;10 TB stored, 200 GB egress, 30-day retention&lt;/td&gt;
&lt;td&gt;2 TB stored, 50 TB egress, 100M GETs&lt;/td&gt;
&lt;td&gt;50 GB stored, 200 GB egress, 1M GETs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Standard&lt;/td&gt;
&lt;td&gt;$460.55&lt;/td&gt;
&lt;td&gt;$244.04&lt;/td&gt;
&lt;td&gt;$4,379.20&lt;/td&gt;
&lt;td&gt;$10.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 Standard&lt;/td&gt;
&lt;td&gt;$10.95&lt;/td&gt;
&lt;td&gt;$149.85&lt;/td&gt;
&lt;td&gt;$62.25&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 IA&lt;/td&gt;
&lt;td&gt;$73.09&lt;/td&gt;
&lt;td&gt;$111.09&lt;/td&gt;
&lt;td&gt;$610.90&lt;/td&gt;
&lt;td&gt;$3.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;$38.41&lt;/td&gt;
&lt;td&gt;$69.43&lt;/td&gt;
&lt;td&gt;$453.83&lt;/td&gt;
&lt;td&gt;$0.78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wasabi&lt;/td&gt;
&lt;td&gt;$7.99, outside policy&lt;/td&gt;
&lt;td&gt;$234.00&lt;/td&gt;
&lt;td&gt;$15.60, outside policy&lt;/td&gt;
&lt;td&gt;$7.99, outside policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tigris Standard&lt;/td&gt;
&lt;td&gt;$19.85&lt;/td&gt;
&lt;td&gt;$204.85&lt;/td&gt;
&lt;td&gt;$90.30&lt;/td&gt;
&lt;td&gt;$1.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Spaces&lt;/td&gt;
&lt;td&gt;$49.76&lt;/td&gt;
&lt;td&gt;$200.00&lt;/td&gt;
&lt;td&gt;$529.76&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner Object Storage&lt;/td&gt;
&lt;td&gt;$12.69&lt;/td&gt;
&lt;td&gt;$87.69&lt;/td&gt;
&lt;td&gt;$75.55&lt;/td&gt;
&lt;td&gt;$7.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storj Standard&lt;/td&gt;
&lt;td&gt;$38.50&lt;/td&gt;
&lt;td&gt;$71.40&lt;/td&gt;
&lt;td&gt;$364.00&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Media: 2 TB stored, 50 TB egress, 100M GETs per month&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wasabi (outside policy)&lt;/td&gt;
&lt;td&gt;15.6$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 Standard&lt;/td&gt;
&lt;td&gt;62.25$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner&lt;/td&gt;
&lt;td&gt;75.55$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tigris Standard&lt;/td&gt;
&lt;td&gt;90.3$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storj Standard&lt;/td&gt;
&lt;td&gt;364$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;453.83$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Spaces&lt;/td&gt;
&lt;td&gt;529.76$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 IA&lt;/td&gt;
&lt;td&gt;610.9$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;List prices on 28 September 2026, from the calculator in the linked repository. Wasabi's figure breaks its free-egress policy (egress above stored data), so it is not a price you can rely on. AWS S3 Standard for the same month is $4,379.20 at list price, off this scale.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things stand out. The order changes completely between workloads: Wasabi is cheapest for media and second most expensive for backups. And the reasons it changes are all in the fine print, not in the headline egress price. The rest of this post goes through that fine print, one rule at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wasabi: free egress is a policy, not a price
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wasabi.com/pricing/faq" rel="noopener noreferrer"&gt;Wasabi's pricing FAQ&lt;/a&gt; is explicit: if your monthly egress is at or below your active storage, your use case "is a good fit" for free egress. If it is above, it "is not a good fit", and if that happens "on a regular basis, we reserve the right to limit or suspend your service." There is no overage price to pay instead.&lt;/p&gt;

&lt;p&gt;So Wasabi is a poor fit for workloads that regularly serve more than they store, such as downloads, media and most public assets, however good the number looks. The calculator prints the price with a note rather than hiding the row, because the policy is judged over time and a single heavy month is not the same as a pattern.&lt;/p&gt;

&lt;p&gt;Wasabi also has three minimums: a 1 TB monthly minimum (the side project pays $7.99 for 50 GB), a 4 KB minimum object size, and a 90-day minimum storage duration, which is the next rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimum storage duration: why Wasabi loses on backups
&lt;/h2&gt;

&lt;p&gt;A backup bucket with 30-day retention deletes every object after 30 days. On a provider with a 90-day minimum, each of those objects is billed as if it stayed 90 days. In a steady state that is three times the storage you actually hold, and it is why Wasabi's backup month costs $234.00 instead of about $78.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups: 10 TB stored, 30-day retention, 200 GB restored&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;Backblaze B2&lt;/td&gt;
&lt;td&gt;69.43$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storj Standard&lt;/td&gt;
&lt;td&gt;71.4$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hetzner&lt;/td&gt;
&lt;td&gt;87.69$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 IA&lt;/td&gt;
&lt;td&gt;111.09$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2 Standard&lt;/td&gt;
&lt;td&gt;149.85$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Spaces&lt;/td&gt;
&lt;td&gt;200$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tigris Standard&lt;/td&gt;
&lt;td&gt;204.85$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wasabi&lt;/td&gt;
&lt;td&gt;234$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Standard&lt;/td&gt;
&lt;td&gt;244.04$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;List prices on 28 September 2026, from the calculator. Wasabi bills each object for 90 days, so 30-day retention is charged at about three times the stored data. Storj and R2 Infrequent Access have 30-day minimums, which 30-day retention just meets.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The minimums across the providers here: Wasabi 90 days, and overwrites count as deletes. Storj 30 days. R2 Infrequent Access, Tigris Infrequent Access and DigitalOcean Cold Storage 30 days, Tigris Archive 90 days. None for S3 Standard, R2 Standard, B2 or Tigris Standard. Any bucket that churns, such as a build cache, CI artifacts or daily exports, pays the minimum on every object, whatever the egress price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backblaze B2: free up to 3x what you store
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.backblaze.com/cloud-storage/pricing" rel="noopener noreferrer"&gt;B2's egress&lt;/a&gt; is free "up to 3x their average monthly storage", measured in byte-hours over the month, then $0.01/GB. It is unlimited only when you download to or through partner CDNs and compute providers (the page names Fastly, Cloudflare, bunny.net, CacheFly, CoreWeave, Equinix Metal, Vultr and phoenixNAP), or on B2 Overdrive, which needs a multi-petabyte commitment.&lt;/p&gt;

&lt;p&gt;That is why B2 does well on backups and less well on the downloads site: 500 GB stored gives 1.5 TB of free egress, and the other 3.5 TB costs $35. The overage rate is still a ninth of S3's, and a partner CDN in front removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare R2: egress is free, reads are not
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/r2/pricing/" rel="noopener noreferrer"&gt;R2&lt;/a&gt; charges nothing for egress, with no ratio and no policy on the pricing page. It does charge for operations: every GET and HEAD is a Class B operation at $0.36 per million after the free 10 million a month. In the media workload, 100 million GETs cost $32.40 of the $62.25 total, more than the storage.&lt;/p&gt;

&lt;p&gt;R2 Infrequent Access is a different product. It adds a $0.01/GB retrieval fee on every read or copy, a 30-day minimum, and no free tier. That is fine for backups, and after S3 it is the most expensive choice here for anything people download: $610.90 for the media month, because 50 TB of reads is $500 of retrieval.&lt;/p&gt;

&lt;p&gt;Two more details. The &lt;code&gt;r2.dev&lt;/code&gt; public URL is rate-limited and meant for development; public buckets in production should use a custom domain. And Cloudflare's &lt;a href="https://www.cloudflare.com/service-specific-terms-application-services/" rel="noopener noreferrer"&gt;service terms&lt;/a&gt; say that, unless you are an Enterprise customer, serving video and other large files through its CDN requires one of its paid services, such as the Developer Platform (which includes R2), Images or Stream. Check those terms before you put a large-file origin hosted elsewhere behind Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundles: a fixed amount of free egress, then per GB
&lt;/h2&gt;

&lt;p&gt;Some providers sell a monthly bundle instead of a zero price:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.digitalocean.com/platform/billing/bandwidth/" rel="noopener noreferrer"&gt;DigitalOcean Spaces&lt;/a&gt;:&lt;/strong&gt; $5 a month includes 250 GiB of storage and 1,024 GiB of outbound transfer shared across all buckets, then $0.02/GiB stored and $0.01/GiB transferred. The built-in CDN is included, and its traffic counts against the same allowance. Transfer from Spaces to Droplets in the same datacenter group is free, so a Spaces bucket next to DigitalOcean compute only pays for what leaves for the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.hetzner.com/storage/object-storage/overview/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt;:&lt;/strong&gt; $7.99 (EUR 6.49) a month includes about 1 TB of storage and 1 TB of egress, accrued hour by hour, so a 30-day month holds 1.08 TB of egress. Extra egress is only $1.20/TB, which is why Hetzner is right behind R2 in the media workload. You pay the base price for any hour you have a bucket, even an empty one, and unused quota does not carry over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://storj.dev/dcs/pricing/simplified" rel="noopener noreferrer"&gt;Storj&lt;/a&gt;:&lt;/strong&gt; no bundle and no free egress. $7/TB for storage and $7/TB for egress from the first byte, with a $5 minimum invoice (accounts that pay in USDC are exempt).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bundles are easy to predict and cheap for small projects. DigitalOcean's $5 covers the side project workload in full. What matters is where the included transfer runs out: DigitalOcean's overage is $0.01 per GiB, the same number as B2's $0.01 per GB above 3x, and it is the main line on its media bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small objects pay more than their size
&lt;/h2&gt;

&lt;p&gt;Several providers bill a minimum object size: Hetzner 64 KB, Storj 50 kB, Wasabi 4 KB, DigitalOcean 4 KiB (128 KiB for Cold Storage, on storage and on every read). A bucket of 10 KB thumbnails is billed at 6.4 times its real size on Hetzner. The calculator does not model this, so if your average object is small, check it against the list before you trust the result. R2, Tigris, S3 Standard and B2 state no minimum.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS: the free 100 GB is per account, and CloudFront changed the math
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;AWS's 100 GB of free internet egress&lt;/a&gt; is shared by the whole account, across all services and Regions, and the volume tiers also count all services together. A bucket in an account that also runs busy EC2 instances may never see the free 100 GB.&lt;/p&gt;

&lt;p&gt;The bigger change is CloudFront. Transfer from S3 to CloudFront has long been free, and since 18 November 2025 CloudFront has &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/flat-rate-pricing-plan.html" rel="noopener noreferrer"&gt;flat-rate plans&lt;/a&gt; with "no overage charges": Pro is $15 a month for a 50 TB allowance and 10 million requests, Business $200 for 50 TB and 125 million requests. Above the allowance, AWS says the first spike up to 3x will not affect service that month, but if you keep exceeding it without upgrading, "your traffic delivery might be adjusted", for example served from fewer or more distant edge locations.&lt;/p&gt;

&lt;p&gt;We did not model this, because the bill then depends on cache hit rates and on how AWS treats sustained overuse. But it means "S3 costs $4,379 for 50 TB" is only true if you serve straight from the bucket. With S3 behind a flat-rate plan, the AWS number for a cache-friendly workload moves toward storage plus the plan fee, and that belongs in the same comparison as R2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bytes you pay for but never deliver
&lt;/h2&gt;

&lt;p&gt;AWS bills the bytes it sent when a client aborts a download, with its own example of 3 GB billed for a request cut off at 2 GB. DigitalOcean bills early disconnects "up to the full object size". For large downloads behind flaky networks, or players that seek around video files, billed egress can be noticeably higher than delivered bytes. Free-egress providers avoid this one by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Units and rounding
&lt;/h2&gt;

&lt;p&gt;The providers here do not agree on what a gigabyte is. AWS, Tigris, DigitalOcean and Wasabi bill binary units (GiB, or 1 TB = 1,024 GB), Storj and Hetzner decimal ones; R2 and B2 do not say. A GiB is about 7.4% larger than a decimal GB, and a TiB about 10% larger than a decimal TB. R2 rounds each line up to the next whole billing unit, and Storj rounds usage up to the whole GB. The calculator ignores all of this, which is fine for choosing a provider and not fine for forecasting a bill to the cent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run your own numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;object-storage-egress-calc&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 calc.py --storage-gb 500 --egress-gb 5000 --gets 20000000 --puts 10000
500 GB stored, 5,000 GB egress, 20,000,000 GETs, 10,000 PUTs, objects live 365 days (prices as of 2026-09-28)
  Wasabi (pay-as-you-go) $ 7.99 billed for 1024 GB minimum; egress above stored data: outside Wasabi's free egress policy (no overage price; service may be limited)
  Cloudflare R2 Standard $ 10.95
  Hetzner Object Storage (USD) $ 12.69
  Tigris Standard $ 19.85
  Backblaze B2 $ 38.41
  Storj Standard $ 38.50
  DigitalOcean Spaces $ 49.76
  Cloudflare R2 Infrequent Access $ 73.09
  AWS S3 Standard (us-east-1) $ 460.55

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--lifetime-days&lt;/code&gt; applies minimum storage durations, and every price in &lt;code&gt;providers.json&lt;/code&gt; has its source URL, so you can update a number when a provider changes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pick
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your workload is mostly&lt;/th&gt;
&lt;th&gt;Look at first&lt;/th&gt;
&lt;th&gt;Watch for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Downloads or media, serving more than you store&lt;/td&gt;
&lt;td&gt;Cloudflare R2 Standard, Hetzner, Tigris&lt;/td&gt;
&lt;td&gt;R2 read operations; Hetzner's 64 KB minimum object size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups and archives with little egress&lt;/td&gt;
&lt;td&gt;Backblaze B2, Storj, Hetzner&lt;/td&gt;
&lt;td&gt;Minimum durations if you delete early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large public files through a CDN&lt;/td&gt;
&lt;td&gt;B2 with a partner CDN, R2, or S3 behind a CloudFront flat-rate plan&lt;/td&gt;
&lt;td&gt;The CDN's own terms and allowances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A small project next to your compute&lt;/td&gt;
&lt;td&gt;DigitalOcean Spaces, R2's free tier&lt;/td&gt;
&lt;td&gt;Where the bundle's transfer runs out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage you keep and rarely read, with egress below storage&lt;/td&gt;
&lt;td&gt;Wasabi&lt;/td&gt;
&lt;td&gt;The 1 TB minimum and 90-day minimum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDN plans in front of storage,&lt;/strong&gt; such as CloudFront's flat-rate plans or partner CDNs in front of B2. They can change the answer, but they depend on cache hit rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committed-use and enterprise pricing.&lt;/strong&gt; Contracts and volume discounts are outside this comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaleway.&lt;/strong&gt; Its page shows 75 GB of free egress and "EUR 0.01" after that, but does not print the unit clearly, so we left it out of the calculator rather than guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance.&lt;/strong&gt; This is a price comparison. We did not measure latency, throughput or availability.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Zero egress is real, and for workloads that serve more than they store it changes the bill by one or two orders of magnitude compared with S3 list prices. But every provider pays for it somewhere else: a policy instead of a price, a ratio to storage, a charge per read, a minimum duration, a bundle that runs out. The right answer depends on the shape of your workload, not on the headline egress price, so run your own numbers with the fine print switched on before you move a bucket.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/object-storage-free-egress-fine-print" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>cloud</category>
      <category>s3</category>
      <category>objectstorage</category>
    </item>
    <item>
      <title>GitHub Started Enforcing Self-Hosted Runner Versions. We Tested What Happens</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 30 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/github-started-enforcing-self-hosted-runner-versions-we-tested-what-happens-3nm8</link>
      <guid>https://dev.to/devopsdaily/github-started-enforcing-self-hosted-runner-versions-we-tested-what-happens-3nm8</guid>
      <description>&lt;p&gt;On September 29, GitHub started enforcing minimum versions for self-hosted Actions runners. The announcement gives one number, 2.329.0, and one rule: install each new runner release within 30 days. Neither tells you what happens to the runner you pinned in a Docker image last spring, or how long you really have before it stops. So we asked GitHub's own API about every runner release and tried to register four real runners, one version each, against a free github.com organization. The expired one did not fail loudly. It registered, connected, logged one error line and exited with code 0, and its job waited in the queue.&lt;/p&gt;

&lt;p&gt;This post shows the data, what each runner did, and how to find every runner in your fleet before one of them goes quiet.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We saw it on a free github.com organization.&lt;/strong&gt; The API reports our test org's plan as &lt;code&gt;free&lt;/code&gt;, and both the registration check and the job check fired. GitHub says Enterprise Server is not affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registration floor:&lt;/strong&gt; runner 2.328.0 was refused with "The minimum runner version required to register with GitHub Actions is now 2.329.0."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job floor:&lt;/strong&gt; runner 2.335.1 registered fine, then exited with "Runner version v2.335.1 is deprecated and cannot receive messages." The probe job stayed queued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exit looks clean:&lt;/strong&gt; &lt;code&gt;run.sh&lt;/code&gt; exited with code 0. With &lt;code&gt;ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE=1&lt;/code&gt; set, the same runner exited with code 7, which a supervisor can see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scheduled window:&lt;/strong&gt; for the 18 versions with a runtime deprecation date and a later release, GitHub's API put the date 62 to 70 whole days after that next release (median 64.5). The documentation says 30.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The floor is not a safe version:&lt;/strong&gt; 2.329.0, the registration minimum, has an API runtime deprecation date of January 23, 2026, which has passed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On September 30, 2026, only 2.336.0 (date November 5) and 2.337.0 (no date yet) were inside their schedule, and both ran our probe job.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Self-hosted runners on github.com, on VMs, bare metal, or Kubernetes with Actions Runner Controller (ARC)&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://cli.github.com/" rel="noopener noreferrer"&gt;GitHub CLI&lt;/a&gt; (&lt;code&gt;gh&lt;/code&gt;), logged in as an admin of at least one repository&lt;/li&gt;
&lt;li&gt;For the fleet check: shell access to your runner hosts, or &lt;code&gt;kubectl&lt;/code&gt; access to the cluster that runs them&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The enforcement has a long history, according to GitHub's changelog. GitHub first set the 2.329.0 minimum for March 16, 2026, then &lt;a href="https://github.blog/changelog/2026-03-13-self-hosted-runner-minimum-version-enforcement-paused/" rel="noopener noreferrer"&gt;paused it&lt;/a&gt; three days before. In June it published a &lt;a href="https://github.blog/changelog/2026-06-12-github-actions-minimum-version-enforcement-timeline-for-self-hosted-runners/" rel="noopener noreferrer"&gt;new timeline&lt;/a&gt;: July 31 for Enterprise Cloud with data residency, September 25 for Enterprise Cloud. The last &lt;a href="https://github.blog/changelog/2026-09-28-self-hosted-runner-version-enforcement-date-has-moved/" rel="noopener noreferrer"&gt;changelog post&lt;/a&gt; moved that to September 29.&lt;/p&gt;

&lt;p&gt;The June post has the part that matters most, and it is easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2.329.0 is only the registration minimum.&lt;/strong&gt; It is the oldest runner the new Actions backend will accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running jobs is a moving target.&lt;/strong&gt; A runner has to install each new release within 30 days, or "the GitHub Actions service will stop queuing jobs to it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinned runners are on their own.&lt;/strong&gt;"A runner pinned to &lt;code&gt;2.329.0&lt;/code&gt; that never updates again will not pick up jobs."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In early September GitHub also shipped a &lt;a href="https://github.blog/changelog/2026-09-03-github-actions-early-september-2026-updates/" rel="noopener noreferrer"&gt;REST API for runner version deprecations&lt;/a&gt;: &lt;code&gt;GET /actions/runners/deprecations/{version}&lt;/code&gt; at repository, organization, or enterprise level. It is the source for the numbers below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GitHub's API says about every release
&lt;/h2&gt;

&lt;p&gt;We listed all 146 &lt;code&gt;actions/runner&lt;/code&gt; release records and asked the repository-level endpoint about each one. Our calls used a &lt;code&gt;gh&lt;/code&gt; login with the classic &lt;code&gt;repo&lt;/code&gt; scope for a user who administers the repository. The organization-level endpoint answered 403 for the same token, because it needs the &lt;code&gt;admin:org&lt;/code&gt; scope or the fine-grained self-hosted runners permission:&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;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;api&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/repos/your-org/your-repo/actions/runners/deprecations/&lt;/span&gt;&lt;span class="mf"&gt;2.335&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;


&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"runner_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.335.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"runtime_deprecates_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-09-24T15:30:55Z"&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;Three things came back that the announcement does not say:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The API only knows 19 versions, 2.321.0 to 2.337.0.&lt;/strong&gt; Anything older returns 404. So does 2.327.0, which was replaced by 2.327.1 three days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No response had a &lt;code&gt;registration_deprecates_at&lt;/code&gt; field.&lt;/strong&gt; The changelog lists it, but every successful response contained only &lt;code&gt;runner_version&lt;/code&gt; and &lt;code&gt;runtime_deprecates_at&lt;/code&gt;. The only registration rule we saw is the fixed 2.329.0 floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runtime dates follow a pattern.&lt;/strong&gt; For each version, we counted the whole days, rounded down, between the next non-prerelease release and its &lt;code&gt;runtime_deprecates_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Days a runner version keeps running jobs after the next release ships&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;2.321.0&lt;/td&gt;
&lt;td&gt;66 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.322.0&lt;/td&gt;
&lt;td&gt;62 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.323.0&lt;/td&gt;
&lt;td&gt;64 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.324.0&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.325.0&lt;/td&gt;
&lt;td&gt;65 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.326.0&lt;/td&gt;
&lt;td&gt;68 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.327.1&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.328.0&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.329.0&lt;/td&gt;
&lt;td&gt;65 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.330.0&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.331.0&lt;/td&gt;
&lt;td&gt;64 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.332.0&lt;/td&gt;
&lt;td&gt;67 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.333.0&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.333.1&lt;/td&gt;
&lt;td&gt;69 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.334.0&lt;/td&gt;
&lt;td&gt;63 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.335.0&lt;/td&gt;
&lt;td&gt;66 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.335.1&lt;/td&gt;
&lt;td&gt;65 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.336.0&lt;/td&gt;
&lt;td&gt;70 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Documented: 30 days: 30 days&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;runtime_deprecates_at from GitHub's runner version deprecations API, minus the next release's publish date. 18 versions, 2.321.0 to 2.336.0. Swept 2026-09-30. GitHub's documentation says 30 days.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For every one of the 18 versions, the scheduled date fell 62 to 70 days after its successor was published. That is about nine weeks, twice the 30 days in the documentation. We do not know why. The dates may include a rollout delay, or a buffer GitHub keeps for itself.&lt;/p&gt;

&lt;p&gt;One test lines up with the API rather than the 30 days. Thirty days after 2.337.0 was published was September 25. On September 30, a 2.336.0 runner still registered and completed our probe job, and its API date is November 5. That is one observation on one day, not a promise of extra time.&lt;/p&gt;

&lt;p&gt;Counted from its own release date, a version's scheduled life is 66 to 138 days (median 104). Here is where recent versions stood on September 30, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Released&lt;/th&gt;
&lt;th&gt;API runtime deprecation date&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.337.0&lt;/td&gt;
&lt;td&gt;2026-08-26&lt;/td&gt;
&lt;td&gt;no end date yet (newest)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.336.0&lt;/td&gt;
&lt;td&gt;2026-07-20&lt;/td&gt;
&lt;td&gt;2026-11-05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.335.1&lt;/td&gt;
&lt;td&gt;2026-06-09&lt;/td&gt;
&lt;td&gt;2026-09-24 (ended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.334.0&lt;/td&gt;
&lt;td&gt;2026-04-21&lt;/td&gt;
&lt;td&gt;2026-08-10 (ended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.329.0&lt;/td&gt;
&lt;td&gt;2025-10-14&lt;/td&gt;
&lt;td&gt;2026-01-23 (ended)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The registration minimum is not a safe version. 2.329.0 meets the registration floor, but its API runtime deprecation date, January 23, 2026, passed eight months before enforcement started. We did not test 2.329.0 itself. The one version we tested that was past its date, 2.335.1, registered and then exited, as shown below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a real runner does
&lt;/h2&gt;

&lt;p&gt;API dates are a schedule, not behavior. To see the behavior, we tried to register one runner per version to a private repository in a free organization, with auto-update turned off (&lt;code&gt;--disableupdate&lt;/code&gt;) so each runner stayed on its version. Each runner that registered then got a single &lt;code&gt;workflow_dispatch&lt;/code&gt; job. The &lt;a href="https://github.com/The-DevOps-Daily/runner-version-window/blob/main/scripts/floor-test.sh" rel="noopener noreferrer"&gt;script&lt;/a&gt; downloads the runner, registers it, starts it, dispatches the job, polls the run for about three minutes, and removes the runner. All output below is from runs on September 30, 2026, between 06:54 and 07:34 UTC, on a linux-arm64 host. An earlier pass of the same four tests that morning, with a first version of the script, gave the same four results; both sets of transcripts are in the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Below the registration floor, 2.328.0.&lt;/strong&gt; The runner is refused before it exists:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;runner 2.328.0&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ REPO=The-DevOps-Daily/runner-floor-lab scripts/floor-test.sh 2.328.0
07:16:15Z runner 2.328.0 (arm64), label floor-2-328-0-1790752575
07:20:30Z config.sh --disableupdate
  ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
  │ RUNNER UPDATE REQUIRED │
  ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
  │ │
  │ The minimum runner version required to register with GitHub Actions is now 2.329.0. │
  │ Please upgrade your runner. │
  │ │
  │ For more information, see: │
  │ https://github.blog/changelog/2026-02-05-github-actions-self-hosted-runner-minimum-version-enforcement-extended │
  │ │
  └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
  Response status code does not indicate success: 404 (Not Found).
07:20:34Z config.sh exit code: 1
07:20:34Z result: not registered

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

&lt;/div&gt;



&lt;p&gt;This is the loud failure, and the easy one. A deploy pipeline that builds runner VMs from an old image fails at &lt;code&gt;config.sh&lt;/code&gt;, and someone notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registered, but past its end date, 2.335.1.&lt;/strong&gt; This is the one to worry about. Registration works, the runner connects, and then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;runner 2.335.1&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# the ASCII registration banner is trimmed from the output
$ REPO=The-DevOps-Daily/runner-floor-lab scripts/floor-test.sh 2.335.1
07:06:37Z runner 2.335.1 (arm64), label floor-2-335-1-1790751997
07:10:50Z config.sh --disableupdate
  # Authentication
  √ Connected to GitHub
  # Runner Registration
  √ Runner successfully added
  # Runner settings
  √ Settings Saved.
07:10:57Z config.sh exit code: 0
07:10:57Z run.sh
  runner status: offline, busy: false
07:11:34Z dispatched run 36682287765, polling for up to 180s
07:14:51Z run status after 197s: queued 
07:14:51Z runner process exited with code 0
07:14:51Z runner output (last lines):

  √ Connected to GitHub

  Current runner version: '2.335.1'
  2026-09-30 07:11:04Z: Listening for Jobs
  An error occurred: Runner version v2.335.1 is deprecated and cannot receive messages.
  Runner listener exit with terminated error, stop the service, no retry needed.
  Exiting runner...
07:15:09Z cancelled run 36682287765

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

&lt;/div&gt;



&lt;p&gt;Look at the order. "Runner successfully added" and "Connected to GitHub" both succeed. The runner even reports "Listening for Jobs". Then it logs one error and stops, and the process exits with code 0, the same code as a clean shutdown. The job is not rejected either. It was still queued when the script stopped polling after 197 seconds, and the script cancelled it. We did not measure how long GitHub would keep it queued.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same runner, with the exit code turned on.&lt;/strong&gt; Exit code 0 does not tell anything that checks exit status that the runner failed. The runner has a setting for this. With &lt;code&gt;ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE=1&lt;/code&gt; in its environment, &lt;code&gt;run.sh&lt;/code&gt; exits with code 7 instead of 0 when the version is deprecated. By the runner's source and &lt;a href="https://github.com/actions/runner/pull/4285" rel="noopener noreferrer"&gt;pull request #4285&lt;/a&gt;, the setting first shipped in 2.333.0. We tested it on 2.335.1:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;runner 2.335.1, deprecated exit code on&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# the ASCII registration banner is trimmed from the output
$ ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE=1 REPO=The-DevOps-Daily/runner-floor-lab scripts/floor-test.sh 2.335.1
07:25:07Z runner 2.335.1 (arm64), label floor-2-335-1-1790753107
07:29:19Z config.sh --disableupdate
  # Authentication
  √ Connected to GitHub
  # Runner Registration
  √ Runner successfully added
  # Runner settings
  √ Settings Saved.
07:29:29Z config.sh exit code: 0
07:29:29Z run.sh (ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE=1)
  runner status: offline, busy: false
07:30:04Z dispatched run 36684028728, polling for up to 180s
07:33:17Z run status after 185s: queued 
07:33:17Z runner process exited with code 7
07:33:17Z runner output (last lines):

  √ Connected to GitHub

  Current runner version: '2.335.1'
  2026-09-30 07:29:37Z: Listening for Jobs
  An error occurred: Runner version v2.335.1 is deprecated and cannot receive messages.
  Runner listener exit with deprecated version exit code: 7.
  Exiting runner...
07:33:18Z cancelled run 36684028728

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

&lt;/div&gt;



&lt;p&gt;Same error, and the new probe job stayed queued too, but now the process exits with a failure code. A supervisor that checks exit status can act on that. By default, systemd treats a non-zero exit as a failed service, and Kubernetes records the container's termination reason as &lt;code&gt;Error&lt;/code&gt; instead of &lt;code&gt;Completed&lt;/code&gt;. We did not run either of them. We set the variable in the shell that started &lt;code&gt;run.sh&lt;/code&gt;, which is the only way we tested it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the window, 2.336.0 and 2.337.0.&lt;/strong&gt; Both registered, picked up the job within seconds, and finished it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;runner 2.336.0&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# the ASCII registration banner is trimmed from the output
$ REPO=The-DevOps-Daily/runner-floor-lab scripts/floor-test.sh 2.336.0
07:01:11Z runner 2.336.0 (arm64), label floor-2-336-0-1790751671
07:05:26Z config.sh --disableupdate
  # Authentication
  √ Connected to GitHub
  # Runner Registration
  √ Runner successfully added
  # Runner settings
  √ Settings Saved.
07:05:41Z config.sh exit code: 0
07:05:41Z run.sh
  runner status: online, busy: false
07:06:11Z dispatched run 36681802539, polling for up to 180s
07:06:29Z run status after 18s: completed success
07:06:29Z runner process: still running
07:06:29Z runner output (last lines):

  √ Connected to GitHub

  Current runner version: '2.336.0'
  2026-09-30 07:05:46Z: Listening for Jobs
  2026-09-30 07:06:14Z: Running job: probe
  2026-09-30 07:06:24Z: Job probe completed with result: Succeeded

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

&lt;/div&gt;



&lt;p&gt;By the API's schedule, 2.336.0 stops receiving jobs on November 5, 2026, unless it updates first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this breaks quietly in practice
&lt;/h2&gt;

&lt;p&gt;Runners with auto-update on should mostly follow the schedule by themselves: the runner updates when a new release is out. The risk is in setups where the runner stays on one version because it cannot or may not update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container images.&lt;/strong&gt; A Dockerfile with &lt;code&gt;ARG RUNNER_VERSION=2.334.0&lt;/code&gt; builds the same runner every time. If those runners do not update themselves, each new one starts on the old version and would behave like the 2.335.1 runner above once its date passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions Runner Controller.&lt;/strong&gt; ARC scale sets start runners from an image such as &lt;code&gt;ghcr.io/actions/actions-runner:2.335.1&lt;/code&gt;, so the image tag tells you which version each new pod starts with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VM templates and golden images.&lt;/strong&gt; An AMI or a Packer template built in the spring holds a spring runner. A VM from it starts on that version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--disableupdate&lt;/code&gt; in install scripts.&lt;/strong&gt; Teams add it so runners do not change during a release window, then forget about them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our test the only clear message was in the runner's own output. The workflow run just showed "Queued".&lt;/p&gt;

&lt;h2&gt;
  
  
  Find every runner version you run
&lt;/h2&gt;

&lt;p&gt;Start with the versions, then check them against the API. On a VM or bare-metal host, ask each installed runner directly. The runner binary prints its version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run in each runner's install directory, for example /home/runner/actions-runner&lt;/span&gt;
./config.sh &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# or: ./bin/Runner.Listener --version&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;On a fresh 2.337.0 download, both print &lt;code&gt;2.337.0&lt;/code&gt;. Loop over every install directory you have. A host with several runners has several versions.&lt;/p&gt;

&lt;p&gt;On Kubernetes, list the runner images that pods run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Distinct runner image references, including tags such as :latest and digests&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{..image}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'actions-runner'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;A version tag like &lt;code&gt;:2.335.1&lt;/code&gt; tells you the version each new pod starts with. A tag like &lt;code&gt;:latest&lt;/code&gt; or a digest does not; resolve those before you check them. Treat both commands as a starting point, not a complete inventory.&lt;/p&gt;

&lt;p&gt;Also check the places that build runners, not only the ones that run them: &lt;code&gt;RUNNER_VERSION&lt;/code&gt; in Dockerfiles, Packer variables, Terraform user-data, and Helm values.&lt;/p&gt;

&lt;p&gt;Then feed the versions to the &lt;a href="https://github.com/The-DevOps-Daily/runner-version-window/blob/main/scripts/check-versions.sh" rel="noopener noreferrer"&gt;check script&lt;/a&gt;. It calls the deprecations API for each version and prints the days left. It needs bash, python3 and &lt;code&gt;gh&lt;/code&gt;, and it exits non-zero on bad input or an API error, so a broken check never reads as a pass:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;check-versions&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"2.337.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;v2.336.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;2.335.1&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;2.329.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;2.320.0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nv"&gt;REPO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-org/your-repo scripts/check-versions.sh
VERSION ENDS STATUS
2.337.0 - no runtime deprecation &lt;span class="nb"&gt;date &lt;/span&gt;announced
2.336.0 2026-11-05 36 days left
2.335.1 2026-09-24 ended 5 days ago: stops taking &lt;span class="nb"&gt;jobs
&lt;/span&gt;2.329.0 2026-01-23 ended 249 days ago: stops taking &lt;span class="nb"&gt;jobs
&lt;/span&gt;2.320.0 - unknown to the API &lt;span class="o"&gt;(&lt;/span&gt;it knows 2.321.0 and later&lt;span class="o"&gt;)&lt;/span&gt;: replace it

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

&lt;/div&gt;



&lt;p&gt;Anything that says "ended" is past its API date. In our test, the one such version we ran did not take jobs. Anything under three weeks needs a new image now.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  How to keep runners inside the window
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Leave auto-update on for long-lived runners.&lt;/strong&gt; It is the default. A runner that updates itself follows the schedule without anyone thinking about it. If you must control when runners change, schedule the update instead of disabling it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update the pinned version on every runner release.&lt;/strong&gt; For images and templates, treat each &lt;code&gt;actions/runner&lt;/code&gt; release as a change to make. Configure your dependency bot to bump the actual runner version or image reference, and check that it really opens those pull requests. Rebuilding an image that still pins the old version does not upgrade anything. Plan around GitHub's documented 30 days; the extra weeks we saw in the API are not a promise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert on the date, not on the symptom.&lt;/strong&gt; Run the check in CI on a schedule and fail when a version in use has less than 21 days left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runner-version-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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt; &lt;span class="c1"&gt;# every Monday&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt; &lt;span class="c1"&gt;# a GitHub-hosted runner, so the check never depends on the runners it checks&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail if a runner version has less than 21 days left&lt;/span&gt;
        &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# A token that administers REPO. The default GITHUB_TOKEN cannot call the deprecations API.&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;${{ secrets.RUNNER_AUDIT_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
        &lt;span class="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 -euo pipefail&lt;/span&gt;
          &lt;span class="s"&gt;# Copy scripts/check-versions.sh from the companion repo into yours.&lt;/span&gt;
          &lt;span class="s"&gt;# runner-versions.txt lists the versions your images and hosts use, one per line.&lt;/span&gt;
          &lt;span class="s"&gt;scripts/check-versions.sh &amp;lt; runner-versions.txt | tee report.txt&lt;/span&gt;
          &lt;span class="s"&gt;if grep -E 'ended|unknown|^[0-9.]+ +[0-9-]+ +([0-9]|1[0-9]|20) days left' report.txt; then&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;elif [$? -ne 1]; then&lt;/span&gt;
            &lt;span class="s"&gt;exit 2 # grep itself failed&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make an expired runner fail loudly.&lt;/strong&gt; Where a container or unit starts &lt;code&gt;run.sh&lt;/code&gt;, set &lt;code&gt;ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE=1&lt;/code&gt; in its environment, as in the test above, and alert on exit code 7:&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;# Part of a pod spec for a runner container whose command is run.sh&lt;/span&gt;
&lt;span class="na"&gt;env&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;ACTIONS_RUNNER_RETURN_VERSION_DEPRECATED_EXIT_CODE&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;One warning, from reading the runner source, not from a test: the &lt;code&gt;svc.sh&lt;/code&gt; service does not use &lt;code&gt;run.sh&lt;/code&gt;. Its wrapper, &lt;a href="https://github.com/actions/runner/blob/v2.337.0/src/Misc/layoutbin/RunnerService.js" rel="noopener noreferrer"&gt;&lt;code&gt;bin/RunnerService.js&lt;/code&gt;&lt;/a&gt;, has no case for code 7 in 2.335.1 or 2.337.0. It treats an unknown code as a failure and starts the listener again after 5 seconds. It only gives up if &lt;code&gt;GITHUB_ACTIONS_SERVICE_EXIT_AFTER_N_FAILURES&lt;/code&gt; is set to a positive number and that many unknown exits happen in a row. On hosts that use &lt;code&gt;svc.sh&lt;/code&gt;, rely on the version check instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for queued jobs.&lt;/strong&gt; A job that sits in "Queued" for more than a few minutes on a self-hosted label is worth an alert of its own. It catches this failure and every other reason runners stop, such as capacity, networking, or a broken image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan for the documented rule, not the measurement.&lt;/strong&gt; The API scheduled every version about 64 days after its successor. The documentation says 30. Build to the documented number, and treat any extra weeks as slack you did not count on. The schedule is GitHub's to change.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise Cloud and data residency.&lt;/strong&gt; We tested one free organization. The changelog names Enterprise Cloud, but we did not run a runner there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supervisors.&lt;/strong&gt; We started &lt;code&gt;run.sh&lt;/code&gt; directly. We did not test how a systemd service, the runner's &lt;code&gt;svc.sh&lt;/code&gt; wrapper, or ARC reacts to exit code 0 or 7. The &lt;code&gt;svc.sh&lt;/code&gt; behavior above comes from the source code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every version.&lt;/strong&gt; We ran four versions. We did not test 2.329.0 itself or any other expired version besides 2.335.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Enterprise Server.&lt;/strong&gt; GitHub says it is not affected, and we did not test it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How the dates are set.&lt;/strong&gt; The 62 to 70 day pattern comes from one snapshot of 18 versions. It is a measurement, not a published rule, and it can change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time.&lt;/strong&gt; All of this is one day of data: September 30, 2026, the day after enforcement started.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The registration minimum and the runtime rule answer different questions, and GitHub's June changelog says so. 2.329.0 only decides whether a runner can register, and that failure is loud. The rule to plan around is the moving one: each version stops receiving jobs some time after the next release, 30 days by the documentation and about 64 days in the API's current schedule. In our test, the runner past its date registered, connected, logged one error and exited with code 0, while its job waited in the queue. In our direct &lt;code&gt;run.sh&lt;/code&gt; test, one environment variable changed that to exit code 7.&lt;/p&gt;

&lt;p&gt;Find the versions your hosts, images, and scale sets run. Check them against the deprecations API. Rebuild pinned images on every runner release, and alert on the end date before the queue tells you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/github-self-hosted-runner-version-window" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>selfhostedrunners</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Terraform Muscle Memory: Practice init, plan and apply Without a Cloud Account</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:12:35 +0000</pubDate>
      <link>https://dev.to/devopsdaily/terraform-muscle-memory-practice-init-plan-and-apply-without-a-cloud-account-151i</link>
      <guid>https://dev.to/devopsdaily/terraform-muscle-memory-practice-init-plan-and-apply-without-a-cloud-account-151i</guid>
      <description>&lt;p&gt;The awkward thing about learning Terraform is that most tutorials assume a cloud account, and a cloud account is exactly what a beginner should not point Terraform at. The gap between "read the docs" and "safely ran apply fifty times" is where the confidence comes from, and it is the gap most people never cross before their first work ticket says &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can cross it in a browser. Below is a practice path built on a simulated Terraform project, plus a second simulator for the part that comes after Terraform works: keeping deployed state honest over time. Disclosure: I help build these; both are free, browser-based, no signup, no AWS bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop that is the whole job
&lt;/h2&gt;

&lt;p&gt;Terraform's day-to-day is one loop: change code, preview the diff, apply it, verify. The &lt;a href="https://devops-daily.com/games/terraform-terminal-simulator" rel="noopener noreferrer"&gt;Terraform Terminal Simulator&lt;/a&gt; drills exactly that loop against a fake project with simulated infrastructure (you will meet a VPC with an id like &lt;code&gt;vpc-04e9f2a1&lt;/code&gt; that costs nobody anything). The guided sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Initialize&lt;/strong&gt;: &lt;code&gt;terraform init&lt;/code&gt;, and what actually happens: providers download, the backend gets configured. The lesson has you run &lt;code&gt;terraform validate&lt;/code&gt; here too, the cheap syntax-and-consistency check that belongs before every plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan&lt;/strong&gt;: &lt;code&gt;terraform plan&lt;/code&gt;, reading the diff before it is real. Learning to actually read a plan, not skim it, is the most transferable habit in IaC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply&lt;/strong&gt;: &lt;code&gt;terraform apply&lt;/code&gt;, creating the simulated resources. (In real Terraform, bare &lt;code&gt;apply&lt;/code&gt; stops for a yes/no confirmation and &lt;code&gt;-auto-approve&lt;/code&gt; skips it; the simulator accepts both forms, and the flag is one to distrust until CI is the one running it.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make a change&lt;/strong&gt;: edit, re-plan, and watch the diff show a modification instead of a creation. This is the moment Terraform's model clicks: you declare the destination, it computes the route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect state&lt;/strong&gt;: &lt;code&gt;terraform state list&lt;/code&gt; and &lt;code&gt;terraform output&lt;/code&gt;, because the state file is Terraform's memory, and querying it is how you check what Terraform thinks exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tear down&lt;/strong&gt;: &lt;code&gt;terraform destroy&lt;/code&gt;, the command that makes experiments reversible, practiced here where it can destroy nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What a plan diff looks like when you make that change in step 4, the shape to learn to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  # aws_vpc.main will be updated in-place
  ~ resource "aws_vpc" "main" {
        id         = "vpc-04e9f2a1"
      ~ tags       = {
          ~ "Environment" = "dev" -&amp;gt; "staging"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;~&lt;/code&gt; is a modification, &lt;code&gt;+&lt;/code&gt; is a creation, &lt;code&gt;-&lt;/code&gt; is a destruction, and &lt;code&gt;-/+&lt;/code&gt; (replacement) is the one that should make you slow down: it means Terraform will destroy and recreate the resource to get there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform fmt&lt;/code&gt; rounds out the set: it standardizes formatting so diffs stay about substance.&lt;/p&gt;

&lt;p&gt;The reason to drill this in a sandbox first is not that the commands are hard. It is that each one has a moment where a habit forms: reading the plan instead of trusting it, checking state instead of assuming, destroying deliberately instead of clicking through. A sandbox lets those habits form before anything is at stake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 2: when the repo and the cluster disagree
&lt;/h2&gt;

&lt;p&gt;Terraform gets your infrastructure created. The modern follow-up question is who keeps the running system matching the repo afterwards, and that is GitOps territory. The &lt;a href="https://devops-daily.com/games/gitops-workflow" rel="noopener noreferrer"&gt;GitOps Workflow simulator&lt;/a&gt; drops you into three decision scenarios, each one a real day-2 situation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Configuration Drift&lt;/strong&gt;: someone changed the live system by hand, so the cluster no longer matches Git. You decide how the reconciler should respond, and you see why "the repo is the truth" is a policy you enforce, not a wish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment Rollback&lt;/strong&gt;: a bad version is live. In GitOps, the rollback is a revert in Git, not a hotfix on the cluster, and the scenario walks why that discipline pays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync Policy Decision&lt;/strong&gt;: automatic sync with self-heal and pruning, or manual gates? The scenario's preferred answer is full automation, which is worth knowing is a simplification: Argo CD itself ships with pruning and self-heal off by default, and plenty of teams gate production syncs on purpose. The value is in having to weigh it at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pairing with Terraform is deliberate: &lt;code&gt;plan&lt;/code&gt;/&lt;code&gt;apply&lt;/code&gt; is convergence toward declared state on demand, and GitOps is the same convergence made continuous, with an agent pulling desired state and reconciling it automatically. Drift is the shared enemy.&lt;/p&gt;

&lt;h2&gt;
  
  
  From sandbox to real
&lt;/h2&gt;

&lt;p&gt;The graduation path, in order of increasing blast radius:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The simulator&lt;/strong&gt; until init, plan, apply, state and destroy feel boring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform against something local&lt;/strong&gt;: HashiCorp's own getting-started path uses the Docker provider, real Terraform managing real containers on your machine, no bill possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real cloud sandbox account with a budget alarm&lt;/strong&gt; set before the first apply, not after the first bill. (Free tiers reduce cost, they do not eliminate it; the alarm is not optional.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading real plans at work&lt;/strong&gt; before you write any, because plan-reading is the skill teams actually need from a newcomer on day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The habits transfer unchanged; only the stakes grow.&lt;/p&gt;

&lt;p&gt;Both 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;. When you are ready for the real thing, HashiCorp's &lt;a href="https://developer.hashicorp.com/terraform/tutorials/docker-get-started" rel="noopener noreferrer"&gt;Docker getting-started tutorial&lt;/a&gt; is the natural next step: real Terraform, local resources, still no cloud bill.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>gitops</category>
      <category>iac</category>
    </item>
    <item>
      <title>containerd 1.7 Reaches End of Life: What Breaks on 2.x</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 29 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/containerd-17-reaches-end-of-life-what-breaks-on-2x-23ej</link>
      <guid>https://dev.to/devopsdaily/containerd-17-reaches-end-of-life-what-breaks-on-2x-23ej</guid>
      <description>&lt;p&gt;containerd 1.7 reaches the end of its support window this month, and Kubernetes has already moved on: 1.35 is the last Kubernetes release that supports containerd 1.x, and 1.36 dropped it. So the question for most clusters is no longer whether to move to containerd 2, but what the move will break.&lt;/p&gt;

&lt;p&gt;The changelog makes it sound like a lot: a new config format, removed CRI APIs, registry mirrors on their way out, schema 1 images gone. We wanted to know which of those actually bite, so we ran the upgrade. Same old node config, same data directory, containerd 1.7.36, then 2.3.6, then 2.4.1, recording everything each version said. The config still loaded. The mirrors still worked. What broke was pulling schema 1 images, even on the upgraded node. The catch is that nothing pulls on an upgraded node until something forces it, so the failure tends to appear later, on the next fresh node.&lt;/p&gt;

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;containerd 1.7 support ends in September 2026&lt;/strong&gt; , and Kubernetes dropped containerd 1.x support in 1.36.0. Recent EKS AL2023 node images already ship containerd 2.2.7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An old version 2 config still loads on 2.x.&lt;/strong&gt; containerd migrates it in memory at startup and logs a warning. &lt;code&gt;containerd config migrate&lt;/code&gt; writes &lt;code&gt;version = 4&lt;/code&gt;, and it copies the dead sections into the new file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline registry mirrors still work on 2.3 and 2.4.&lt;/strong&gt; Our logging mirror received five requests per pull on all three versions. Their removal date has moved from v2.1 to v2.4 to v2.7 depending on which version you ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pulling schema 1 images is the real break.&lt;/strong&gt; An image pulled and converted under 1.7 is still in the image store after the upgrade, but any schema 1 pull on 2.x fails: &lt;code&gt;schema 1 image manifests are no longer supported&lt;/code&gt;. It shows up when a node has to pull: a fresh node, a garbage-collected image, or &lt;code&gt;imagePullPolicy: Always&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A read-only check script&lt;/strong&gt; in the repository reports all of this for a real node.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Kubernetes nodes that run containerd. (We did not test Docker Engine hosts.)&lt;/li&gt;
&lt;li&gt;Root access to one node, to read its config and run &lt;code&gt;ctr&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; if you want the cluster-wide view.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why now
&lt;/h2&gt;

&lt;p&gt;Three dates line up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;containerd's own release table&lt;/strong&gt; lists 1.7 as an LTS release with end of life in September 2026, extended to that date for Kubernetes 1.30 to 1.32 on GKE. See &lt;a href="https://github.com/containerd/containerd/blob/main/RELEASES.md" rel="noopener noreferrer"&gt;RELEASES.md&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes&lt;/strong&gt; agreed that the last release to support containerd 1.x is 1.35, with support dropped in 1.36.0. The kubelet exposes a &lt;code&gt;kubelet_cri_losing_support&lt;/code&gt; metric: when it appears with a version label of &lt;code&gt;1.36.0&lt;/code&gt;, that node's containerd is too old for the next Kubernetes version. See the &lt;a href="https://kubernetes.io/blog/2025/11/26/kubernetes-v1-35-sneak-peek/" rel="noopener noreferrer"&gt;Kubernetes v1.35 sneak peek&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed node images have moved already.&lt;/strong&gt; The EKS AL2023 AMI release &lt;a href="https://github.com/awslabs/amazon-eks-ami/releases/tag/v20260923" rel="noopener noreferrer"&gt;v20260923&lt;/a&gt; ships containerd &lt;code&gt;2.2.7&lt;/code&gt;. On managed Kubernetes the containerd version usually comes with the node image, so check your node image's release notes before you upgrade a pool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see what every node in a cluster runs today:&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 nodes &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;NAME:.metadata.name,RUNTIME:.status.nodeInfo.containerRuntimeVersion

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The demo runs three containerd versions one after the other, against the same &lt;code&gt;--root&lt;/code&gt; and &lt;code&gt;--state&lt;/code&gt; directories, the way an in-place node upgrade reuses the node's data. Each one starts with the same config, written the way many Kubernetes nodes were set up in the 1.x era:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri"]&lt;/span&gt;
  &lt;span class="py"&gt;sandbox_image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"registry.k8s.io/pause:3.9"&lt;/span&gt;

  &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd]&lt;/span&gt;
    &lt;span class="py"&gt;snapshotter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"overlayfs"&lt;/span&gt;
    &lt;span class="py"&gt;default_runtime_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"runc"&lt;/span&gt;

    &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]&lt;/span&gt;
      &lt;span class="py"&gt;runtime_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"io.containerd.runc.v2"&lt;/span&gt;

      &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]&lt;/span&gt;
        &lt;span class="py"&gt;SystemdCgroup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]&lt;/span&gt;
    &lt;span class="py"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"http://127.0.0.1:5055"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[plugins."io.containerd.runtime.v1.linux"]&lt;/span&gt;
  &lt;span class="py"&gt;shim&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"containerd-shim"&lt;/span&gt;
  &lt;span class="py"&gt;runtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"runc"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;docker.io&lt;/code&gt; mirror points at a tiny local server that logs every request and answers 404, so containerd falls back to Docker Hub. That turns "is this setting still honoured?" into a number we can count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One data directory, three versions&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;containerd 1.7.36&lt;/strong&gt; old config, schema 1 pull&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;containerd 2.3.6&lt;/strong&gt; LTS, same root&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;containerd 2.4.1&lt;/strong&gt; latest, same root&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runs/&lt;/strong&gt; every command and warning&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For each version the script records &lt;code&gt;ctr deprecations list&lt;/code&gt;, a CRI image pull through &lt;code&gt;crictl&lt;/code&gt;, how many requests reached the mirror, the schema 1 steps, and the daemon's warnings. The terminal output below comes from those recordings; the commands are shown without the demo's socket flags, and trailing spaces are trimmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the old config still loads
&lt;/h2&gt;

&lt;p&gt;Both 2.x versions started with the version 2 config. They converted it in memory and said so once, at startup:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;containerd 2.3.6 daemon log&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;time="2026-09-28T23:41:57+03:00" level=warning msg="Configuration migrated from version 2, use `containerd config migrate` to avoid migration" t="31.333µs"

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

&lt;/div&gt;



&lt;p&gt;That is friendlier than the changelog suggests, and it is also a trap: a config that loads is not a config that is doing what it says. Running &lt;code&gt;containerd config migrate&lt;/code&gt; with 2.3.6 or 2.4.1 printed a complete config with &lt;code&gt;version = 4&lt;/code&gt;, and two details in it matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;runtime v1 section&lt;/strong&gt; came through unchanged as &lt;code&gt;[plugins.'io.containerd.runtime.v1.linux']&lt;/code&gt;. Runtime v1 was removed in 2.0, so that block now configures nothing, silently.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;inline mirrors&lt;/strong&gt; came through too, moved to the new &lt;code&gt;io.containerd.cri.v1.images&lt;/code&gt; section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;config migrate&lt;/code&gt; gives you the new layout, not a clean config. Read the output and delete what no longer exists before you ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the mirrors still work, and the deadline keeps moving
&lt;/h2&gt;

&lt;p&gt;The inline &lt;code&gt;registry.mirrors&lt;/code&gt; setting has been deprecated since containerd 1.5. We expected 2.x to ignore it. It did not. Each version pulled a different busybox tag through the CRI, and the logging mirror recorded five requests per pull. Here is 2.4.1:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;containerd 2.4.1&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;crictl pull docker.io/library/busybox:1.38.0
&lt;span class="go"&gt;time="2026-09-28T23:42:10+03:00" level=warning msg="Config \"/etc/crictl.yaml\" does not exist, trying next: \"/home/biliev/projects/containerd-2-upgrade-check/bin/crictl.yaml\""
Image is up to date for sha256:d2482869a6b838d3b4c9cad0c8085c06277909482771eb525d298fc3a7d07927
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;the &lt;span class="nb"&gt;local &lt;/span&gt;mirror&lt;span class="s1"&gt;'s request log for that pull
&lt;/span&gt;&lt;span class="go"&gt;requests the docker.io mirror received: 5
HEAD /v2/library/busybox/manifests/1.38.0?ns=docker.io
GET /v2/library/busybox/manifests/sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e?ns=docker.io
GET /v2/library/busybox/manifests/sha256:365a051f12e05767b598e643676f14a450fb678a75ccf2beb0052c95d5c73b83?ns=docker.io

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

&lt;/div&gt;



&lt;p&gt;What changed is the removal date in the warning. Each version records it with &lt;code&gt;ctr deprecations list&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Mirrors "will be removed in"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;containerd 1.7.36&lt;/td&gt;
&lt;td&gt;v2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;containerd 2.3.6&lt;/td&gt;
&lt;td&gt;v2.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;containerd 2.4.1&lt;/td&gt;
&lt;td&gt;v2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A deadline that has moved twice is still a deadline. There is also a side effect today: with inline mirrors in the config, 2.x logs &lt;code&gt;Found 'Registry.Mirrors' in CRI config which is incompatible with transfer service ... Falling back to local image pull mode.&lt;/code&gt; In other words, keeping the old setting also keeps the old pull path.&lt;/p&gt;

&lt;p&gt;The replacement is &lt;code&gt;config_path&lt;/code&gt; plus one &lt;code&gt;hosts.toml&lt;/code&gt; per registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/containerd/config.toml (version 3 or later)&lt;/span&gt;
&lt;span class="nn"&gt;[plugins.'io.containerd.cri.v1.images'.registry]&lt;/span&gt;
  &lt;span class="py"&gt;config_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'/etc/containerd/certs.d'&lt;/span&gt;


&lt;span class="c"&gt;# /etc/containerd/certs.d/docker.io/hosts.toml&lt;/span&gt;
&lt;span class="py"&gt;server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://registry-1.docker.io"&lt;/span&gt;

&lt;span class="c"&gt;# Give "resolve" (tag lookups) only to mirrors you trust.&lt;/span&gt;
&lt;span class="nn"&gt;[host."https://mirror.gcr.io"]&lt;/span&gt;
  &lt;span class="py"&gt;capabilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"pull"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 3: schema 1 fails, but not where you are looking
&lt;/h2&gt;

&lt;p&gt;Docker image manifest schema 1 is the format images were pushed in before Docker 1.10 introduced schema 2 in 2016. Some are still served that way. &lt;a href="https://github.com/The-DevOps-Daily/containerd-2-upgrade-check/blob/main/scripts/find-schema1.sh" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/find-schema1.sh&lt;/code&gt;&lt;/a&gt; asks for newer formats first, the way a current client does, and &lt;code&gt;docker.io/library/busybox:1.24&lt;/code&gt;, &lt;code&gt;gcr.io/google-containers/busybox:1.24&lt;/code&gt;, &lt;code&gt;gcr.io/google-containers/pause:0.8.0&lt;/code&gt; and &lt;code&gt;quay.io/coreos/etcd:v2.2.5&lt;/code&gt; still came back as schema 1. containerd 1.7 still pulls them, converting on the way and warning about it; our 1.7.36 run did that with &lt;code&gt;busybox:1.24&lt;/code&gt;. containerd 2.0 disabled schema 1 pulls (2.0.x can turn them back on with &lt;code&gt;CONTAINERD_ENABLE_DEPRECATED_PULL_SCHEMA_1_IMAGE=1&lt;/code&gt;), and 2.1 removed them, according to containerd's &lt;a href="https://github.com/containerd/containerd/blob/main/RELEASES.md" rel="noopener noreferrer"&gt;deprecation table&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On 1.7.36 the pull worked, and the deprecation was recorded:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;containerd 1.7.36&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# the demo keeps the last two lines of ctr's progress output
$ ctr -n demo images pull --platform linux/amd64 docker.io/library/busybox:1.24
unpacking linux/amd64 sha256:dc53f06b7ab95434d92cd20c00409fe5b5ded8d3b68f861e709f2e4b49067242...
done: 323.503075ms
$ ctr deprecations list
ID LAST OCCURRENCE MESSAGE
io.containerd.deprecation/cri-registry-mirrors 2026-09-28T20:41:42.439195342Z The `mirrors` property of `[plugins."io.containerd.grpc.v1.cri".registry]` is deprecated since containerd v1.5 and will be removed in containerd v2.1. Use `config_path` instead.
io.containerd.deprecation/pull-schema-1-image 2026-09-28T20:41:54.047880144Z Schema 1 images are deprecated since containerd v1.7 and removed in containerd v2.0. Since containerd v1.7.8, schema 1 images are identified by the "io.containerd.image/converted-docker-schema1" label.

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

&lt;/div&gt;



&lt;p&gt;Then we upgraded the same data directory. On 2.3.6 and on 2.4.1 the converted image was still in the image store, with its label. (It is an amd64 image and the demo ran on an arm64 Pi, so we listed it but did not run it.) Pulling it again failed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;containerd 2.4.1, same data directory&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ctr -n demo images ls
time="2026-09-28T23:42:17+03:00" level=warning msg="DEPRECATION: The `mirrors` property of `[plugins.\"io.containerd.grpc.v1.cri\".registry]` is deprecated since containerd v1.5 and will be removed in containerd v2.7. Use `config_path` instead."
REF TYPE DIGEST SIZE PLATFORMS LABELS
docker.io/library/busybox:1.24 application/vnd.oci.image.manifest.v1+json sha256:dc53f06b7ab95434d92cd20c00409fe5b5ded8d3b68f861e709f2e4b49067242 661.3 KiB linux/amd64 io.containerd.image/converted-docker-schema1=sha256:8ea3273d79b47a8b6d018be398c17590a4b5ec604515f416c5b797db9dde3ad8
# the demo keeps the last two lines of ctr's output
$ ctr -n demo images pull --platform linux/amd64 docker.io/library/busybox:1.24
time="2026-09-28T23:42:17+03:00" level=warning msg="DEPRECATION: The `mirrors` property of `[plugins.\"io.containerd.grpc.v1.cri\".registry]` is deprecated since containerd v1.5 and will be removed in containerd v2.7. Use `config_path` instead."
ctr: schema 1 image manifests are no longer supported: invalid argument

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

&lt;/div&gt;



&lt;p&gt;That is the whole trap. The pull fails everywhere, the upgraded node included. But an upgraded node that already has the image on disk has no reason to pull it, so nothing visible happens on upgrade day. The failure appears later, whenever a node has to pull:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new node&lt;/strong&gt; from the autoscaler or a node pool upgrade has an empty image store and has to pull.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image garbage collection&lt;/strong&gt; on a busy node deletes the cached copy, and the next pod start pulls again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;imagePullPolicy: Always&lt;/code&gt;&lt;/strong&gt; , or a rollback to an old tag that the new nodes have never pulled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these looks like a random &lt;code&gt;ImagePullBackOff&lt;/code&gt; on one node, days after an upgrade that "went fine".&lt;/p&gt;

&lt;p&gt;To find the images before they find you, look for the label containerd adds on conversion. The check script does this across every namespace, which on a Kubernetes node includes &lt;code&gt;k8s.io&lt;/code&gt;. The fix is to stop depending on the old manifest: rebuild the image and push it with a current tool, which writes a schema 2 or OCI manifest, or move to a newer tag that already has one. Retagging the old image does not change its manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check a real node
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/containerd-2-upgrade-check/blob/main/scripts/check-node.sh" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/check-node.sh&lt;/code&gt;&lt;/a&gt; is read-only. Run it as root on a node before you upgrade. This is its output against the demo's 1.7.36 daemon after the schema 1 pull, with the paths overridden for the demo:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;check-node.sh&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo CONTAINERD_CONFIG=configs/old-node-config.toml CONTAINERD_ADDRESS=/tmp/ctd-check/containerd.sock CTR=bin/containerd-1.7.36/ctr scripts/check-node.sh
== containerd version
  Version: v1.7.36
  Revision: 2892c2042ee7fbd3be0e5bdc675e07b5acedb0bf

== config: configs/old-node-config.toml
config version: 2
WARN inline registry.mirrors: deprecated; move to config_path and hosts.toml files
WARN runtime v1 section: removed in 2.0; containerd 2.x ignores it

== deprecations containerd has already seen (1.7.x and later)
ID LAST OCCURRENCE MESSAGE
io.containerd.deprecation/cri-registry-mirrors 2026-09-28T20:48:14.827082624Z The `mirrors` property of `[plugins."io.containerd.grpc.v1.cri".registry]` is deprecated since containerd v1.5 and will be removed in containerd v2.1. Use `config_path` instead.
io.containerd.deprecation/pull-schema-1-image 2026-09-28T20:48:19.606197042Z Schema 1 images are deprecated since containerd v1.7 and removed in containerd v2.0. Since containerd v1.7.8, schema 1 images are identified by the "io.containerd.image/converted-docker-schema1" label.

== images that were converted from schema 1 (they keep working; pulling them again on 2.x fails)
k8s.io docker.io/library/busybox:1.24

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ctr deprecations list&lt;/code&gt; is the most useful line in there. It lists the deprecations containerd has observed on that node: config problems it saw at startup, and deprecated features something actually used, such as a schema 1 pull. It is not a complete audit, but it reflects the node, not the changelog.&lt;/p&gt;

&lt;h2&gt;
  
  
  An upgrade checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List runtime versions&lt;/strong&gt; across the cluster with the &lt;code&gt;kubectl&lt;/code&gt; command above, and watch for &lt;code&gt;kubelet_cri_losing_support&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the check&lt;/strong&gt; on one node per node pool or image type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild and push every schema 1 image&lt;/strong&gt; it finds, so it gets a schema 2 or OCI manifest, and search your manifests for old tags the check cannot see because no node has pulled them yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move mirrors&lt;/strong&gt; to &lt;code&gt;config_path&lt;/code&gt; and &lt;code&gt;hosts.toml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;containerd config migrate&lt;/code&gt;&lt;/strong&gt; , then delete the sections for things that no longer exist, such as runtime v1, before you ship the new config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade one node pool&lt;/strong&gt; , then drain a node onto a fresh one, so the new node has to pull every image from scratch. That is where schema 1 fails, so test it on purpose.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The CRI v1alpha2 removal.&lt;/strong&gt; containerd 2.0 removed the old CRI API. Any kubelet recent enough to still be supported uses CRI v1, but older tools that speak v1alpha2 will stop working. We had no such client to show it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real cluster.&lt;/strong&gt; The recordings come from standalone daemons on a Raspberry Pi 4 (arm64). The schema 1 images are amd64 only, so we pulled them with &lt;code&gt;--platform linux/amd64&lt;/code&gt; and did not run them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;aufs, custom runtimes, NRI plugins and GPU setups.&lt;/strong&gt; A node that relies on any of these needs its own test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every managed platform.&lt;/strong&gt; Node images decide the containerd version and its defaults. Check your provider's release notes, as we did for EKS.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The containerd 1.7 to 2.x upgrade is gentler than its changelog. An old config loads, deprecated mirrors keep working for now, and images already on the node stay in the image store. That gentleness is exactly what makes the one real break dangerous: schema 1 pulls fail on every node, but you only see it when a node has to pull, which usually means the fresh node next week, not the one you just upgraded. Run the check, rebuild the old images, clean up the config by hand after &lt;code&gt;config migrate&lt;/code&gt;, and test the upgrade on an empty node before the node pool does it for you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/containerd-1-7-end-of-life-what-breaks-on-2x" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>containerd</category>
      <category>containers</category>
      <category>upgrades</category>
    </item>
    <item>
      <title>Where to Run AI Agents: 8 Managed Agent Runtimes Compared</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 28 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/where-to-run-ai-agents-8-managed-agent-runtimes-compared-mgo</link>
      <guid>https://dev.to/devopsdaily/where-to-run-ai-agents-8-managed-agent-runtimes-compared-mgo</guid>
      <description>&lt;p&gt;An agent that only runs while your laptop is open is a demo. The moment it has to work through a backlog overnight, react to a webhook, or run for fifty users at once, it needs somewhere else to live: a sandbox it cannot escape, state that survives a pause, a way to reach tools without holding every credential, and a bill that does not surprise you.&lt;/p&gt;

&lt;p&gt;In 2026 that "somewhere else" became a product category. DigitalOcean put Managed Agents into public preview on 21 September, AWS shipped AgentCore Runtime V2 three days earlier, Microsoft made Foundry hosted agents generally available in July, Google renamed Vertex AI Agent Engine to Agent Runtime, and Cloudflare took Containers and its Sandbox SDK to GA in April. This post compares eight of them on the things that decide whether an agent survives production, and works out one cost scenario on every platform from list prices.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best overall for running coding and tool-using agents today: DigitalOcean Managed Agents.&lt;/strong&gt; Claude Code, Codex CLI and OpenCode start with one command, each session gets its own Firecracker microVM, checkpoints capture live memory, sessions have no 8 or 24 hour limit, and 16,000+ tools sit behind one MCP endpoint. It also has the lowest vCPU list price in the group. It is a public preview in one US region, and its network is open by default until you set an allowlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for many small stateful agents: Cloudflare.&lt;/strong&gt; Durable Objects give every agent its own storage and schedule, a hibernating agent is not billed for duration, and the Sandbox SDK adds a VM-isolated Linux box when an agent needs a shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best inside an enterprise cloud: AWS AgentCore, Google Agent Runtime or Microsoft Foundry,&lt;/strong&gt; depending on which cloud already holds your identity, network and audit trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best when you run the agent loop yourself: E2B, Vercel Sandbox or Modal.&lt;/strong&gt; They sell the sandbox, not the agent.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;An agent you want to host: a coding CLI such as Claude Code or Codex, a framework agent (LangGraph, ADK, Agents SDK), or your own loop.&lt;/li&gt;
&lt;li&gt;A rough idea of your workload: session length, how much of the time the agent waits on a model, and which tools it calls.&lt;/li&gt;
&lt;li&gt;Familiarity with the Model Context Protocol (MCP) helps for the tool-access sections.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we compared
&lt;/h2&gt;

&lt;p&gt;Every platform here promises "run your agent in the cloud". They differ on six things that matter once real work runs through them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Isolation.&lt;/strong&gt; Can agent-written code reach anything it should not? A microVM per session is the strongest common answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State.&lt;/strong&gt; Can a session pause and resume where it was, including running processes? Can you checkpoint it and try two approaches in parallel?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool access.&lt;/strong&gt; How does the agent reach GitHub, a CRM or a database, and where do the credentials live?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first agent.&lt;/strong&gt; How much packaging stands between you and a running session?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits.&lt;/strong&gt; Largest sandbox, longest session, regions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price.&lt;/strong&gt; List prices, and what they are billed on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The platforms fall into four groups, and the ranking makes more sense once you see them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four kinds of agent runtime&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosts for agents and coding CLIs&lt;/strong&gt; bring a harness, get a microVM

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean&lt;/strong&gt; Managed Agents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt; AgentCore Runtime&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework runtimes&lt;/strong&gt; deploy an agent built on their SDK

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt; Agent Runtime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft&lt;/strong&gt; Foundry hosted agents&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge stateful agents&lt;/strong&gt; one durable object per agent

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; Agents SDK + Sandbox&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandboxes for your own loop&lt;/strong&gt; you orchestrate, they isolate

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;E2B&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel Sandbox&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modal&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;p&gt;List prices on 28 September 2026. "Active" means CPU is billed only while it is in use.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;vCPU-hour&lt;/th&gt;
&lt;th&gt;Memory-hour&lt;/th&gt;
&lt;th&gt;Largest sandbox&lt;/th&gt;
&lt;th&gt;Longest session&lt;/th&gt;
&lt;th&gt;Regions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DigitalOcean Managed Agents&lt;/td&gt;
&lt;td&gt;Preview&lt;/td&gt;
&lt;td&gt;Firecracker microVM&lt;/td&gt;
&lt;td&gt;$0.044 (see note)&lt;/td&gt;
&lt;td&gt;$0.0095 per GB, peak&lt;/td&gt;
&lt;td&gt;16 vCPU / 32 GB&lt;/td&gt;
&lt;td&gt;No stated limit&lt;/td&gt;
&lt;td&gt;1 (Richmond)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Containers / Sandbox&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;VM per container&lt;/td&gt;
&lt;td&gt;$0.072, active&lt;/td&gt;
&lt;td&gt;$0.009 per GiB&lt;/td&gt;
&lt;td&gt;4 vCPU / 12 GiB&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;Global&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS AgentCore Runtime (V1 rates)&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;microVM&lt;/td&gt;
&lt;td&gt;$0.0895, active&lt;/td&gt;
&lt;td&gt;$0.00945 per GB&lt;/td&gt;
&lt;td&gt;2 vCPU / 8 GB&lt;/td&gt;
&lt;td&gt;8 h (14 days on Instances)&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Agent Runtime&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;$0.085&lt;/td&gt;
&lt;td&gt;$0.009 per GiB&lt;/td&gt;
&lt;td&gt;8 vCPU / 32 GiB&lt;/td&gt;
&lt;td&gt;"Days"&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Foundry hosted agents&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;VM per session&lt;/td&gt;
&lt;td&gt;$0.0994&lt;/td&gt;
&lt;td&gt;$0.0118 per GiB&lt;/td&gt;
&lt;td&gt;2 vCPU / 4 GiB&lt;/td&gt;
&lt;td&gt;Not stated (idle timeout 2 to 60 min)&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;Firecracker microVM&lt;/td&gt;
&lt;td&gt;$0.0504&lt;/td&gt;
&lt;td&gt;$0.0162 per GiB&lt;/td&gt;
&lt;td&gt;8 vCPU / 8 GiB (Hobby)&lt;/td&gt;
&lt;td&gt;24 h (Pro)&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Sandbox&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;Firecracker microVM&lt;/td&gt;
&lt;td&gt;$0.128, active&lt;/td&gt;
&lt;td&gt;$0.0212 per GB&lt;/td&gt;
&lt;td&gt;8 vCPU (Pro), 32 (Enterprise)&lt;/td&gt;
&lt;td&gt;24 h (Pro)&lt;/td&gt;
&lt;td&gt;About 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modal Sandboxes&lt;/td&gt;
&lt;td&gt;GA&lt;/td&gt;
&lt;td&gt;gVisor&lt;/td&gt;
&lt;td&gt;about $0.071&lt;/td&gt;
&lt;td&gt;$0.024 per GiB&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;24 h&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note on DigitalOcean: its price is quoted per vCPU-hour of actual use, but active-CPU billing is "coming soon". Until then it bills 25% of the vCPUs you allocate, whatever the agent does. Microsoft's public pricing page shows placeholders; its rates above come from Microsoft's Azure Retail Prices API.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. DigitalOcean Managed Agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.digitalocean.com/products/managed-agents/" rel="noopener noreferrer"&gt;Managed Agents&lt;/a&gt; is two services that work together: &lt;strong&gt;Harness Runtime&lt;/strong&gt; , which runs each agent session in its own Firecracker microVM, and &lt;strong&gt;Action Gateway&lt;/strong&gt; , a managed MCP endpoint in front of more than 16,000 tools. It went to public preview for all users on 21 September 2026 after a private preview in August.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it is first.&lt;/strong&gt; It is the only platform here where the agents most teams already use are first-class citizens. Claude Code, Codex CLI, OpenCode, Hermes and LangGraph are built-in adapters, and a custom container image covers the rest. Starting one is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doctl harness-runtime launch &lt;span class="nt"&gt;--harness&lt;/span&gt; claude-code &lt;span class="nt"&gt;--name&lt;/span&gt; repo-helper

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

&lt;/div&gt;



&lt;p&gt;The rest of the package is what a long-running agent needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real pause and resume.&lt;/strong&gt; Pausing "freezes the sandbox in place. Processes, memory, and the workspace filesystem are all preserved", and idle sessions pause themselves after 15 minutes by default. A paused session costs nothing for compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkpoints with live memory.&lt;/strong&gt; A checkpoint captures the workspace and memory. From it you can fork up to four copies to try different approaches (on the Claude Code, Codex CLI and OpenCode adapters), or roll back in place. Fly.io Sprites restores files only; Daytona forks only its VM sandboxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room to work.&lt;/strong&gt; Sizes go from 1 vCPU / 1 GB up to 16 vCPU / 32 GB with a 500 GB disk, more than AWS (2 vCPU / 8 GB), Microsoft (2 / 4) or Cloudflare (4 / 12) give a single session. There is no 8 or 24 hour session limit, only a cap of 744 active hours per session per month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools without credentials in the sandbox.&lt;/strong&gt; Action Gateway exposes its catalog through three meta tools (search, invoke, and a code runner) instead of flooding the model's context. Its OAuth connections are resolved at the gateway, so the key never enters the sandbox. It also works on its own, from a Claude Code or Codex on your laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One policy layer.&lt;/strong&gt; The coding adapters share the same &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;ask&lt;/code&gt; and &lt;code&gt;deny&lt;/code&gt; rules, with approvals from the terminal or with &lt;code&gt;doctl harness-runtime approve&lt;/code&gt;. Support varies in the details: Codex CLI cannot use &lt;code&gt;default: deny&lt;/code&gt;, and triggered runs must not use &lt;code&gt;ask&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One key for every model.&lt;/strong&gt; Instead of an Anthropic or OpenAI key, a session can use DigitalOcean serverless inference through &lt;code&gt;HARNESS_INFERENCE_MODEL&lt;/code&gt; and &lt;code&gt;HARNESS_INFERENCE_API_KEY&lt;/code&gt;, billed on the same DigitalOcean account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A session is described in YAML. This one follows the &lt;a href="https://docs.digitalocean.com/products/managed-agents/agent-harness-runtime/reference/environment-spec/" rel="noopener noreferrer"&gt;environment spec reference&lt;/a&gt; and locks down the two defaults you most likely want to change, the open network and the permission rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;repo-helper&lt;/span&gt;
&lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-code&lt;/span&gt;
&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mars-2vcpu-4gb&lt;/span&gt;
&lt;span class="na"&gt;idle_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&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;HARNESS_INFERENCE_MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic-claude-5-sonnet&lt;/span&gt;
&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;HARNESS_INFERENCE_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DO_MODEL_ACCESS_KEY}&lt;/span&gt;
&lt;span class="c1"&gt;# Naming one host turns egress into a deny-by-default allowlist&lt;/span&gt;
&lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api.github.com&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;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ask&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file.read&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ask&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Price.&lt;/strong&gt; $0.044 per vCPU-hour and $0.0095 per GB-hour of peak memory, the lowest vCPU list price in this comparison. Action Gateway calls cost $0.10 per 1,000, and the Exa search tool $10.10 per 1,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is a &lt;strong&gt;public preview&lt;/strong&gt; : no SLA, support on weekday business hours (Pacific time), no data durability guarantee, and the terms warn that the API and spec may change without notice.&lt;/li&gt;
&lt;li&gt;It runs in &lt;strong&gt;one region&lt;/strong&gt; , Richmond (RIC1), and data is processed in the US.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress is open by default&lt;/strong&gt; until you name a host, and ordinary secrets are readable inside the sandbox. Scoped secrets, which keep the real key at the egress proxy, are "not yet enabled everywhere".&lt;/li&gt;
&lt;li&gt;Billing runs from a &lt;strong&gt;prepaid balance&lt;/strong&gt; shared with your other DigitalOcean products, with no per-session spend cap, and paused sessions still count toward the limit of up to 100 sessions per team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you want Claude Code, Codex or OpenCode working in the cloud this week, need sessions up to 16 vCPU with no fixed time limit, or want a large tool catalog without hosting MCP servers yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cloudflare Agents SDK, Durable Objects and Sandbox
&lt;/h2&gt;

&lt;p&gt;Cloudflare approaches the problem from the other end. The &lt;a href="https://developers.cloudflare.com/agents/" rel="noopener noreferrer"&gt;Agents SDK&lt;/a&gt; turns each agent into a Durable Object with "a durable identity, local SQL storage, real-time connections, scheduled work, and recoverable execution", running in V8 isolates on Cloudflare's global network. When an agent needs a real Linux shell, the &lt;a href="https://developers.cloudflare.com/sandbox/" rel="noopener noreferrer"&gt;Sandbox SDK&lt;/a&gt; starts a container where "each sandbox runs in a separate VM". Containers and Sandbox went GA on 13 April 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it is second.&lt;/strong&gt; For agents that are many, small and long-lived (a support agent per customer, an email agent per inbox, a webhook agent per repository), it is built for exactly this. A Durable Object that hibernates is not billed for duration, CPU on Containers is billed only while active, Workers and Durable Objects run on Cloudflare's global network, and Containers can be placed by region or jurisdiction, including &lt;code&gt;eu&lt;/code&gt; and FedRAMP. The 2026 egress controls add host allow and deny lists plus credential injection, so a sandbox can call an API without holding the key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price.&lt;/strong&gt; Workers Paid ($5 a month), then Containers at $0.072 per active vCPU-hour and $0.009 per GiB-hour of provisioned memory. Durable Objects cost $0.15 per million requests and $12.50 per million GB-seconds of duration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The largest container is 4 vCPU, 12 GiB and 20 GB, and container disk is ephemeral (there is a backup and restore API). Heavy coding agents outgrow it.&lt;/li&gt;
&lt;li&gt;An agent handles 30 seconds of compute per request or message; long work goes into scheduled tasks or a sandbox.&lt;/li&gt;
&lt;li&gt;There is no hosted tool catalog. You build and host MCP servers yourself, and &lt;code&gt;McpAgent&lt;/code&gt; is deprecated in favour of &lt;code&gt;createMcpHandler&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;agents&lt;/code&gt; package is still pre-1.0 (0.24.0).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you run lots of stateful, mostly idle agents close to users, or want to host remote MCP servers at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AWS Bedrock AgentCore
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/" rel="noopener noreferrer"&gt;AgentCore&lt;/a&gt; is AWS's set of agent building blocks: Runtime, Gateway, Identity, Memory, Code Interpreter and Browser. Each Runtime session "receives its own dedicated microVM", and Runtime V2, launched on 18 September 2026, restores sessions from snapshots with a P75 cold start of 1.9 to 2.0 seconds in AWS's own numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; Runtime, Gateway and Identity run in 22 regions including GovCloud (V2 is in five so far), with IAM and VPC throughout. On V1 pricing, CPU "scales to zero during I/O wait". The Gateway turns your OpenAPI specs, Lambdas and MCP servers into tools for $0.005 per 1,000 calls. An &lt;strong&gt;Instances&lt;/strong&gt; option runs sessions on EC2 capacity in your account for up to 14 days, with GPUs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; A microVM session tops out at 2 vCPU and 8 GB and eight hours. Coding CLIs work (AWS has a walkthrough for Claude Code, Codex and others), but you package and push each one as a container yourself. The Gateway wraps your own APIs rather than shipping a SaaS catalog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; your identity, data and audit trail already live in AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Google Agent Runtime (formerly Vertex AI Agent Engine)
&lt;/h2&gt;

&lt;p&gt;Google renamed Vertex AI Agent Engine to &lt;strong&gt;Agent Runtime on Gemini Enterprise Agent Platform&lt;/strong&gt; in April 2026. It is a managed runtime for containerized agents, with full integration for Google's ADK and templates for LangGraph, LangChain, AG2 and LlamaIndex, plus Sessions and Memory Bank for state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; $0.085 per vCPU-hour and $0.009 per GiB-hour, and "idle time spent waiting for the next prompt between turns is not billed", which suits chat-style agents that sit waiting for users. Containers go up to 8 vCPU and 32 GiB, in 23 regions, with Agent Gateway for MCP and A2A governance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; It is a framework runtime, not a host for third-party coding CLIs. The Code Execution sandbox has "no network access" and does not let you install your own libraries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you build on ADK or LangGraph and already run on Google Cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Microsoft Foundry hosted agents
&lt;/h2&gt;

&lt;p&gt;Foundry Agent Service is GA, and its &lt;strong&gt;hosted agents&lt;/strong&gt; reached general availability on 9 July 2026. Hosted agents use "per-session VM-isolated sandboxes" with persistent home directories, and a Toolbox puts curated tools (code interpreter, web search, OpenAPI, MCP and A2A) "behind one managed MCP-compatible endpoint".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; Entra identity, VNet integration, publishing to Teams and Microsoft 365, and 31 regions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; Sessions are small (0.5 vCPU / 1 GiB up to 2 vCPU / 4 GiB, with up to 20 GiB of disk), idle timeouts run from 2 to 60 minutes, and hosted agents are Python and C# only. Hosted compute lists at $0.0994 per vCPU-hour and $0.0118 per GiB-hour in Microsoft's retail price API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; your agents serve people who live in Microsoft 365.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. E2B
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://e2b.dev/" rel="noopener noreferrer"&gt;E2B&lt;/a&gt; sells the sandbox, not the agent: "every E2B sandbox runs in its own Firecracker microVM with its own kernel", driven from its open source (Apache-2.0) SDKs while your code runs the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; Pause saves "both the sandbox's filesystem and memory state", resumes in about a second, and a paused sandbox "is kept indefinitely". An MCP gateway inside the sandbox offers 200+ tools from the Docker MCP Catalog. Compute is $0.0504 per vCPU-hour and $0.0162 per GiB-hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; Continuous runtime is capped at 1 hour on the free Hobby plan and 24 hours on Pro ($150 a month plus usage), and compute is billed per second on the sandbox size while it runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you are building a product with a code interpreter or coding agent inside it and want to own the orchestration.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Vercel Sandbox
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://vercel.com/docs/sandbox" rel="noopener noreferrer"&gt;Vercel Sandbox&lt;/a&gt; went GA in January 2026, and "each sandbox runs in its own Firecracker microVM with a dedicated kernel". Sessions can now run for 24 hours on Pro, persistence is on by default, and it is available in Vercel's compute regions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; CPU is billed only while active ($0.128 per hour), so time spent waiting on a model is not counted, and the firewall "injects credentials into egressing traffic. The secrets never enter the sandbox." The default image ships with Node, Python and coding agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; No first-party MCP gateway that we found, a 45-minute session limit on Hobby, and memory at $0.0212 per GB-hour, more than twice DigitalOcean's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; your agents are TypeScript and AI SDK code that already deploys to Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Modal Sandboxes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://modal.com/docs/guide/sandboxes" rel="noopener noreferrer"&gt;Modal&lt;/a&gt; runs sandboxes on gVisor, with VM sandboxes in beta, and is at its best for Python workloads and GPUs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths.&lt;/strong&gt; Very high fan-out (Modal claims 100,000+ concurrent sandboxes), GPU support, and egress controls down to a domain allowlist. Good for RL, evals and batch agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to watch.&lt;/strong&gt; Billing is "whichever is higher: your resource request or your actual usage", sandboxes cost about three times Modal Functions, memory snapshots are alpha, and there is no first-party MCP gateway that we found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you run many short Python agents or need GPUs next to the sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  One hour on every platform
&lt;/h2&gt;

&lt;p&gt;To put the price lists side by side, take the example from DigitalOcean's launch post: one hour on a 2 vCPU / 4 GB sandbox, with the agent averaging 25% CPU. Platforms that bill active CPU pay for half a vCPU-hour; the rest pay for the full allocation. Memory is billed on whatever basis each platform uses. Free tiers, plan fees, storage, tool calls and model tokens are left out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One hour at 2 vCPU and about 4 GB, 25% average CPU&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;DigitalOcean&lt;/td&gt;
&lt;td&gt;0.06$&lt;/td&gt;
&lt;td&gt;DigitalOcean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS AgentCore (V1)&lt;/td&gt;
&lt;td&gt;0.083$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;0.108$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Sandbox&lt;/td&gt;
&lt;td&gt;0.149$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B&lt;/td&gt;
&lt;td&gt;0.166$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Agent Runtime&lt;/td&gt;
&lt;td&gt;0.206$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modal&lt;/td&gt;
&lt;td&gt;0.238$&lt;/td&gt;
&lt;td&gt;Others&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Derived from list prices on 28 September 2026, not quotes. Cloudflare uses its predefined standard-3 type (2 vCPU / 8 GiB). Google counts the whole hour as active; its runtime does not bill idle time between turns, so a chatty agent can cost much less. Microsoft is left out because its billing basis is not confirmed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The arithmetic for the first two, so you can redo it with your own numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DigitalOcean 2 vCPU x 25% x $0.044 + 4 GB x $0.0095 = $0.022 + $0.038 = $0.060
AWS V1 0.5 vCPU-h x $0.0895 + 4 GB x $0.00945 = $0.045 + $0.038 = $0.083

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

&lt;/div&gt;



&lt;p&gt;Microsoft's retail rates imply about $0.246 if the full allocation is billed for the hour, but we could not confirm how hosted agents are billed.&lt;/p&gt;

&lt;p&gt;Two things matter more than the hourly rate. First, a session that never pauses runs for 744 hours in a 31-day month: a medium DigitalOcean session costs $44.64 for that before storage, which is why auto-pause matters. Second, model tokens are billed on top of every number here. Price them before you optimise the sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pick
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run Claude Code, Codex or OpenCode in the cloud with the least setup&lt;/td&gt;
&lt;td&gt;DigitalOcean Managed Agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running sessions that need more than 8 vCPU&lt;/td&gt;
&lt;td&gt;DigitalOcean Managed Agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many small, mostly idle agents close to users&lt;/td&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything must stay in AWS, with IAM and VPC&lt;/td&gt;
&lt;td&gt;AWS AgentCore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADK or LangGraph agents on Google Cloud&lt;/td&gt;
&lt;td&gt;Google Agent Runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents for Teams and Microsoft 365 users&lt;/td&gt;
&lt;td&gt;Microsoft Foundry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A code interpreter inside your own product&lt;/td&gt;
&lt;td&gt;E2B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript agents already on Vercel&lt;/td&gt;
&lt;td&gt;Vercel Sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python fan-out, evals or GPUs&lt;/td&gt;
&lt;td&gt;Modal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health or card data (PHI, PCI)&lt;/td&gt;
&lt;td&gt;Not DigitalOcean while it is in preview (its terms forbid it); check each GA platform's compliance programme&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;Cloudflare does not name the hypervisor behind its container VMs, and Google does not name the sandboxing technology behind Agent Runtime.&lt;/li&gt;
&lt;li&gt;Microsoft's public pricing page for hosted agents shows placeholders; the rates here come from its retail price API.&lt;/li&gt;
&lt;li&gt;DigitalOcean's docs disagree with themselves in places: the limits page says VPC connections are not supported while the spec documents a &lt;code&gt;vpc_uuid&lt;/code&gt; field, and resume is quoted as 200 ms in marketing and 305 ms in the launch benchmark.&lt;/li&gt;
&lt;li&gt;Prices and preview terms change fast in this category. Check each vendor's page before you commit.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The category split into two ideas in 2026. One says "give us your agent and we will run it": DigitalOcean, AWS, Google and Microsoft. The other says "we give you a sandbox, you run the agent": E2B, Vercel, Modal, and Cloudflare with its own twist of one durable object per agent.&lt;/p&gt;

&lt;p&gt;For most teams that want an agent like Claude Code or Codex working in the cloud now, DigitalOcean Managed Agents is the shortest path: one command to a microVM, pause and resume with memory, sandboxes up to 16 vCPU / 32 GB, a large tool catalog, and the lowest vCPU price on the list. Go in knowing it is a preview in one region, set an egress allowlist on day one, and keep an eye on the prepaid balance. If you need GA guarantees, multiple regions or a specific cloud's identity model, Cloudflare and the hyperscalers are close behind, and the decision table above tells you which.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/managed-agent-runtimes-compared-2026" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>aiagents</category>
      <category>digitalocean</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Issue to Pull Request with DigitalOcean Managed Agents</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 28 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/issue-to-pull-request-with-digitalocean-managed-agents-1gji</link>
      <guid>https://dev.to/devopsdaily/issue-to-pull-request-with-digitalocean-managed-agents-1gji</guid>
      <description>&lt;p&gt;We gave an agent three real GitHub issues and a microVM of its own, and kept every credential that could change the repository outside that microVM. The agent ran as OpenCode inside &lt;a href="https://docs.digitalocean.com/products/managed-agents/" rel="noopener noreferrer"&gt;DigitalOcean Managed Agents&lt;/a&gt;, on DeepSeek V4 Pro served by DigitalOcean's own serverless inference, so the only secret it ever held was a DigitalOcean model key.&lt;/p&gt;

&lt;p&gt;It produced three pull requests that we merged, each for about two cents of model tokens. It also produced one that passed the test suite and was still wrong, which is the most useful part of this post. Below is the whole setup, the recorded runs, the check we added after the wrong one, what the permission rules did and did not stop, and the gotchas that cost us time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr" rel="noopener noreferrer"&gt;The-DevOps-Daily/do-agent-issue-to-pr on GitHub&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One script turns an issue into a pull request.&lt;/strong&gt; It creates a Harness Runtime session, clones the repository into the microVM, prompts the agent once with no human attached, reads the diff back out, runs the tests again, and opens the pull request with credentials the agent never sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model runs on DigitalOcean.&lt;/strong&gt; &lt;code&gt;HARNESS_INFERENCE_MODEL: deepseek-v4-pro&lt;/code&gt; plus a model access key; no Anthropic or OpenAI account involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three issues, three merged fixes, about $0.02 to $0.03 of model tokens each&lt;/strong&gt; at DigitalOcean's list price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One pull request passed its tests and was wrong.&lt;/strong&gt; The agent added tests where &lt;code&gt;unittest&lt;/code&gt; never runs them. The script now refuses a change that touches &lt;code&gt;tests/&lt;/code&gt; without raising the number of tests that run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool rules are not the boundary.&lt;/strong&gt; OpenCode's edit and write tools were refused by our policy, so the agent wrote files with &lt;code&gt;bash&lt;/code&gt; instead. The sandbox, the missing credentials and the egress allowlist are what actually held.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A DigitalOcean account with Managed Agents (public preview since 21 September 2026) and an API token that can use it.&lt;/li&gt;
&lt;li&gt;A DigitalOcean model access key for serverless inference.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doctl&lt;/code&gt; 1.175.0 or later and the GitHub CLI.&lt;/li&gt;
&lt;li&gt;A repository with tests the agent can run. Ours is a deliberately small Python module so the runs are easy to read.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;The target is a small duration parser, &lt;code&gt;parse_duration("1h30m")&lt;/code&gt;, with three open issues: combined durations return the wrong number, days are not supported, and unknown units are silently ignored. All three are real bugs in the code as first committed, and the existing tests pass on all of them.&lt;/p&gt;

&lt;p&gt;The design rule was simple: whatever the agent can touch, it cannot publish. It edits files in a microVM. Everything that talks to GitHub with write access happens outside, in a script we control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One issue, one session, one pull request&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GitHub issue&lt;/strong&gt; labelled agent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;issue-to-pr.sh&lt;/strong&gt; holds the GitHub token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harness Runtime&lt;/strong&gt; OpenCode in a microVM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DO inference&lt;/strong&gt; DeepSeek V4 Pro&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull request&lt;/strong&gt; a person reviews&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The agent spec
&lt;/h2&gt;

&lt;p&gt;A session is described in YAML. This is the file every run uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;issue-to-pr&lt;/span&gt;
&lt;span class="na"&gt;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opencode&lt;/span&gt;
&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mars-2vcpu-4gb&lt;/span&gt;
&lt;span class="na"&gt;idle_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&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;HARNESS_INFERENCE_MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deepseek-v4-pro&lt;/span&gt;
&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;HARNESS_INFERENCE_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DO_INFERENCE_KEY}&lt;/span&gt;
&lt;span class="c1"&gt;# Naming a host turns egress into a deny-by-default allowlist. The platform&lt;/span&gt;
&lt;span class="c1"&gt;# adds GitHub (for the clone) and the model endpoint on its own.&lt;/span&gt;
&lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api.github.com&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;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;
  &lt;span class="na"&gt;filesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;workspace-write&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file.read&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file.write&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/workspace/**'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow&lt;/span&gt;
    &lt;span class="c1"&gt;# Last matching rule wins, so these override the allow above.&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;push*'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;curl&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="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;What each part is for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;agent: opencode&lt;/code&gt;&lt;/strong&gt; picks the OpenCode adapter. Managed Agents also has built-in adapters for Claude Code, Codex CLI, Hermes and LangGraph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HARNESS_INFERENCE_MODEL&lt;/code&gt; and &lt;code&gt;HARNESS_INFERENCE_API_KEY&lt;/code&gt;&lt;/strong&gt; route the model through DigitalOcean serverless inference. The key goes under &lt;code&gt;secrets&lt;/code&gt;, which are stored separately and never returned by the API. &lt;code&gt;${DO_INFERENCE_KEY}&lt;/code&gt; is filled in from your shell when you create the session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;egress&lt;/code&gt;&lt;/strong&gt; is open by default. Naming a single host switches the session to an allowlist, and the platform adds the hosts the adapter needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;permissions&lt;/code&gt;&lt;/strong&gt; start from &lt;code&gt;deny&lt;/code&gt;. For native actions the last matching rule wins, which is why the two &lt;code&gt;bash&lt;/code&gt; denies come after the broad &lt;code&gt;bash&lt;/code&gt; allow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check a policy before paying for a session. The validate endpoint returns a verdict per rule, and all five of ours came back &lt;code&gt;exact&lt;/code&gt; (&lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/blob/main/runs/checks/policy-validate.json" rel="noopener noreferrer"&gt;response&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.digitalocean.com/v2/agents/sessions/policy/validate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DIGITALOCEAN_ACCESS_TOKEN&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;"Content-Type: application/x-yaml"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @agent.yaml

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

&lt;/div&gt;



&lt;p&gt;It rejects a spec that still has a &lt;code&gt;${VAR}&lt;/code&gt; placeholder in it, so validate a copy with the secret filled in or replaced by a dummy value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script, step by step
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/blob/main/scripts/issue-to-pr.sh" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/issue-to-pr.sh&lt;/code&gt;&lt;/a&gt; is about 120 lines of bash. The parts that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A fresh microVM per issue.&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;doctl harness-runtime create &lt;span class="nt"&gt;--spec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;/agent.yaml"&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait-timeout&lt;/span&gt; 300 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Clone from outside the agent loop.&lt;/strong&gt; The session has no GitHub credentials, so it could not clone a private repository by itself. &lt;code&gt;exec&lt;/code&gt; runs a command in the sandbox directly, as root, so the checkout is handed to the &lt;code&gt;agent&lt;/code&gt; user the model runs as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doctl harness-runtime &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"git clone -q https://github.com/&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;&amp;amp; chown -R agent:agent &lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. One headless run.&lt;/strong&gt; &lt;code&gt;prompt&lt;/code&gt; sends a single prompt, waits for the run to finish, and exits 0, 1 or 124 on timeout. &lt;code&gt;--on-hitl reject&lt;/code&gt; refuses anything the policy would otherwise stop to ask a person about, because nobody is there to answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doctl harness-runtime prompt &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--on-hitl&lt;/span&gt; reject &lt;span class="nt"&gt;--timeout&lt;/span&gt; 1200 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; json - &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/answer.json"&lt;/span&gt; 2&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/progress.log"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The prompt includes the issue title and body and four rules: change only what the issue needs, add tests under &lt;code&gt;tests/&lt;/code&gt;, run the test suite, and do not commit or push. With &lt;code&gt;-o json&lt;/code&gt; the answer comes back with the run status and token counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check the work instead of trusting the summary.&lt;/strong&gt; The script reads the diff out of the sandbox and runs the tests there itself. It also counts the tests before and after, for a reason the next sections explain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doctl harness-runtime &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"git -c safe.directory=&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; -C &lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; add -A &amp;amp;&amp;amp; git -c safe.directory=&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; -C &lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; diff --cached"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;/change.patch"&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Open the pull request with our credentials.&lt;/strong&gt; The patch is applied to a fresh clone on the machine running the script, pushed to a branch named after the session, and opened with &lt;code&gt;gh pr create&lt;/code&gt;. The agent's closing summary becomes the pull request body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Remove the session.&lt;/strong&gt; An &lt;code&gt;EXIT&lt;/code&gt; trap saves the session's event log and removes the session, so a failed run does not leave a sandbox behind. It ignores errors from both commands, so check &lt;code&gt;doctl harness-runtime list&lt;/code&gt; now and then.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real run
&lt;/h2&gt;

&lt;p&gt;This is issue #3, "Reject durations with unknown units", exactly as the terminal printed it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;issue-to-pr.sh&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ scripts/issue-to-pr.sh 3
12:58:31 issue #3: Reject durations with unknown units
12:58:31 creating session issue-3-1790600274
12:58:51 tests before: 8
12:58:51 running the agent
12:59:36 tests pass in the sandbox: 8 before, 10 after
remote: 
remote: Create a pull request for 'agent/issue-3-1790600274' on GitHub by visiting:        
remote: https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/new/agent/issue-3-1790600274        
remote: 
12:59:43 opened https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/7
https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/issues/3#issuecomment-5870355576
12:59:49 removing session issue-3-1790600274

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

&lt;/div&gt;



&lt;p&gt;The change it produced, &lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/7" rel="noopener noreferrer"&gt;pull request #7&lt;/a&gt;, checks the whole string before parsing it:&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;_UNITS&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;s&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="n"&gt;_PART&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)([smhd])&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;_VALID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+[smhd])+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

 &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
     &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
         &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;empty duration&lt;/span&gt;&lt;span class="sh"&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_VALID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not a duration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;It also added two tests, one for &lt;code&gt;"1h30x"&lt;/code&gt; and one for &lt;code&gt;"abc"&lt;/code&gt;. A reviewer would point out that the old "no parts" check below it is now unreachable, which is the kind of note that belongs in a review, not a reason to reject the fix. We merged it. Issue #1 went the same way: a one-character fix (&lt;code&gt;total =&lt;/code&gt; to &lt;code&gt;total +=&lt;/code&gt;) and two tests, merged as &lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/4" rel="noopener noreferrer"&gt;#4&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pull request that passed and was wrong
&lt;/h2&gt;

&lt;p&gt;Issue #2 asked for days, &lt;code&gt;"7d"&lt;/code&gt; and &lt;code&gt;"1d12h"&lt;/code&gt;. The first run changed the code correctly and reported that all tests passed, and our script, which at that point only checked that the suite passed, opened &lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/5" rel="noopener noreferrer"&gt;pull request #5&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The tests it added looked like this at the bottom of the file:&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;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; __main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;main&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;test_days&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="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;They are inside the &lt;code&gt;if __name__&lt;/code&gt; block, not inside the test class, so &lt;code&gt;unittest&lt;/code&gt; never collects them. The agent's own summary said "All 8 tests pass (6 pre-existing + 2 new ones)". The test run our script did afterwards in the same sandbox printed &lt;code&gt;Ran 6 tests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A person reading the diff caught it. The script could not, because "the tests pass" was the only thing it checked. So it now counts how many tests run before and after the agent's change, and refuses to open a pull request if the agent touched &lt;code&gt;tests/&lt;/code&gt; but the count did not go up. It is a coarse check, since it cannot tell which new test ran, but it catches this failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;count_tests&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  doctl harness-runtime &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"cd &lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;&amp;amp; python3 -m unittest -q 2&amp;gt;&amp;amp;1"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/^Ran \([0-9]*\) tests\{0,1\}.*/\1/p'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The second run of issue #2 added four tests that ran (6 before, 10 after), but failed to push because pull request #5's branch still existed; each run now pushes to its own branch. The third run added two tests that ran and became &lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/pull/6" rel="noopener noreferrer"&gt;#6&lt;/a&gt;, which we merged.&lt;/p&gt;

&lt;p&gt;The lesson is not specific to this platform. An agent's report of its own work is a claim. Check the claim with something the agent did not write, and keep a person on the merge button.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the policy stopped, and what it did not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The network allowlist held.&lt;/strong&gt; With &lt;code&gt;egress&lt;/code&gt; limited to &lt;code&gt;api.github.com&lt;/code&gt;, we ran these inside a session with &lt;code&gt;doctl harness-runtime exec&lt;/code&gt; (&lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/blob/main/runs/checks/egress.txt" rel="noopener noreferrer"&gt;recorded here&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;inside the session&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 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import urllib.request as u; print(u.urlopen('https://example.com', timeout=8).status)"&lt;/span&gt;
&lt;span class="gp"&gt;urllib.error.URLError: &amp;lt;urlopen error Tunnel connection failed: 404 Not Found&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import urllib.request as u; print(u.urlopen('https://pypi.org/simple/', timeout=8).status)"&lt;/span&gt;
&lt;span class="gp"&gt;urllib.error.URLError: &amp;lt;urlopen error Tunnel connection failed: 404 Not Found&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import urllib.request as u; print(u.urlopen('https://github.com', timeout=8).status)"&lt;/span&gt;
&lt;span class="go"&gt;200

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

&lt;/div&gt;



&lt;p&gt;GitHub and the model endpoint still work, because the platform adds those hosts itself. An agent talked into leaking something by a malicious issue cannot reach an arbitrary server, though it can still reach the hosts on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tool rules did not do what we expected.&lt;/strong&gt; The session log for issue #3 shows OpenCode's own editing tools being refused:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;doctl harness-runtime logs issue-3-1790600274&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;▸ edit
[2026-09-28T12:58:59Z] run.tool_call_completed
  ✗ The user has specified a rule which prevents you from using this specific tool … (22ms)

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;file.write&lt;/code&gt; rule for &lt;code&gt;/workspace/**&lt;/code&gt; did not cover OpenCode's &lt;code&gt;edit&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt; tools in these runs, so &lt;code&gt;default: deny&lt;/code&gt; refused them. The agent did not stop. It changed the files through &lt;code&gt;bash&lt;/code&gt; instead, with &lt;code&gt;sed -i&lt;/code&gt; and &lt;code&gt;cat &amp;gt;&lt;/code&gt; heredocs, which we had allowed. Every merged fix in this post was written that way.&lt;/p&gt;

&lt;p&gt;DigitalOcean's docs warn about exactly this: "Rules match literally ... A denied action doesn't block the goal behind it." If &lt;code&gt;bash&lt;/code&gt; is allowed, the agent can do anything &lt;code&gt;bash&lt;/code&gt; can do inside the sandbox, whatever the other rules say. Treat tool rules as a way to shape the agent's behaviour, and treat the microVM, the missing credentials and the egress allowlist as the security boundary.&lt;/p&gt;

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

&lt;p&gt;Token counts come from the &lt;code&gt;prompt&lt;/code&gt; command's JSON output; the price is DigitalOcean's list price for DeepSeek V4 Pro, $1.74 per million input tokens and $3.48 per million output tokens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Input / output tokens&lt;/th&gt;
&lt;th&gt;Model cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Issue #1&lt;/td&gt;
&lt;td&gt;Merged (#4)&lt;/td&gt;
&lt;td&gt;8,481 / 1,024&lt;/td&gt;
&lt;td&gt;$0.018&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue #2, first&lt;/td&gt;
&lt;td&gt;Closed in review (#5)&lt;/td&gt;
&lt;td&gt;14,372 / 1,848&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue #2, second&lt;/td&gt;
&lt;td&gt;Refused to push (old branch)&lt;/td&gt;
&lt;td&gt;14,676 / 2,268&lt;/td&gt;
&lt;td&gt;$0.033&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue #2, third&lt;/td&gt;
&lt;td&gt;Merged (#6)&lt;/td&gt;
&lt;td&gt;9,868 / 1,605&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue #3&lt;/td&gt;
&lt;td&gt;Merged (#7)&lt;/td&gt;
&lt;td&gt;8,750 / 2,109&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two smaller costs sit on top. Each new session starts with a short readiness run, which used between 235 and 4,587 input tokens in ours. And the sandbox is billed while it exists: a Medium session lists at about $0.060 an hour under the current billing rule (25% of the allocated vCPUs plus peak memory), and by the script's timestamps each of ours existed for under two minutes, so compute came to a fraction of a cent per issue. These figures are derived from token counts and list prices; the new usage had not reached the invoice when we wrote this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas we hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;doctl&lt;/code&gt; wants an Anthropic key for Claude Code, even on DigitalOcean inference.&lt;/strong&gt; &lt;code&gt;doctl harness-runtime create&lt;/code&gt; refused a &lt;code&gt;claude-code&lt;/code&gt; spec that used &lt;code&gt;HARNESS_INFERENCE_*&lt;/code&gt;, asking for &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. In doctl's source, &lt;code&gt;prepareClaudeCodeStart&lt;/code&gt; resolves that key for every &lt;code&gt;claude-code&lt;/code&gt; session and checks it against Anthropic. Posting the same YAML to &lt;code&gt;POST /v2/agents/sessions&lt;/code&gt; with &lt;code&gt;Content-Type: application/x-yaml&lt;/code&gt; created the session (&lt;a href="https://github.com/The-DevOps-Daily/do-agent-issue-to-pr/tree/main/runs/checks" rel="noopener noreferrer"&gt;both recorded&lt;/a&gt;). OpenCode has no such check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model access depends on your inference tier.&lt;/strong&gt; Our key got "403 this model is not available for your subscription tier" for the Anthropic and OpenAI models we tried, both in a direct chat completion and inside a Claude Code session. Open models such as DeepSeek V4 Pro worked. Test the model you want with a plain chat completion before building on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--gh-repo&lt;/code&gt; did not clone anything for us.&lt;/strong&gt; Without a GitHub connection set up through &lt;code&gt;doctl harness-runtime auth&lt;/code&gt;, the workspace stayed empty. Cloning with &lt;code&gt;exec&lt;/code&gt; is explicit and works for public repositories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exec&lt;/code&gt; runs as root.&lt;/strong&gt; Files it creates belong to root, and the agent could not edit them until we added &lt;code&gt;chown&lt;/code&gt;. Git run as root then refuses the agent-owned checkout as "dubious ownership" unless you pass &lt;code&gt;-c safe.directory&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A closed pull request leaves its branch.&lt;/strong&gt; Name branches after the session, not the issue, or the next attempt cannot push.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wiring it to GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The repository includes a workflow that runs the same script when an issue gets the &lt;code&gt;agent&lt;/code&gt; label:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;labeled&lt;/span&gt;&lt;span class="pi"&gt;]&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;agent&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.label.name == 'agent'&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;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&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;digitalocean/action-doctl@v2&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;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}&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;scripts/issue-to-pr.sh "${{ github.event.issue.number }}"&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;DIGITALOCEAN_ACCESS_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;DO_INFERENCE_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DO_INFERENCE_KEY }}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two things to know before you turn it on. First, the DigitalOcean token in that secret should be able to do as little as possible, ideally in a separate DigitalOcean team: a token that can manage your whole account is a large thing to hand to a workflow. Second, pull requests opened with the workflow's own &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; do not trigger other workflows, so your normal test workflow will not run on them unless you open them with a GitHub App or a separate token. The script's own test run inside the sandbox covers the gap, but it is not a substitute for CI.&lt;/p&gt;

&lt;p&gt;The runs in this post were made with the script from a terminal, not through Actions.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whether this scales past a toy repository.&lt;/strong&gt; The target is a few dozen lines with a fast test suite. A real codebase means longer runs, more tokens, and tests the agent may not be able to run inside the sandbox without more egress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the &lt;code&gt;file.write&lt;/code&gt; rule did not cover OpenCode's edit tools.&lt;/strong&gt; We saw the refusals, not the mapping behind them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How other models compare.&lt;/strong&gt; Our inference tier only allowed open models, so we did not run the same issues through Claude or GPT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything about speed.&lt;/strong&gt; This is a how-to, not a benchmark, and Managed Agents is a public preview.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The pattern is small and reusable: a disposable microVM per task, a model key as the only secret inside, an egress allowlist, a script outside the sandbox that checks the agent's work with something the agent did not write, and a person on the merge button. On DigitalOcean Managed Agents the whole loop is a &lt;code&gt;create&lt;/code&gt;, a few &lt;code&gt;exec&lt;/code&gt; calls, one &lt;code&gt;prompt&lt;/code&gt; and a &lt;code&gt;remove&lt;/code&gt;, and the model can run on DigitalOcean too.&lt;/p&gt;

&lt;p&gt;The agent did good work on three small issues for a few cents each. It also produced a confident pull request whose new tests never ran, and it worked around refused editing tools through &lt;code&gt;bash&lt;/code&gt; without being asked to. Both are reasons to build the checks first and the automation second.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/issue-to-pull-request-digitalocean-managed-agents" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>digitalocean</category>
      <category>aiagents</category>
      <category>github</category>
    </item>
    <item>
      <title>Terraform State: Remove, Move and Migrate Resources, and Set Up a Remote Backend</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Sat, 26 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/terraform-state-remove-move-and-migrate-resources-and-set-up-a-remote-backend-kef</link>
      <guid>https://dev.to/devopsdaily/terraform-state-remove-move-and-migrate-resources-and-set-up-a-remote-backend-kef</guid>
      <description>&lt;p&gt;Terraform state is the file that maps every resource in your configuration to a real object somewhere: an instance ID, a bucket name, a DNS record. Most days you never look at it. Then someone renames a resource, splits a repository, or needs Terraform to let go of a database without deleting it, and suddenly the state file is the only thing that matters.&lt;/p&gt;

&lt;p&gt;This post covers the state operations that come up again and again: removing a resource from state, renaming or moving it, carrying it to another project, bootstrapping a remote backend, and the DynamoDB error people hit while building a lock table. Each one has an old way (a CLI command that edits state directly) and, in recent Terraform versions, a new way (a block in your configuration that goes through &lt;code&gt;plan&lt;/code&gt; like any other change). The new way is almost always better, and the sections below show why.&lt;/p&gt;

&lt;p&gt;All the terminal output in this post comes from real runs on Terraform 1.15.8, using the built-in &lt;code&gt;terraform_data&lt;/code&gt; resource so the examples run anywhere without a cloud account.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;To stop managing a resource without destroying it, use a &lt;code&gt;removed&lt;/code&gt; block with &lt;code&gt;destroy = false&lt;/code&gt; (Terraform 1.7+). &lt;code&gt;terraform state rm&lt;/code&gt; does the same thing but skips &lt;code&gt;plan&lt;/code&gt;, and it will recreate the resource if you forget to delete it from the config.&lt;/li&gt;
&lt;li&gt;To rename a resource, use a &lt;code&gt;moved&lt;/code&gt; block (Terraform 1.1+). It shows up in &lt;code&gt;plan&lt;/code&gt; and gets code review. &lt;code&gt;terraform state mv&lt;/code&gt; still works for one-off fixes.&lt;/li&gt;
&lt;li&gt;To move a resource to another project, remove it from the old one with a &lt;code&gt;removed&lt;/code&gt; block and adopt it in the new one with an &lt;code&gt;import&lt;/code&gt; block. Editing state files by hand is the fallback.&lt;/li&gt;
&lt;li&gt;To bootstrap a remote backend, create the bucket with local state first, then add the &lt;code&gt;backend&lt;/code&gt; block and run &lt;code&gt;terraform init -migrate-state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The S3 backend can lock with a lock file in the bucket (&lt;code&gt;use_lockfile = true&lt;/code&gt;, added in Terraform 1.10). DynamoDB locking has been deprecated since 1.11. Lock files also work on S3-compatible storage like DigitalOcean Spaces.&lt;/li&gt;
&lt;li&gt;Never commit &lt;code&gt;.tfstate&lt;/code&gt; to Git. Do commit &lt;code&gt;.terraform.lock.hcl&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Terraform 1.7 or later for &lt;code&gt;removed&lt;/code&gt; blocks, and 1.11 or later for the S3 lock-file examples (the feature arrived in 1.10 as experimental)&lt;/li&gt;
&lt;li&gt;A configuration with existing state to practice on, or the &lt;a href="https://devops-daily.com/games/terraform-terminal-simulator" rel="noopener noreferrer"&gt;Terraform terminal simulator&lt;/a&gt; if you want to try &lt;code&gt;terraform state list&lt;/code&gt; in the browser first&lt;/li&gt;
&lt;li&gt;Access to an object storage bucket (AWS S3 or DigitalOcean Spaces) for the backend sections&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What state actually tracks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt; what you want&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt; what Terraform thinks exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real infrastructure&lt;/strong&gt; what actually exists&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By default, every &lt;code&gt;plan&lt;/code&gt; compares three things: your configuration, the state file, and the real objects the provider can see. State is the link in the middle. It is keyed by &lt;strong&gt;resource address&lt;/strong&gt; (&lt;code&gt;aws_instance.web&lt;/code&gt;, &lt;code&gt;module.network.aws_vpc.main&lt;/code&gt;), so almost every problem in this post comes down to one of two questions: which address points at which real object, and which state file holds that address.&lt;/p&gt;

&lt;p&gt;You can see the addresses in your state at any time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;terraform state list&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 state list
&lt;span class="go"&gt;terraform_data.db
terraform_data.web

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  Stop managing a resource without deleting it
&lt;/h2&gt;

&lt;p&gt;The situation: a database, a DNS zone or a bucket was created by Terraform, and now it should live outside this configuration. Maybe another team owns it, maybe you are splitting a repository. You want Terraform to forget it, not destroy it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The old way: terraform state rm
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;state rm, then plan&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 state &lt;span class="nb"&gt;rm &lt;/span&gt;terraform_data.db
&lt;span class="go"&gt;Removed terraform_data.db
Successfully removed 1 resource instance(s).
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan
&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Plan: 1 to add, 0 to change, 0 to destroy.

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

&lt;/div&gt;



&lt;p&gt;That second command is the trap. &lt;code&gt;state rm&lt;/code&gt; removed the resource from state, but the &lt;code&gt;resource "terraform_data" "db"&lt;/code&gt; block is still in the configuration, so the next plan wants to &lt;strong&gt;create it again&lt;/strong&gt;. On a real database that means a second database, or a name collision. &lt;code&gt;state rm&lt;/code&gt; only works safely when you delete the block from the configuration in the same change, and nothing in the workflow reminds you to do that.&lt;/p&gt;

&lt;p&gt;It also skips &lt;code&gt;plan&lt;/code&gt; entirely. The change happens the moment you press enter, it never shows up in a pull request, and in CI there is nothing to review.&lt;/p&gt;

&lt;h3&gt;
  
  
  The new way: a removed block
&lt;/h3&gt;

&lt;p&gt;Delete the resource block and put a &lt;code&gt;removed&lt;/code&gt; block in its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;terraform_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&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;destroy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;# forget it, do not delete it&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;Now the removal is a normal change that goes through &lt;code&gt;plan&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;plan with a removed block&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 plan
&lt;span class="go"&gt;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;terraform_data.db will no longer be managed by Terraform, but will not be destroyed
&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;destroy &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false &lt;/span&gt;is &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;the configuration&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;  . resource "terraform_data" "db" {
        id = "a38fe64a-ecd1-489f-f733-8048076db5f0"
&lt;/span&gt;&lt;span class="gp"&gt;        #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;2 unchanged attributes hidden&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;    }
Plan: 0 to add, 0 to change, 0 to destroy.

Warning: Some objects will no longer be managed by Terraform
&lt;/span&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;Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
&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="go"&gt;terraform_data.web

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

&lt;/div&gt;



&lt;p&gt;The plan says exactly what will happen, your reviewer sees it, and there is no window where the configuration and the state disagree. Once the change is applied everywhere, you can delete the &lt;code&gt;removed&lt;/code&gt; block. If you leave &lt;code&gt;destroy&lt;/code&gt; out (it defaults to &lt;code&gt;true&lt;/code&gt;), the block becomes a way to destroy a resource on purpose, which is also useful, just not here.&lt;/p&gt;

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

&lt;p&gt;Before any state change, take a copy: &lt;code&gt;terraform state pull &amp;gt; backup.tfstate&lt;/code&gt;. It costs nothing and gives you a way back. Restoring it after another change is not a plain push: in our run &lt;code&gt;terraform state push backup.tfstate&lt;/code&gt; refused with "cannot import state with serial 3 over newer state with serial 4". &lt;code&gt;terraform state push -force backup.tfstate&lt;/code&gt; overwrites the current state and skips both the serial and the lineage checks, so treat it as an exception: take a fresh backup of the current state first, and remember it restores Terraform's records, not the infrastructure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Rename or move a resource inside a project
&lt;/h2&gt;

&lt;p&gt;Renaming &lt;code&gt;aws_instance.web&lt;/code&gt; to &lt;code&gt;aws_instance.frontend&lt;/code&gt; looks harmless in the code. Terraform sees it differently: one address disappeared and a new one appeared, so the plan destroys the old instance and creates a new one. For anything with data on it, that is an outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The new way first: a moved block
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"terraform_data"&lt;/span&gt; &lt;span class="s2"&gt;"frontend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"web-server"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;moved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;terraform_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;terraform_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&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;plan with a moved block&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 plan
&lt;span class="go"&gt;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;terraform_data.web has moved to terraform_data.frontend
&lt;span class="go"&gt;    resource "terraform_data" "frontend" {
        id = "0f0915bf-cbea-ff5f-1129-a346d2268e84"
&lt;/span&gt;&lt;span class="gp"&gt;        #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;2 unchanged attributes hidden&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;    }
Plan: 0 to add, 0 to change, 0 to destroy.

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

&lt;/div&gt;



&lt;p&gt;No destroy, no create, just a move that anyone can read in the pull request. &lt;code&gt;moved&lt;/code&gt; blocks also handle the moves that are painful by hand: pulling resources into a module (&lt;code&gt;from = aws_s3_bucket.logs&lt;/code&gt;, &lt;code&gt;to = module.logging.aws_s3_bucket.this&lt;/code&gt;), or switching from &lt;code&gt;count&lt;/code&gt; to &lt;code&gt;for_each&lt;/code&gt; (&lt;code&gt;from = aws_instance.web[0]&lt;/code&gt;, &lt;code&gt;to = aws_instance.web["primary"]&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;moved&lt;/code&gt; block is cheap to keep. In a reusable module, keep it for good: consumers can skip versions, and a removed &lt;code&gt;moved&lt;/code&gt; block turns their upgrade into a destroy and recreate. In a root configuration you can delete it once every state that uses the configuration has applied the move.&lt;/p&gt;

&lt;h3&gt;
  
  
  The old way: terraform state mv
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;terraform state mv&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 state &lt;span class="nb"&gt;mv &lt;/span&gt;terraform_data.frontend terraform_data.web
&lt;span class="go"&gt;Move "terraform_data.frontend" to "terraform_data.web"
Successfully moved 1 object(s).
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan
&lt;span class="go"&gt;No changes. Your infrastructure matches the configuration.

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

&lt;/div&gt;



&lt;p&gt;Here we undid the rename from the previous section: after applying the &lt;code&gt;moved&lt;/code&gt; block, we renamed the block back to &lt;code&gt;web&lt;/code&gt;, deleted the &lt;code&gt;moved&lt;/code&gt; block, and moved the state to match. It works, and for a quick fix on your own sandbox it is fine. The problem is the same as &lt;code&gt;state rm&lt;/code&gt;: it changes shared state immediately, outside review, and the code change that goes with it has to land separately. In a team, prefer &lt;code&gt;moved&lt;/code&gt;. Both &lt;code&gt;state rm&lt;/code&gt; and &lt;code&gt;state mv&lt;/code&gt; accept &lt;code&gt;-dry-run&lt;/code&gt; if you want to see what they would touch first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move resources to another project
&lt;/h2&gt;

&lt;p&gt;Splitting a large configuration into smaller ones (network in one project, applications in another) means carrying resources from one state file to a different one. There are two ways to do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The reviewable way: removed plus import
&lt;/h3&gt;

&lt;p&gt;In the &lt;strong&gt;source&lt;/strong&gt; project, delete the resource and let go of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web&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;destroy&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="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;In the &lt;strong&gt;target&lt;/strong&gt; project, add the resource block and adopt the existing object with an &lt;code&gt;import&lt;/code&gt; block (Terraform 1.5+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web&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;"i-0a1b2c3d4e5f67890"&lt;/span&gt; &lt;span class="c1"&gt;# the real instance ID&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_instance"&lt;/span&gt; &lt;span class="s2"&gt;"web"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ami&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ami-0c55b159cbfafe1f0"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t3.small"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Apply the source first, so the object is only ever managed by one project, then the target. Make sure nobody runs either project in between. The target's plan shows &lt;code&gt;1 to import&lt;/code&gt;, and if your resource block does not match the real object, the plan shows the differences before anything changes. If you would rather not write the resource block by hand, leave it out, keep only the &lt;code&gt;import&lt;/code&gt; block, and run &lt;code&gt;terraform plan -generate-config-out=generated.tf&lt;/code&gt; (to a file that does not exist yet): Terraform writes a starting block from the real object. Both changes go through plan and review, and at no point does anyone edit a state file.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fallback: move between state files directly
&lt;/h3&gt;

&lt;p&gt;Some resources cannot be imported, and sometimes you need to move dozens at once. &lt;code&gt;terraform state mv&lt;/code&gt; can write to a different state file. It only moves state, so move the configuration in the same change: delete the resource block from the source, add it to the target, and fix any references. With a remote backend, pull both states to local files, move the resource, and push them back:&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="k"&gt;in &lt;/span&gt;the &lt;span class="nb"&gt;source &lt;/span&gt;project
&lt;span class="gp"&gt;terraform state pull &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;source.tfstate
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;the target project
&lt;span class="gp"&gt;terraform state pull &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;target.tfstate
&lt;span class="go"&gt;terraform state mv -state=../source/source.tfstate -state-out=target.tfstate \
  aws_instance.web aws_instance.web
terraform state push target.tfstate

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;back &lt;span class="k"&gt;in &lt;/span&gt;the &lt;span class="nb"&gt;source &lt;/span&gt;project
&lt;span class="go"&gt;terraform state push source.tfstate

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

&lt;/div&gt;



&lt;p&gt;Here is the core of it on two local projects:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;move a resource to another project&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 state &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../source.tfstate &lt;span class="nt"&gt;-state-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../network/terraform.tfstate terraform_data.web terraform_data.web
&lt;span class="go"&gt;Move "terraform_data.web" to "terraform_data.web"
Successfully moved 1 object(s).
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;the target project, which now holds the resource
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform state list
&lt;span class="go"&gt;terraform_data.web
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan
&lt;span class="go"&gt;No changes. Your infrastructure matches the configuration.

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

&lt;/div&gt;



&lt;p&gt;In this run the target configuration already contained the &lt;code&gt;terraform_data.web&lt;/code&gt; block, which is why its plan shows no changes. Nobody else should run Terraform against either project while you do this, and you should keep the backups from the tip above. Like &lt;code&gt;state rm&lt;/code&gt; and &lt;code&gt;state mv&lt;/code&gt; inside one project, it changes state without a plan, so check both plans right after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up a remote backend, with Terraform itself
&lt;/h2&gt;

&lt;p&gt;State in a local &lt;code&gt;terraform.tfstate&lt;/code&gt; file works for one person on one laptop. The moment a second person or a CI job runs Terraform, you need a &lt;strong&gt;remote backend&lt;/strong&gt; with locking turned on for every writer, so two applies cannot write the same state at once. Not every backend locks, and on S3 it is opt-in, so check that yours does.&lt;/p&gt;

&lt;p&gt;The classic chicken-and-egg problem: you want Terraform to create the bucket that will hold Terraform's state. The answer is two steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: create the bucket with local state.&lt;/strong&gt; A small bootstrap configuration, applied once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bootstrap the state bucket&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS S3&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"state"&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="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_versioning"&lt;/span&gt; &lt;span class="s2"&gt;"state"&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="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;versioning_configuration&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;"Enabled"&lt;/span&gt; &lt;span class="c1"&gt;# every state write becomes a recoverable version&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_public_access_block"&lt;/span&gt; &lt;span class="s2"&gt;"state"&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="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_acls&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;block_public_policy&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;ignore_public_acls&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;restrict_public_buckets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DigitalOcean Spaces&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"digitalocean_spaces_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"state"&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;"acme-terraform-state"&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;"fra1"&lt;/span&gt;
  &lt;span class="nx"&gt;acl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: point the configuration at the bucket and migrate.&lt;/strong&gt; Add a &lt;code&gt;backend&lt;/code&gt; block:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend block&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS S3&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&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;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;"prod/network.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;"us-east-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;# lock with a .tflock object in the bucket, no DynamoDB&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;DigitalOcean Spaces&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&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;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;endpoints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://fra1.digitaloceanspaces.com"&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;"prod/network.tfstate"&lt;/span&gt;

    &lt;span class="c1"&gt;# Spaces speaks the S3 API; these skip AWS-only checks&lt;/span&gt;
    &lt;span class="nx"&gt;skip_credentials_validation&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;skip_requesting_account_id&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;skip_metadata_api_check&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;skip_region_validation&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;skip_s3_checksum&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;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt; &lt;span class="c1"&gt;# required by the backend, not used by Spaces&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="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;Then run &lt;code&gt;terraform init -migrate-state&lt;/code&gt;. Terraform notices the backend changed, asks whether to copy the existing state to the new backend, and from then on reads and writes it remotely. The same command handles any backend change later: a new bucket, a new key, or moving from one provider to another. Here it is moving local state to a new location:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;terraform init -migrate-state&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 init &lt;span class="nt"&gt;-migrate-state&lt;/span&gt; &lt;span class="nt"&gt;-force-copy&lt;/span&gt;
&lt;span class="go"&gt;Initializing the backend...
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Terraform has been successfully initialized!

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-force-copy&lt;/code&gt; answers yes to the copy prompt, which is what you want in a script. Run it without the flag the first time so you see the question.&lt;/p&gt;

&lt;p&gt;For Spaces, the credentials are a Spaces access key and secret, passed as &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; in the environment, not written in the backend block. DigitalOcean documents the full setup in &lt;a href="https://docs.digitalocean.com/products/spaces/reference/terraform-backend/" rel="noopener noreferrer"&gt;Configure DigitalOcean Spaces as a Terraform Remote State Backend&lt;/a&gt;, including state locking with &lt;code&gt;use_lockfile&lt;/code&gt; on Terraform 1.11 or later. If your state also holds secrets (it usually does), keep the bucket private and limit who has keys to it; that is the whole point of the next two sections.&lt;/p&gt;

&lt;p&gt;For the errors people usually hit on this step, from the wrong region to missing permissions, see &lt;a href="https://devops-daily.com/posts/terraform-s3-backend-configuration-errors" rel="noopener noreferrer"&gt;common S3 backend configuration errors&lt;/a&gt;, and if a lock gets stuck, &lt;a href="https://devops-daily.com/posts/terraform-statefile-locked" rel="noopener noreferrer"&gt;how to unlock a locked state file&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DynamoDB lock table is on its way out
&lt;/h2&gt;

&lt;p&gt;For years, locking on the S3 backend meant a separate DynamoDB table with a &lt;code&gt;LockID&lt;/code&gt; key. Terraform 1.10 added locking through S3 itself: at the start of an operation Terraform writes a &lt;code&gt;.tflock&lt;/code&gt; object next to the state with a conditional write that fails if the object already exists, so a second apply is blocked. With &lt;code&gt;use_lockfile = true&lt;/code&gt; you no longer need the table, and Terraform now says so when it sees the old setting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;terraform init with dynamodb_table&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 init
&lt;span class="go"&gt;Initializing the backend...
Warning: Deprecated Parameter
  on main.tf line 6, in terraform:
   6: dynamodb_table = "terraform-locks"
The parameter "dynamodb_table" is deprecated. Use parameter "use_lockfile"
instead.

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

&lt;/div&gt;



&lt;p&gt;If you are migrating an existing backend, you can set both &lt;code&gt;use_lockfile = true&lt;/code&gt; and &lt;code&gt;dynamodb_table&lt;/code&gt; for a while. A client with both settings takes both locks, so it excludes old clients that only know DynamoDB and new ones that only use the lock file. Retire the table only when every writer (people and CI jobs) has &lt;code&gt;use_lockfile&lt;/code&gt; enabled, has permission to create and delete the &lt;code&gt;.tflock&lt;/code&gt; object, and nothing depends on DynamoDB any more.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "all attributes must be indexed" error
&lt;/h3&gt;

&lt;p&gt;If you still build a DynamoDB table in Terraform, for locking or anything else, you will probably meet this error from the AWS provider sooner or later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: all attributes must be indexed. Unused attributes: ["category"]

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

&lt;/div&gt;



&lt;p&gt;The message sounds like a query rule, but it is about the &lt;code&gt;attribute&lt;/code&gt; blocks. In &lt;code&gt;aws_dynamodb_table&lt;/code&gt;, an &lt;code&gt;attribute&lt;/code&gt; block does not describe the item's fields. It declares the type of a &lt;strong&gt;key&lt;/strong&gt; : the table's &lt;code&gt;hash_key&lt;/code&gt; or &lt;code&gt;range_key&lt;/code&gt;, or a key of a global or local secondary index. DynamoDB is schemaless for every other field, so an &lt;code&gt;attribute&lt;/code&gt; that no key uses is an error.&lt;/p&gt;

&lt;p&gt;The wrong way, declaring fields as if it were a SQL table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_dynamodb_table"&lt;/span&gt; &lt;span class="s2"&gt;"orders"&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;"orders"&lt;/span&gt;
  &lt;span class="nx"&gt;billing_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PAY_PER_REQUEST"&lt;/span&gt;
  &lt;span class="nx"&gt;hash_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&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;"id"&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;"S"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&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;"category"&lt;/span&gt; &lt;span class="c1"&gt;# no key uses this: "all attributes must be indexed"&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;"S"&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 right way: declare only key attributes. If you do need to query by &lt;code&gt;category&lt;/code&gt;, make it a key of an index, and then its &lt;code&gt;attribute&lt;/code&gt; block is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_dynamodb_table"&lt;/span&gt; &lt;span class="s2"&gt;"orders"&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;"orders"&lt;/span&gt;
  &lt;span class="nx"&gt;billing_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PAY_PER_REQUEST"&lt;/span&gt;
  &lt;span class="nx"&gt;hash_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&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;"id"&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;"S"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&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;"category"&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;"S"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;global_secondary_index&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;"by-category"&lt;/span&gt;
    &lt;span class="nx"&gt;hash_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"category"&lt;/span&gt;
    &lt;span class="nx"&gt;projection_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ALL"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;For a lock table, the rule is short: one attribute, &lt;code&gt;LockID&lt;/code&gt; of type &lt;code&gt;S&lt;/code&gt;, as the hash key, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should .tfstate go in Git?
&lt;/h2&gt;

&lt;p&gt;No, for three reasons that each matter on their own:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;State can hold secrets in plain text.&lt;/strong&gt; Database passwords, generated keys, and values marked &lt;code&gt;sensitive&lt;/code&gt; are redacted in plan output, but &lt;code&gt;sensitive&lt;/code&gt; does not keep them out of state. (Newer ephemeral values and write-only arguments do, where a provider supports them.) A state file in Git is a credential in Git, forever, in every clone and every fork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git cannot lock.&lt;/strong&gt; Two people who run &lt;code&gt;apply&lt;/code&gt; from their own checkouts each write a different state. The next merge picks one, and Terraform loses track of whatever the other one created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State changes whenever an apply changes something.&lt;/strong&gt; Committing it makes every infrastructure change a merge conflict waiting to happen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What belongs where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# .gitignore
&lt;/span&gt;*.&lt;span class="n"&gt;tfstate&lt;/span&gt;
*.&lt;span class="n"&gt;tfstate&lt;/span&gt;.*
.&lt;span class="n"&gt;terraform&lt;/span&gt;/
&lt;span class="n"&gt;crash&lt;/span&gt;.&lt;span class="n"&gt;log&lt;/span&gt;
&lt;span class="c"&gt;# saved plans can contain sensitive values: save them as *.tfplan
&lt;/span&gt;*.&lt;span class="n"&gt;tfplan&lt;/span&gt;
&lt;span class="c"&gt;# only if your variable files hold secrets; commit a non-secret example instead
&lt;/span&gt;*.&lt;span class="n"&gt;tfvars&lt;/span&gt;
*.&lt;span class="n"&gt;tfvars&lt;/span&gt;.&lt;span class="n"&gt;json&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Commit &lt;code&gt;.terraform.lock.hcl&lt;/code&gt;, though. It pins the exact provider versions and checksums, so everyone and every CI run use the same provider build. And if state already made it into your history, rotating the secrets in it matters more than rewriting the history, because every existing clone still has the old file.&lt;/p&gt;

&lt;p&gt;Where state should live is a bigger question than a backend block: who is allowed to run apply, and whose laptop has the keys. We wrote about that in &lt;a href="https://dev.to/devopsdaily/who-owns-the-state-file-and-other-questions-that-decide-your-week-2cpe"&gt;Who owns the state file&lt;/a&gt;.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Instead of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stop managing a resource, keep it running&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;removed&lt;/code&gt; with &lt;code&gt;destroy = false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;terraform state rm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename a resource or move it into a module&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;moved&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;terraform state mv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move a resource to another project&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;removed&lt;/code&gt; in the source, &lt;code&gt;import&lt;/code&gt; in the target&lt;/td&gt;
&lt;td&gt;pulling, editing and pushing state files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start using a remote backend&lt;/td&gt;
&lt;td&gt;bootstrap the bucket, then &lt;code&gt;terraform init -migrate-state&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;copying state files by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock state on S3 or Spaces&lt;/td&gt;
&lt;td&gt;&lt;code&gt;use_lockfile = true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a DynamoDB table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep state safe&lt;/td&gt;
&lt;td&gt;a private, versioned bucket&lt;/td&gt;
&lt;td&gt;committing &lt;code&gt;.tfstate&lt;/code&gt; to Git&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern behind all of it: prefer changes that go through &lt;code&gt;plan&lt;/code&gt;. The config blocks (&lt;code&gt;removed&lt;/code&gt;, &lt;code&gt;moved&lt;/code&gt;, &lt;code&gt;import&lt;/code&gt;) turn state surgery into reviewable code, and the CLI commands are there for the rare case where that is not possible. If you want to go further with the language itself, &lt;a href="https://dev.to/devopsdaily/terraform-variables-loops-and-outputs-the-complete-guide-5d70"&gt;Terraform variables, loops and outputs&lt;/a&gt; covers the rest, and the &lt;a href="https://devops-daily.com/games/terraform-terminal-simulator" rel="noopener noreferrer"&gt;Terraform terminal simulator&lt;/a&gt; lets you practice &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;apply&lt;/code&gt; and &lt;code&gt;state list&lt;/code&gt; in the browser.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/terraform-state-remove-move-migrate-backend" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>terraformstate</category>
      <category>infrastructureascode</category>
      <category>s3</category>
    </item>
    <item>
      <title>Terraform Module Outputs, Inputs and Sources: How Modules Pass Data to Each Other</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Sat, 26 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/terraform-module-outputs-inputs-and-sources-how-modules-pass-data-to-each-other-42mm</link>
      <guid>https://dev.to/devopsdaily/terraform-module-outputs-inputs-and-sources-how-modules-pass-data-to-each-other-42mm</guid>
      <description>&lt;p&gt;A Terraform module is a box with a very small door. Everything inside it (resources, locals, data sources) is private. The only way in is an input variable, and the only way out is an output. Once that clicks, most module questions answer themselves: "how do I reference the instance my module created?", "how do I give this module my VPC?", "why can't I see &lt;code&gt;module.app.aws_instance.web&lt;/code&gt;?"&lt;/p&gt;

&lt;p&gt;This post walks through the module questions that come up most in real projects: outputs, passing values and whole resources between modules, modules with &lt;code&gt;for_each&lt;/code&gt;, Git sources pinned to a branch or tag, and the provider error people hit when they refactor old modules. Every command in the terminal blocks was run on Terraform 1.15.8 with the built-in &lt;code&gt;terraform_data&lt;/code&gt; resource and the &lt;code&gt;random&lt;/code&gt; provider, so you can repeat them without a cloud account.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A module exposes values only through &lt;code&gt;output&lt;/code&gt; blocks. From the caller you read them as &lt;code&gt;module.&amp;lt;name&amp;gt;.&amp;lt;output&amp;gt;&lt;/code&gt;. You cannot reach into a module's resources directly.&lt;/li&gt;
&lt;li&gt;To pass data into a module, declare a &lt;code&gt;variable&lt;/code&gt; in the module and set it in the &lt;code&gt;module&lt;/code&gt; block. That is how you "pass a resource" too: pass its attributes, or the whole object with a typed variable.&lt;/li&gt;
&lt;li&gt;Referencing &lt;code&gt;module.network.vpc_id&lt;/code&gt; from another module creates the dependency automatically. No &lt;code&gt;depends_on&lt;/code&gt; needed.&lt;/li&gt;
&lt;li&gt;A module with &lt;code&gt;for_each&lt;/code&gt; becomes a map of instances: &lt;code&gt;module.network["production"].vpc_id&lt;/code&gt;, or a &lt;code&gt;for&lt;/code&gt; expression to collect them all.&lt;/li&gt;
&lt;li&gt;Git module sources take &lt;code&gt;?ref=&lt;/code&gt; with a branch, tag or commit. Use tags or commits for anything shared, branches only while developing.&lt;/li&gt;
&lt;li&gt;"Provider configuration not present" means Terraform needs a provider configuration that is gone, most often because a module with its own &lt;code&gt;provider&lt;/code&gt; block was removed while its resources are still in state. Move provider blocks to the root, apply, then remove the module.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Terraform 1.4 or later for the examples (they use the built-in &lt;code&gt;terraform_data&lt;/code&gt; resource; we ran them on 1.15), and 1.7 or later for the &lt;code&gt;removed&lt;/code&gt; block mentioned at the end&lt;/li&gt;
&lt;li&gt;A root configuration with at least one local module, or the &lt;a href="https://devops-daily.com/games/terraform-terminal-simulator" rel="noopener noreferrer"&gt;Terraform terminal simulator&lt;/a&gt; to practice the basics first&lt;/li&gt;
&lt;li&gt;For the Git source section, access to a Git repository that holds a module&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The layout used in this post
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── main.tf # root module: calls the child modules
└── modules
    ├── network
    │ └── main.tf # creates a "VPC", outputs its id and CIDR
    └── app
        └── main.tf # takes a vpc_id input, creates a "server"

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Data only crosses a module boundary through variables and outputs&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Root module&lt;/strong&gt; main.tf&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;module.network&lt;/strong&gt; for_each: staging, production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;module.app&lt;/strong&gt; var.vpc_id&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root outputs&lt;/strong&gt; vpc_ids, app_server&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Root module -&amp;gt; module.network (cidr, name)&lt;/li&gt;
&lt;li&gt;module.network -&amp;gt; module.app (vpc_id output)&lt;/li&gt;
&lt;li&gt;module.network -&amp;gt; Root outputs (vpc_id)&lt;/li&gt;
&lt;li&gt;module.app -&amp;gt; Root outputs (server_id)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get a value out of a module: outputs
&lt;/h2&gt;

&lt;p&gt;Inside the module, an &lt;code&gt;output&lt;/code&gt; block decides what the caller can see:&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;# modules/network/main.tf&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"cidr"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&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;"terraform_data"&lt;/span&gt; &lt;span class="s2"&gt;"vpc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ID of the network, for modules that need to attach to it"&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;terraform_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"cidr"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cidr&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;In the caller, the module's outputs are attributes of &lt;code&gt;module.&amp;lt;name&amp;gt;&lt;/code&gt;. That is the only view you get. Try to reach past it, to the resource itself, and Terraform refuses:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;reaching inside a module&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;an output that tries to &lt;span class="nb"&gt;read &lt;/span&gt;a resource inside module.app
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan
&lt;span class="go"&gt;Error: Unsupported attribute
  on extra.tf line 2, in output "direct":
   2: value = module.app.terraform_data.server.id
    ├────────────────
    │ module.app is object with 1 attribute "server_id"
This object does not have an attribute named "terraform_data".

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

&lt;/div&gt;



&lt;p&gt;The error message is the whole lesson: &lt;code&gt;module.app is object with 1 attribute "server_id"&lt;/code&gt;. If the caller needs something, the module has to output it.&lt;/p&gt;

&lt;p&gt;A few habits make outputs easier to live with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output IDs and names, not whole resources, by default.&lt;/strong&gt; Callers then depend on a small, stable interface instead of every attribute of a resource type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;description&lt;/code&gt;.&lt;/strong&gt; It shows up in module documentation generators and in the registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark secrets &lt;code&gt;sensitive = true&lt;/code&gt;.&lt;/strong&gt; Terraform then hides the value in plan and apply output. The value is still stored in state, so that does not make it safe to share state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can output a whole object when the caller genuinely needs many attributes:&lt;/strong&gt; &lt;code&gt;value = aws_instance.web&lt;/code&gt; gives the caller &lt;code&gt;module.app.web.private_ip&lt;/code&gt;, &lt;code&gt;module.app.web.arn&lt;/code&gt; and so on, at the cost of a wider interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pass a resource into a module: inputs
&lt;/h2&gt;

&lt;p&gt;There is no way to hand a module "the resource" as a live link. You pass values through variables, and Terraform tracks the dependency for you.&lt;/p&gt;

&lt;p&gt;The common case is a single attribute:&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;# modules/app/main.tf&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Network the server attaches to"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&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;"terraform_data"&lt;/span&gt; &lt;span class="s2"&gt;"server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"server in &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;&lt;span class="k"&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;output&lt;/span&gt; &lt;span class="s2"&gt;"server_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;terraform_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server&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="c1"&gt;# main.tf&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"app"&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;"./modules/app"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="c1"&gt;# an output of another module&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;When a module needs several attributes of the same resource, pass them together as an object instead of five separate variables:&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;object&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;string&lt;/span&gt;
    &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;})&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;"app"&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;"./modules/app"&lt;/span&gt;
  &lt;span class="nx"&gt;network&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
    &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cidr&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;You can even pass a whole resource object into a variable, as long as the object type matches its attribute names. An &lt;code&gt;aws_vpc&lt;/code&gt; has &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;cidr_block&lt;/code&gt;, so the variable would be &lt;code&gt;object({ id = string, cidr_block = string })&lt;/code&gt; and the caller writes &lt;code&gt;network = aws_vpc.main&lt;/code&gt;; Terraform keeps the attributes the type names and discards the rest. &lt;code&gt;type = any&lt;/code&gt; also works. The typed object is better: it documents exactly which attributes the module relies on, and the error messages are clearer when something does not fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chain modules: one module's output as another's input
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;module "app"&lt;/code&gt; block above already chains two modules. Because it reads &lt;code&gt;module.network["production"].vpc_id&lt;/code&gt;, Terraform knows that everything in &lt;code&gt;module.app&lt;/code&gt; that uses the value depends on the network. You do not need &lt;code&gt;depends_on&lt;/code&gt;. Reach for &lt;code&gt;depends_on&lt;/code&gt; on a module only for dependencies Terraform cannot see from references, and even then prefer passing a real value: &lt;code&gt;depends_on&lt;/code&gt; on a module makes every resource in it wait for everything it depends on, which can make plans noisier.&lt;/p&gt;

&lt;p&gt;The pattern scales to longer chains, network to database to application, with each module taking the previous one's outputs as inputs. Keep the chain in the root module. A child module that calls another module to get at a third one's outputs is usually a sign that the boundaries are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modules with for_each: outputs become a map
&lt;/h2&gt;

&lt;p&gt;Put &lt;code&gt;for_each&lt;/code&gt; on a module block and you get one instance per key:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&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;"./modules/network"&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;staging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.10.0.0/16"&lt;/span&gt;
    &lt;span class="nx"&gt;production&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.20.0.0/16"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;cidr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"vpc_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&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;net&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"app_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server_id&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;module.network&lt;/code&gt; is now a map of module instances. Index it with a key to read one (&lt;code&gt;module.network["production"].vpc_id&lt;/code&gt;), or loop over it with a &lt;code&gt;for&lt;/code&gt; expression to collect an output from every instance. Here is the whole configuration applied:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;module outputs with for_each&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="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

app_server = "cc4f60a8-1f31-ee53-d88b-403eda671809"
vpc_ids = {
  "production" = "5f6dd161-90ed-1b1b-7d7f-046be2e59622"
  "staging" = "6b7f90ae-73b6-e693-25e1-a661428b3cfc"
}

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

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;for&lt;/code&gt; expression works for &lt;code&gt;count&lt;/code&gt; modules with a list instead of a map: &lt;code&gt;[for net in module.network : net.vpc_id]&lt;/code&gt;. If you need the values as a list from a &lt;code&gt;for_each&lt;/code&gt; module, wrap it: &lt;code&gt;values(module.network)[*].vpc_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Module sources: pin a Git branch, tag or commit
&lt;/h2&gt;

&lt;p&gt;Local paths are fine inside one repository. When several repositories share a module, keep it in Git and point &lt;code&gt;source&lt;/code&gt; at it. The &lt;code&gt;ref&lt;/code&gt; query parameter selects what to check out:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# a release tag: what production should use (if your tags are never moved)&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;"git::https://github.com/acme/terraform-modules.git//network?ref=v1.4.0"&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;"network_dev"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# a branch: moves every time someone pushes, fine while you develop the module&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;"git::https://github.com/acme/terraform-modules.git//network?ref=feature/ipv6"&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;"network_pinned"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# a full commit SHA: cannot move at all&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;"git::https://github.com/acme/terraform-modules.git//network?ref=4f2a9c1e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Details that trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The double slash&lt;/strong&gt; (&lt;code&gt;.git//network&lt;/code&gt;) selects a subdirectory of the repository. Without it, Terraform uses the repository root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private repositories over SSH&lt;/strong&gt; use &lt;code&gt;git::ssh://git@github.com/acme/terraform-modules.git//network?ref=v1.4.0&lt;/code&gt;, or the shorter &lt;code&gt;git@github.com:acme/terraform-modules.git//network?ref=v1.4.0&lt;/code&gt;. The machine running Terraform (including CI) needs a key that can read the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch sources do not update on their own.&lt;/strong&gt; A repeat &lt;code&gt;terraform init&lt;/code&gt; keeps the modules already in &lt;code&gt;.terraform/modules&lt;/code&gt; as long as their &lt;code&gt;source&lt;/code&gt; is unchanged. To pick up new commits on the branch, run &lt;code&gt;terraform get -update&lt;/code&gt; (modules only) or &lt;code&gt;terraform init -upgrade&lt;/code&gt; (modules, and it also reconsiders provider versions). A fresh checkout, like a CI runner, downloads whatever the branch points at right now. That is exactly why production should not point at a branch: two runs of the same commit of your root configuration can use different module code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags can move.&lt;/strong&gt; Git lets someone delete a tag and create it again on another commit. If nobody in your organisation re-tags releases, a tag is a good pin; if you cannot be sure, pin the full commit SHA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version constraints (&lt;code&gt;version = "~&amp;gt; 1.4"&lt;/code&gt;) only work with registry sources&lt;/strong&gt;, not with Git URLs. With Git, the &lt;code&gt;ref&lt;/code&gt; is your version pin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  "Provider configuration not present" when you refactor modules
&lt;/h2&gt;

&lt;p&gt;This one surprises people because it appears after deleting code, not adding it. Older modules often declared their own &lt;code&gt;provider&lt;/code&gt; block inside the module. Here is one:&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;# modules/legacy/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="nx"&gt;random&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/random"&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;# a provider block inside a child module: the legacy pattern&lt;/span&gt;
&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"random"&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;"random_pet"&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Everything works until you remove the &lt;code&gt;module "legacy"&lt;/code&gt; block from the root to get rid of it. Terraform wants to destroy &lt;code&gt;random_pet.name&lt;/code&gt;, but the provider configuration it needs to do that lived inside the module you just deleted:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;removing a module that had its own provider block&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 plan
&lt;span class="go"&gt;Error: Provider configuration not present
To work with module.legacy.random_pet.name (orphan) its original provider
configuration at
module.legacy.provider["registry.terraform.io/hashicorp/random"] is required,
but it has been removed. This occurs when a provider configuration is removed
while objects created by that provider still exist in the state. Re-add the
provider configuration to destroy module.legacy.random_pet.name (orphan),
after which you can remove the provider configuration again.

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

&lt;/div&gt;



&lt;p&gt;The fix is to separate the two changes. First move the provider configuration to the root and delete the &lt;code&gt;provider&lt;/code&gt; block from the module. Child modules inherit the root's default provider configurations automatically, so the module keeps working. Apply that on its own:&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 (root)&lt;/span&gt;
&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"random"&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;"legacy"&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;"./modules/legacy"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;step 1: provider moved to the root&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 plan
&lt;span class="go"&gt;No changes. Your infrastructure matches the configuration.
&lt;/span&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;Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;step 2: now delete the module block
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;terraform plan
&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;module.legacy.random_pet.name will be destroyed
&lt;span class="gp"&gt;  #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;because random_pet.name is not &lt;span class="k"&gt;in &lt;/span&gt;configuration&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;Plan: 0 to add, 0 to change, 1 to destroy.

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

&lt;/div&gt;



&lt;p&gt;Now removing the module is a normal destroy. If you want Terraform to forget the resources instead of destroying them, put a &lt;code&gt;removed&lt;/code&gt; block in place of the module (&lt;code&gt;from = module.legacy&lt;/code&gt; with &lt;code&gt;destroy = false&lt;/code&gt;); our &lt;a href="https://dev.to/devopsdaily/terraform-state-remove-move-and-migrate-resources-and-set-up-a-remote-backend-1lcn-temp-slug-2440263"&gt;Terraform state post&lt;/a&gt; covers &lt;code&gt;removed&lt;/code&gt; and &lt;code&gt;moved&lt;/code&gt; in detail.&lt;/p&gt;

&lt;p&gt;Two rules keep you out of this for good:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;provider&lt;/code&gt; blocks in reusable modules.&lt;/strong&gt; A module declares what it needs in &lt;code&gt;required_providers&lt;/code&gt;; the root decides how providers are configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a module needs a non-default provider&lt;/strong&gt; , for example a second AWS region, pass it explicitly from the root:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alias&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu"&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"backup_bucket"&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;"./modules/bucket"&lt;/span&gt;
  &lt;span class="nx"&gt;providers&lt;/span&gt; &lt;span class="p"&gt;=&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="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eu&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;For the bigger picture of sharing providers and variables between many modules, see &lt;a href="https://devops-daily.com/posts/terraform-provider-variable-sharing-modules" rel="noopener noreferrer"&gt;how to share providers and variables across Terraform modules&lt;/a&gt;.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How do I read a value from a module?&lt;/td&gt;
&lt;td&gt;Add an &lt;code&gt;output&lt;/code&gt; in the module, read &lt;code&gt;module.&amp;lt;name&amp;gt;.&amp;lt;output&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can I reference a resource inside a module directly?&lt;/td&gt;
&lt;td&gt;No, only its outputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How do I pass a resource into a module?&lt;/td&gt;
&lt;td&gt;Pass its attributes, or a typed object, through a &lt;code&gt;variable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Do chained modules need &lt;code&gt;depends_on&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;No, references create the dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How do I read outputs from a &lt;code&gt;for_each&lt;/code&gt; module?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;module.x["key"].output&lt;/code&gt;, or a &lt;code&gt;for&lt;/code&gt; expression over &lt;code&gt;module.x&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch, tag or commit as a Git source?&lt;/td&gt;
&lt;td&gt;Tag or commit for shared code, branch only while developing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Provider configuration not present"?&lt;/td&gt;
&lt;td&gt;Move the provider to the root, apply, then remove the module&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Modules stay pleasant to work with when their interface is small and explicit: a few typed variables in, a few described outputs out, no provider blocks inside. For the language features that go into those interfaces, &lt;a href="https://dev.to/devopsdaily/terraform-variables-loops-and-outputs-the-complete-guide-5d70"&gt;Terraform variables, loops and outputs&lt;/a&gt; is the next read, and for laying modules out across environments, see &lt;a href="https://devops-daily.com/posts/organize-terraform-modules-multiple-environments" rel="noopener noreferrer"&gt;how to organize Terraform modules for multiple environments&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/terraform-module-outputs-inputs-and-sources" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>terraformmodules</category>
      <category>infrastructureascode</category>
      <category>hcl</category>
    </item>
  </channel>
</rss>
