<?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: Amaresh Pelleti</title>
    <description>The latest articles on DEV Community by Amaresh Pelleti (@amareswer).</description>
    <link>https://dev.to/amareswer</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%2F3978481%2Fbef1aa2c-c07a-414a-bb88-ea788ca39ba2.jpg</url>
      <title>DEV Community: Amaresh Pelleti</title>
      <link>https://dev.to/amareswer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amareswer"/>
    <language>en</language>
    <item>
      <title>Docker Volumes vs Bind Mounts: Which to Use</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Mon, 09 Nov 2026 13:41:51 +0000</pubDate>
      <link>https://dev.to/amareswer/docker-volumes-vs-bind-mounts-which-to-use-dof</link>
      <guid>https://dev.to/amareswer/docker-volumes-vs-bind-mounts-which-to-use-dof</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/docker-volumes-vs-bind-mounts/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwo6mx8b8842e30k6d30h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwo6mx8b8842e30k6d30h.webp" alt="Where a Docker volume and a bind mount store data, on Docker's side or on your host" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Use a Docker volume when the container owns the data, such as a database or a model cache. Use a bind mount when you need to edit the files on your host, such as source code or a config file. The docker volumes vs bind mounts choice also changes startup behavior and speed. It decides how easy it is to lose data, too. So it's worth testing before you pick.&lt;/p&gt;

&lt;p&gt;I ran everything below on Docker Engine 29.4.3 with Docker Desktop for Mac. I didn't test a Linux host, so I flag the spots where it may differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Difference Between Docker Volumes vs Bind Mounts?
&lt;/h2&gt;

&lt;p&gt;A volume is storage that Docker creates and manages for you. A bind mount maps a folder that already exists on your host into the container. Docker's &lt;a href="https://docs.docker.com/engine/storage/volumes/" rel="noopener noreferrer"&gt;volumes documentation&lt;/a&gt; calls volumes the preferred way to persist container data. Its &lt;a href="https://docs.docker.com/engine/storage/bind-mounts/" rel="noopener noreferrer"&gt;bind mounts page&lt;/a&gt; points bind mounts at sharing source code and host config files.&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;Volume&lt;/th&gt;
&lt;th&gt;Bind mount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who manages it&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where data lives&lt;/td&gt;
&lt;td&gt;Docker's storage area&lt;/td&gt;
&lt;td&gt;A path you choose on the host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host path must exist&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, unless Docker creates it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fills from the image on first use&lt;/td&gt;
&lt;td&gt;Yes (copy-up)&lt;/td&gt;
&lt;td&gt;No, it hides the image's files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Easy to edit from the host&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Databases, caches, app state&lt;/td&gt;
&lt;td&gt;Source code, config files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When Should You Use a Docker Volume?
&lt;/h2&gt;

&lt;p&gt;Use a volume for any data the container creates and you want to keep, but never edit by hand. Volumes survive the container, and Docker handles their location. I wrote a file into a volume, let the container exit, and read it back from a new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dvbtest-data:/data alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo saved &amp;gt; /data/note.txt'&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dvbtest-data:/data alpine &lt;span class="nb"&gt;cat&lt;/span&gt; /data/note.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;saved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--rm&lt;/code&gt; flag deleted both containers, but the volume stayed, and &lt;code&gt;docker volume ls&lt;/code&gt; still listed it. &lt;code&gt;docker volume inspect&lt;/code&gt; shows where Docker stores it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local /var/lib/docker/volumes/dvbtest-data/_data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ Note: that path does not exist on a Mac. On Docker Desktop the volume lives inside the Linux VM, so &lt;code&gt;ls&lt;/code&gt; on my host returned &lt;code&gt;No such file or directory&lt;/code&gt;. If you need to browse the files from your host, a volume is the wrong tool.&lt;/p&gt;

&lt;p&gt;The same pattern powers the model cache in the &lt;a href="https://devtoolhub.com/ollama-docker-setup-gpu-volumes/" rel="noopener noreferrer"&gt;Ollama Docker setup guide&lt;/a&gt;. Without a named volume, those downloads vanish with the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Use a Bind Mount?
&lt;/h2&gt;

&lt;p&gt;Use a bind mount when your host and the container need the same files. Source code you edit in an IDE is the classic case. So is a config file you maintain. Per the docs, a bind mount gives the container write access to your host files by default. Add &lt;code&gt;:ro&lt;/code&gt; when the container only needs to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; /tmp/dvb/auto-created:/data:ro alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo z &amp;gt; /data/y'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sh: can't create /data/y: Read-only file system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container can't change the host folder now. That matters because a bug or a bad command inside the container can otherwise delete files on your machine.&lt;/p&gt;

&lt;p&gt;On ownership: my container wrote as root, and the file still showed up on my Mac owned by my own user. That mapping is a Docker Desktop behavior. I didn't test Linux, where a root process in the container may leave root-owned files on the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a Mount Hide the Files Already in the Image?
&lt;/h2&gt;

&lt;p&gt;A bind mount always hides what the image has in that folder. A new named volume does the opposite: Docker copies the image's files into it first. I mounted both over the NGINX web root, &lt;code&gt;/usr/share/nginx/html&lt;/code&gt;, which ships with &lt;code&gt;index.html&lt;/code&gt; and &lt;code&gt;50x.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An empty host folder as a bind mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total 4
drwxr-xr-x    2 root     root            64 Nov  5 13:57 .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That folder looked empty, and &lt;code&gt;index.html&lt;/code&gt; was gone from view. A new named volume at the same path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50x.html
index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker copied both files into the volume on first use. To switch that copy off, use &lt;code&gt;volume-nocopy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,src&lt;span class="o"&gt;=&lt;/span&gt;dvbtest-nocopy,dst&lt;span class="o"&gt;=&lt;/span&gt;/usr/share/nginx/html,volume-nocopy nginx:alpine &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; /usr/share/nginx/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That listed zero files. Note the copy-up only happens when the volume is empty. A volume that already holds data hides the image's files, just like a bind mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Volumes vs Bind Mounts With -v and --mount
&lt;/h2&gt;

&lt;p&gt;The two flags differ in one way that bites people: what happens when the host path is missing. With &lt;code&gt;-v&lt;/code&gt;, Docker silently creates the folder. With &lt;code&gt;--mount&lt;/code&gt;, it stops with an error. Here is &lt;code&gt;-v&lt;/code&gt; pointing at a path that didn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; /tmp/dvb/auto-created:/data alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo hi &amp;gt; /data/x'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ran fine and created &lt;code&gt;/tmp/dvb/auto-created&lt;/code&gt; on my host. The same mismatch with &lt;code&gt;--mount&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,src&lt;span class="o"&gt;=&lt;/span&gt;/tmp/dvb/nope,dst&lt;span class="o"&gt;=&lt;/span&gt;/data alpine &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /host_mnt/private/tmp/dvb/nope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exited with code 125. The &lt;code&gt;/host_mnt/...&lt;/code&gt; prefix looks like Docker Desktop's file-sharing path translation. Add &lt;code&gt;bind-create-src&lt;/code&gt; to the &lt;code&gt;--mount&lt;/code&gt; options if you want the folder created, as the docs describe.&lt;/p&gt;

&lt;p&gt;A typo in a &lt;code&gt;-v&lt;/code&gt; path therefore creates an empty folder and starts the container happily. That is a classic source of "my config isn't loading" bugs. Prefer &lt;code&gt;--mount&lt;/code&gt; in scripts.&lt;/p&gt;

&lt;p&gt;There is a second trap in the &lt;code&gt;-v&lt;/code&gt; syntax. A bare name is a volume, and a path starting with &lt;code&gt;./&lt;/code&gt; is a bind mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; data:/app alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo a &amp;gt; /app/f'&lt;/span&gt;    &lt;span class="c"&gt;# creates a named volume "data"&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./data:/app alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo b &amp;gt; /app/f'&lt;/span&gt;  &lt;span class="c"&gt;# writes to ./data on your host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first command, &lt;code&gt;docker volume ls&lt;/code&gt; listed a volume called &lt;code&gt;data&lt;/code&gt; and my project folder stayed empty. The second wrote &lt;code&gt;f&lt;/code&gt; into &lt;code&gt;./data&lt;/code&gt;. Forget the &lt;code&gt;./&lt;/code&gt; and you create a hidden volume instead of the folder you meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Much Slower Is a Bind Mount on Docker Desktop?
&lt;/h2&gt;

&lt;p&gt;On my Mac, writing 3,000 small files into a bind mount took about five times longer than into a volume. Each run launched a container and created the files with a shell loop, so both numbers include roughly the same startup cost:&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;Bind mount&lt;/th&gt;
&lt;th&gt;Named volume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4.49 s&lt;/td&gt;
&lt;td&gt;0.70 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3.27 s&lt;/td&gt;
&lt;td&gt;0.73 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3.78 s&lt;/td&gt;
&lt;td&gt;0.67 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a rough test: three runs, one machine, one workload. The gap likely comes from Docker Desktop crossing the VM boundary on every file operation. Expect it to matter for &lt;code&gt;node_modules&lt;/code&gt;, package caches and builds. On a Linux host, a bind mount is a direct path and I'd expect no such penalty, but I haven't measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Delete Volumes Safely?
&lt;/h2&gt;

&lt;p&gt;Containers don't delete volumes, so unused ones pile up until you remove them. Compose shows the difference well. I started a service with a volume, then ran &lt;code&gt;docker compose down&lt;/code&gt;. The volume survived:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dvbtest_appdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ Note: &lt;code&gt;docker compose down -v&lt;/code&gt; deletes every named volume declared in that Compose file, along with its data. Run &lt;code&gt;docker compose down&lt;/code&gt; without &lt;code&gt;-v&lt;/code&gt; unless you really want a clean slate.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Volume dvbtest_appdata Removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The earlier run's log line was still there when I brought the service back up, so the data had persisted until I used &lt;code&gt;-v&lt;/code&gt;. For one volume, use &lt;code&gt;docker volume rm &amp;lt;name&amp;gt;&lt;/code&gt;. &lt;code&gt;docker volume prune&lt;/code&gt; removes all unused volumes, so read what it lists first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Volumes vs Bind Mounts: Which Should You Use?
&lt;/h2&gt;

&lt;p&gt;Pick a volume for data the container owns and a bind mount for files you edit. If you're unsure, start with a volume, because it's portable and faster on Docker Desktop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Databases, caches, uploads, anything you back up with Docker&lt;/td&gt;
&lt;td&gt;Volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source code, config files, build output you want on your host&lt;/td&gt;
&lt;td&gt;Bind mount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config the container should read but never change&lt;/td&gt;
&lt;td&gt;Bind mount with &lt;code&gt;:ro&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporary state you don't need to keep (Docker's docs point to &lt;code&gt;tmpfs&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Neither&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the rest of your container setup, the &lt;a href="https://devtoolhub.com/docker-best-practices-a-beginners-guide/" rel="noopener noreferrer"&gt;Docker best practices guide&lt;/a&gt; covers the surrounding habits, and the &lt;a href="https://devtoolhub.com/5-best-practices-for-writing-dockerfiles/" rel="noopener noreferrer"&gt;Dockerfile best practices&lt;/a&gt; cover the image side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I use a Docker volume or a bind mount for a database?&lt;/strong&gt;&lt;br&gt;
A: Use a volume. Docker manages its location, it survives container removal, and on Docker Desktop it avoided the slowdown I saw with bind mounts. A bind mount makes sense for a database only if you must inspect the files directly from the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between &lt;code&gt;-v&lt;/code&gt; and &lt;code&gt;--mount&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
A: &lt;code&gt;--mount&lt;/code&gt; uses explicit &lt;code&gt;key=value&lt;/code&gt; options and errors if a bind source path is missing. &lt;code&gt;-v&lt;/code&gt; is shorter and silently creates a missing host folder. Both can create volumes or bind mounts, but &lt;code&gt;--mount&lt;/code&gt; is the clearer choice for scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do Docker volumes persist after the container is deleted?&lt;/strong&gt;&lt;br&gt;
A: Yes. Named volumes stay until you remove them with &lt;code&gt;docker volume rm&lt;/code&gt;, &lt;code&gt;docker volume prune&lt;/code&gt; or &lt;code&gt;docker compose down -v&lt;/code&gt;. Anonymous volumes created with &lt;code&gt;docker run --rm&lt;/code&gt; are removed along with the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why is my bind mount empty inside the container?&lt;/strong&gt;&lt;br&gt;
A: A bind mount hides whatever the image has at that path. If the host folder is empty, the container sees an empty folder. A new named volume works differently, because Docker copies the image's files into it first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Are Docker bind mounts slower than volumes?&lt;/strong&gt;&lt;br&gt;
A: On Docker Desktop for Mac, yes. Creating 3,000 small files took 3.3 to 4.5 seconds on a bind mount and about 0.7 seconds on a volume in my tests. I didn't measure a Linux host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker manages a volume, while a bind mount maps a host folder you choose&lt;/li&gt;
&lt;li&gt;Volumes survived container removal in my test, and &lt;code&gt;docker compose down -v&lt;/code&gt; is what deletes them&lt;/li&gt;
&lt;li&gt;New volumes get the image's files copied in first, but a bind mount hides them&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-v&lt;/code&gt; creates a missing host path silently, and &lt;code&gt;--mount&lt;/code&gt; stops with exit code 125&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-v data:/app&lt;/code&gt; makes a volume, and &lt;code&gt;-v ./data:/app&lt;/code&gt; makes a bind mount&lt;/li&gt;
&lt;li&gt;Creating 3,000 files took about five times longer on a bind mount than a volume on Docker Desktop for Mac&lt;/li&gt;
&lt;li&gt;Any bind mount the container should only read needs &lt;code&gt;:ro&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before your next &lt;code&gt;docker run -v&lt;/code&gt;, decide which side owns the data. In the docker volumes vs bind mounts choice, that answer settles it. Container-owned data goes in a volume. Files you edit go in a bind mount.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Git Pull vs Fetch: What Each Command Really Does</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:26:29 +0000</pubDate>
      <link>https://dev.to/amareswer/git-pull-vs-fetch-what-each-command-really-does-2m75</link>
      <guid>https://dev.to/amareswer/git-pull-vs-fetch-what-each-command-really-does-2m75</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/git-pull-vs-fetch/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfx1nufoe69a50lcnz04.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfx1nufoe69a50lcnz04.webp" alt="Flow from remote to origin/main to the local branch, with fetch and pull arrows" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git fetch&lt;/code&gt; downloads new commits and leaves your branch and your files alone. &lt;code&gt;git pull&lt;/code&gt; runs a fetch and then merges or rebases the result into your current branch. The git pull vs fetch choice comes down to one question: do you want to look at incoming changes first, or take them right now?&lt;/p&gt;

&lt;p&gt;I ran every command below on Git 2.56.0. A bare repository played the remote, and a second clone played a teammate who keeps pushing to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Difference Between Git Pull vs Fetch?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git fetch&lt;/code&gt; updates your remote-tracking branches, such as &lt;code&gt;origin/main&lt;/code&gt;, and records what it got in &lt;code&gt;.git/FETCH_HEAD&lt;/code&gt;. It never touches your local branches or your working tree. &lt;code&gt;git pull&lt;/code&gt; runs &lt;code&gt;git fetch&lt;/code&gt; first, then integrates the fetched branch into your current branch. So fetch is safe to run at any time, and pull can change your files.&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;&lt;code&gt;git fetch&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;git pull&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Updates &lt;code&gt;origin/main&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moves your local branch&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, by merge or rebase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changes files in your working tree&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can stop on a conflict&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe with uncommitted changes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Only if they don't overlap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://git-scm.com/docs/git-fetch" rel="noopener noreferrer"&gt;git fetch docs&lt;/a&gt; describe the first half. The &lt;a href="https://git-scm.com/docs/git-pull" rel="noopener noreferrer"&gt;git pull docs&lt;/a&gt; describe the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Git Fetch Change in Your Repository?
&lt;/h2&gt;

&lt;p&gt;Fetch changes your remote-tracking refs and nothing else. In my test the teammate pushed one commit. Before the fetch, &lt;code&gt;git status&lt;/code&gt; saw nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status &lt;span class="nt"&gt;-sb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## main...origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I fetched:&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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;From /tmp/gpf/remote
   144625a..22c2c9f  main       -&amp;gt; origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Git knows the remote moved ahead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## main...origin/main [behind 1]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My &lt;code&gt;app.txt&lt;/code&gt; still said &lt;code&gt;v1&lt;/code&gt;. The new commit sat on &lt;code&gt;origin/main&lt;/code&gt;, where I could read it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; main..origin/main
git diff main origin/main &lt;span class="nt"&gt;--stat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;22c2c9f teammate edits app
 app.txt | 1 +
 1 file changed, 1 insertion(+)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.git/FETCH_HEAD&lt;/code&gt; held one line, the commit hash plus &lt;code&gt;branch 'main' of /tmp/gpf/remote&lt;/code&gt;. Scripts use that file, and you rarely need to open it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Git Pull Do Beyond a Fetch?
&lt;/h2&gt;

&lt;p&gt;Pull does the fetch, then moves your branch. From the same starting point, &lt;code&gt;git pull&lt;/code&gt; printed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Updating 144625a..22c2c9f
Fast-forward
 app.txt | 1 +
 1 file changed, 1 insertion(+)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the same result as running &lt;code&gt;git fetch&lt;/code&gt; and then &lt;code&gt;git merge origin/main&lt;/code&gt;. I checked: the two-step version printed the identical &lt;code&gt;Fast-forward&lt;/code&gt; output. Pull knows which branch to merge because of the upstream settings Git stored when I first pushed with &lt;code&gt;-u&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;branch.main.remote origin
branch.main.merge refs/heads/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fast-forward is the easy case. The trouble starts when both sides have new commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Pull vs Fetch When Your Branches Have Diverged
&lt;/h2&gt;

&lt;p&gt;When your branch and &lt;code&gt;origin/main&lt;/code&gt; both have commits the other lacks, a bare &lt;code&gt;git pull&lt;/code&gt; stops with an error until you say how to combine them. I added &lt;code&gt;mine.txt&lt;/code&gt; locally while the teammate pushed another commit. After a fetch, status showed the split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## main...origin/main [ahead 1, behind 1]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* 8e9826c me adds mine.txt
| * 0ed7d96 teammate adds line 2
|/
* 22c2c9f teammate edits app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no &lt;code&gt;pull.*&lt;/code&gt; config set, &lt;code&gt;git pull&lt;/code&gt; refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hint: You have divergent branches and need to specify how to reconcile them.
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
fatal: Need to specify how to reconcile divergent branches.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command exited with code 128 and changed nothing. You have three ways forward, and each leaves different history:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;History after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git pull --ff-only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Refuses unless a fast-forward is possible&lt;/td&gt;
&lt;td&gt;Unchanged. It failed with &lt;code&gt;Not possible to fast-forward, aborting.&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git pull --rebase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replays your commits on top of &lt;code&gt;origin/main&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Linear. My commit became &lt;code&gt;1b5ae05&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git pull --no-rebase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Merges &lt;code&gt;origin/main&lt;/code&gt; into your branch&lt;/td&gt;
&lt;td&gt;A merge commit, &lt;code&gt;Merge branch 'main' of ...&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rebase gave me a straight line of four commits. The merge version added a fifth commit with two parents. Neither is wrong. Rebase keeps &lt;code&gt;git log&lt;/code&gt; readable, and merge never rewrites a commit you already made. If you want the longer comparison, the &lt;a href="https://devtoolhub.com/git-branching-merging-strategies/" rel="noopener noreferrer"&gt;branching and merging strategies guide&lt;/a&gt; covers when each fits, and the &lt;a href="https://devtoolhub.com/advanced-git-rebase-stash-cherry-pick/" rel="noopener noreferrer"&gt;rebase, stash and cherry-pick guide&lt;/a&gt; covers the rebase details.&lt;/p&gt;

&lt;p&gt;Did the pull go the wrong way? Git records where your branch was before an integration in &lt;code&gt;ORIG_HEAD&lt;/code&gt;. After my &lt;code&gt;--rebase&lt;/code&gt; pull, I ran this to go back:&lt;/p&gt;

&lt;p&gt;⚠️ Note: &lt;code&gt;git reset --hard&lt;/code&gt; throws away every uncommitted change to tracked files in the whole repository. Commit or stash first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; ORIG_HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My branch returned to &lt;code&gt;8e9826c&lt;/code&gt;, the commit from before the rebase. The &lt;a href="https://devtoolhub.com/git-reset-vs-revert-vs-restore/" rel="noopener noreferrer"&gt;reset, revert and restore guide&lt;/a&gt; explains the three reset modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Git Pull Fail With Uncommitted Changes?
&lt;/h2&gt;

&lt;p&gt;Pull fails when your uncommitted edits touch a file the incoming commits also change. Git aborts before it changes anything. I edited &lt;code&gt;app.txt&lt;/code&gt; without committing while the teammate pushed a new line to the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: Your local changes to the following files would be overwritten by merge:
    app.txt
Please commit your changes or stash them before you merge.
Aborting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fetch in the same state worked without complaint. It printed &lt;code&gt;0ed7d96..b67314f  main       -&amp;gt; origin/main&lt;/code&gt;, and &lt;code&gt;git status&lt;/code&gt; still showed my &lt;code&gt;M app.txt&lt;/code&gt; edit untouched. That is the practical reason to fetch first: you see the incoming change, then decide whether to commit, stash, or wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Remove Stale Remote Branches With Fetch?
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;git fetch&lt;/code&gt; adds new branches but never deletes old remote-tracking refs. Run &lt;code&gt;git fetch --prune&lt;/code&gt; to remove the ones whose branch is gone from the remote. The teammate pushed &lt;code&gt;feature/login&lt;/code&gt;, then deleted it from the remote. My next plain fetch printed nothing, and &lt;code&gt;git branch -r&lt;/code&gt; still listed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  origin/HEAD -&amp;gt; origin/main
  origin/feature/login
  origin/main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--prune&lt;/code&gt;, the stale ref went away:&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 &lt;span class="nt"&gt;--prune&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; - [deleted]         (none)     -&amp;gt; origin/feature/login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only removes remote-tracking refs, never your local branches. Per the docs, tags are left alone unless you add &lt;code&gt;--prune-tags&lt;/code&gt;. To make it the default for every fetch and pull, run &lt;code&gt;git config --global fetch.prune true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Pull vs Fetch: Which Should You Use?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;git fetch&lt;/code&gt; when you want to see what changed before you integrate it, and &lt;code&gt;git pull&lt;/code&gt; when your branch is simply behind and you trust the incoming commits. If you pull on a shared, busy branch, expect the divergence prompt sooner or later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fetch first&lt;/strong&gt; when you have uncommitted work, when the branch is shared and busy, or when a script only needs to know whether you're behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull&lt;/strong&gt; when you are a plain fast-forward behind. My first pull above was exactly that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a two-step habit&lt;/strong&gt; when you want control: &lt;code&gt;git fetch&lt;/code&gt;, then &lt;code&gt;git status -sb&lt;/code&gt;, then &lt;code&gt;git merge --ff-only origin/main&lt;/code&gt; or &lt;code&gt;git rebase origin/main&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Settle the divergent-branch prompt once instead of per command. For linear history, set &lt;code&gt;git config --global pull.rebase true&lt;/code&gt;. To refuse anything except a fast-forward, set &lt;code&gt;git config --global pull.ff only&lt;/code&gt;. Those two keys come from Git's own hint text above. I tested the matching command-line flags, not the config keys themselves.&lt;/p&gt;

&lt;p&gt;Nothing in the &lt;a href="https://git-scm.com/docs/BreakingChanges" rel="noopener noreferrer"&gt;Git 3.0 breaking changes list&lt;/a&gt; touches pull or fetch, so nothing planned for Git 3.0 changes how these two commands behave. For the bigger picture of how these commands fit together, start with the &lt;a href="https://devtoolhub.com/git-fundamentals/" rel="noopener noreferrer"&gt;Git fundamentals guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is git pull the same as git fetch plus git merge?&lt;/strong&gt;&lt;br&gt;
A: Yes, by default. &lt;code&gt;git pull&lt;/code&gt; runs &lt;code&gt;git fetch&lt;/code&gt;, then merges the upstream branch into your current branch. In my test, &lt;code&gt;git fetch&lt;/code&gt; followed by &lt;code&gt;git merge origin/main&lt;/code&gt; printed the same &lt;code&gt;Fast-forward&lt;/code&gt; output as a single pull. With &lt;code&gt;--rebase&lt;/code&gt; it rebases instead of merging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does git fetch change my working directory?&lt;/strong&gt;&lt;br&gt;
A: No. It updates remote-tracking branches like &lt;code&gt;origin/main&lt;/code&gt; and &lt;code&gt;.git/FETCH_HEAD&lt;/code&gt; only. My edited &lt;code&gt;app.txt&lt;/code&gt; was untouched after a fetch, and &lt;code&gt;git status&lt;/code&gt; still showed it as modified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does git pull say "Need to specify how to reconcile divergent branches"?&lt;/strong&gt;&lt;br&gt;
A: Your branch and the remote both have new commits, and no &lt;code&gt;pull.rebase&lt;/code&gt; or &lt;code&gt;pull.ff&lt;/code&gt; setting tells Git what to do. Pass &lt;code&gt;--rebase&lt;/code&gt;, &lt;code&gt;--no-rebase&lt;/code&gt; or &lt;code&gt;--ff-only&lt;/code&gt;, or set &lt;code&gt;pull.rebase&lt;/code&gt; or &lt;code&gt;pull.ff&lt;/code&gt; once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I use git fetch or git pull?&lt;/strong&gt;&lt;br&gt;
A: Fetch when you want to inspect incoming commits first or have uncommitted work. Pull when you're just behind and want to catch up. Many people fetch, check &lt;code&gt;git status -sb&lt;/code&gt;, then merge or rebase by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I delete remote-tracking branches that no longer exist?&lt;/strong&gt;&lt;br&gt;
A: Run &lt;code&gt;git fetch --prune&lt;/code&gt;. It removes stale &lt;code&gt;origin/*&lt;/code&gt; refs and leaves local branches alone. Set &lt;code&gt;git config --global fetch.prune true&lt;/code&gt; to do it on every fetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git fetch&lt;/code&gt; updates &lt;code&gt;origin/main&lt;/code&gt; and &lt;code&gt;.git/FETCH_HEAD&lt;/code&gt;, and never touches your branch or files&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git pull&lt;/code&gt; is a fetch plus a merge or rebase, so it can change files and stop on conflicts&lt;/li&gt;
&lt;li&gt;A bare pull on diverged branches fails with exit 128 until you pass &lt;code&gt;--rebase&lt;/code&gt;, &lt;code&gt;--no-rebase&lt;/code&gt; or &lt;code&gt;--ff-only&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pull aborts if uncommitted edits overlap incoming changes, while fetch works fine&lt;/li&gt;
&lt;li&gt;Plain fetch keeps stale &lt;code&gt;origin/*&lt;/code&gt; refs, and &lt;code&gt;--prune&lt;/code&gt; removes them&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt; brought my branch back to its pre-rebase commit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time the git pull vs fetch question comes up, run &lt;code&gt;git fetch&lt;/code&gt; and &lt;code&gt;git status -sb&lt;/code&gt; first. If the status says &lt;code&gt;behind&lt;/code&gt;, pull is a safe fast-forward. If it says &lt;code&gt;ahead&lt;/code&gt; and &lt;code&gt;behind&lt;/code&gt;, pick a strategy before you pull.&lt;/p&gt;

</description>
      <category>git</category>
      <category>versioncontrol</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Azure Managed Identity vs Service Principal: Which to Use</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Thu, 05 Nov 2026 12:57:10 +0000</pubDate>
      <link>https://dev.to/amareswer/azure-managed-identity-vs-service-principal-which-to-use-1oph</link>
      <guid>https://dev.to/amareswer/azure-managed-identity-vs-service-principal-which-to-use-1oph</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/azure-managed-identity-vs-service-principal/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most Azure managed identity vs service principal debates start from a false split. A managed identity is a service principal. Microsoft's docs say Azure creates a &lt;a href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview" rel="noopener noreferrer"&gt;service principal of a special type&lt;/a&gt; in Entra ID for every managed identity. The real question is who looks after the credential: Azure or you. Below you get the rule for choosing, the commands for both, and the gotchas that appear after the first deploy.&lt;/p&gt;

&lt;p&gt;I built this from Microsoft Learn and the Azure CLI reference as of November 3, 2026. I haven't run every command against a live tenant, so test them in a sandbox subscription first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Azure Managed Identity vs Service Principal: What Is the Difference?
&lt;/h2&gt;

&lt;p&gt;A service principal is the identity an application uses to sign in to Entra ID and receive Azure permissions. A managed identity is a service principal whose credential Azure creates, stores and rotates for you. You can never read it. With a regular service principal, you create a secret or certificate yourself. You store it and replace it before it expires. That credential handling is the whole difference.&lt;/p&gt;

&lt;p&gt;There are two flavors of managed identity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;System-assigned&lt;/strong&gt; identities live on a single Azure resource, such as a VM or an App Service. Delete the resource and Azure deletes the identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-assigned&lt;/strong&gt; identities are standalone resources. You create them, attach them to one or more resources, and delete them yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Managed identities cost nothing extra, according to the same overview page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Azure Managed Identity vs Service Principal: Side by Side
&lt;/h2&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;Managed identity&lt;/th&gt;
&lt;th&gt;Service principal (app registration)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credential&lt;/td&gt;
&lt;td&gt;None you can see or leak&lt;/td&gt;
&lt;td&gt;Client secret, certificate or federated credential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where code can run&lt;/td&gt;
&lt;td&gt;Azure compute that supports managed identities&lt;/td&gt;
&lt;td&gt;Anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiry risk&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Secrets last 24 months at most&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;System-assigned: tied to the resource. User-assigned: independent&lt;/td&gt;
&lt;td&gt;Until you delete the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharing&lt;/td&gt;
&lt;td&gt;User-assigned can attach to many resources&lt;/td&gt;
&lt;td&gt;Anything that holds the credential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Workloads running on Azure&lt;/td&gt;
&lt;td&gt;CI/CD, on-premises, other clouds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When Should You Use a Managed Identity?
&lt;/h2&gt;

&lt;p&gt;Use a managed identity whenever your code runs on Azure compute that supports one. It removes the secret. Nothing sits in an environment variable waiting to leak, and nothing expires on a Friday night. Microsoft's passwordless guidance recommends managed identities plus Azure RBAC as the default path for Azure-to-Azure connections.&lt;/p&gt;

&lt;p&gt;In code, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; from the Azure Identity libraries handles both environments. Locally it can use the account you signed in with through the Azure CLI. On Azure it picks up the managed identity of the host. Your code doesn't change between the two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should You Pick System-Assigned or User-Assigned?
&lt;/h3&gt;

&lt;p&gt;Microsoft's own best-practice page says user-assigned identities fit a broader range of scenarios. Pick user-assigned when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resources come and go quickly.&lt;/strong&gt; Many VMs with system-assigned identities can hit the Entra object-creation rate limit and return HTTP 429.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Several resources need the same access.&lt;/strong&gt; In Microsoft's example, four VMs sharing one user-assigned identity need two role assignments. Four system-assigned identities need eight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access must exist before the resource does.&lt;/strong&gt; A system-assigned identity appears with its resource, so you can't grant it a role in advance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick system-assigned when permissions should disappear with the resource. It also fits audit logs that must name the specific resource, not just the identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Do You Still Need a Service Principal?
&lt;/h2&gt;

&lt;p&gt;You need a service principal when your workload runs somewhere a managed identity can't attach: GitHub Actions runners, on-premises servers, or another cloud. Even then, avoid a stored secret. Microsoft says client secrets are less secure than certificate or federated credentials and should not be used in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation" rel="noopener noreferrer"&gt;Workload identity federation&lt;/a&gt; is the secretless route. Your app registration or user-assigned identity trusts tokens from an external provider such as GitHub. The workload then swaps that token for an Entra access token. Two details worth knowing: system-assigned identities can't hold federated credentials, and an app or user-assigned identity can have at most 20 of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Set Up Each One?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A managed identity for an App Service
&lt;/h3&gt;

&lt;p&gt;⚠️ Note: leave &lt;code&gt;--scope&lt;/code&gt; off &lt;code&gt;az webapp identity assign&lt;/code&gt;. The CLI reference lists &lt;code&gt;Contributor&lt;/code&gt; as the default for &lt;code&gt;--role&lt;/code&gt;. Grant the narrow role yourself in the next step.&lt;/p&gt;

&lt;p&gt;This creates one user-assigned identity and attaches it to one web app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az identity create &lt;span class="nt"&gt;--name&lt;/span&gt; app-uami &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-app &lt;span class="nt"&gt;--location&lt;/span&gt; centralus
az webapp identity assign &lt;span class="nt"&gt;-g&lt;/span&gt; rg-app &lt;span class="nt"&gt;-n&lt;/span&gt; my-web-app &lt;span class="nt"&gt;--identities&lt;/span&gt; app-uami
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, grant it read access to one storage account. Passing &lt;code&gt;--assignee-object-id&lt;/code&gt; skips the Microsoft Graph lookup. The reference says pairing it with &lt;code&gt;--assignee-principal-type&lt;/code&gt; avoids propagation-latency errors.&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;PRINCIPAL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az identity show &lt;span class="nt"&gt;--name&lt;/span&gt; app-uami &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-app &lt;span class="nt"&gt;--query&lt;/span&gt; principalId &lt;span class="nt"&gt;--output&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-object-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRINCIPAL_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assignee-principal-type&lt;/span&gt; ServicePrincipal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Storage Blob Data Reader"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scope&lt;/span&gt; /subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/rg-app/providers/Microsoft.Storage/storageAccounts/&amp;lt;account&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A service principal for GitHub Actions, no secret
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;az ad sp create-for-rbac&lt;/code&gt; creates a password credential by default. Turn that off and scope the role to one resource group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az ad sp create-for-rbac &lt;span class="nt"&gt;--name&lt;/span&gt; gh-deploy &lt;span class="nt"&gt;--create-password&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; Reader &lt;span class="nt"&gt;--scopes&lt;/span&gt; /subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/rg-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add a federated credential on the app registration under &lt;strong&gt;Certificates &amp;amp; secrets&lt;/strong&gt;. The issuer is &lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt; and the audience is &lt;code&gt;api://AzureADTokenExchange&lt;/code&gt;. In the workflow, grant &lt;code&gt;id-token: write&lt;/code&gt; and log in with the official action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@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;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CLIENT_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;tenant-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_TENANT_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;subscription-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_SUBSCRIPTION_ID }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our &lt;a href="https://devtoolhub.com/deploying-infrastructure-to-azure-with-terraform-and-github-actions/" rel="noopener noreferrer"&gt;Terraform and GitHub Actions deployment guide&lt;/a&gt; shows this login in a full pipeline. The &lt;a href="https://devtoolhub.com/azure-service-principal-guide/" rel="noopener noreferrer"&gt;service principal setup guide&lt;/a&gt; covers the portal steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Breaks in an Azure Managed Identity vs Service Principal Setup?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Role changes take hours to reach a managed identity.&lt;/strong&gt; Azure caches managed identity tokens per resource URI for around 24 hours. You can't force a refresh. If the app still gets a 403 after a role or group change, the old token is the likely cause. Microsoft's fix is to put permissions directly on a user-assigned identity instead of going through an Entra group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting an identity doesn't delete its role assignments.&lt;/strong&gt; They linger as "Identity not found". They also count against the 5,000 role assignments per subscription. Microsoft's cleanup command removes assignments whose &lt;code&gt;ObjectType&lt;/code&gt; is &lt;code&gt;Unknown&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get-AzRoleAssignment | Where-Object {$_.ObjectType -eq "Unknown"} | Remove-AzRoleAssignment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ Note: that command runs against your current subscription and deletes every matching assignment. Run the &lt;code&gt;Get-AzRoleAssignment&lt;/code&gt; half on its own first and read the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A wrong federated-credential subject fails silently.&lt;/strong&gt; Entra accepts a bad &lt;code&gt;subject&lt;/code&gt; when you create the credential. The failure shows up at token exchange. Matching is case-sensitive, wildcards aren't supported, and the issuer, subject and audience must match the token exactly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub changed the default subject format.&lt;/strong&gt; For repositories created after July 15, 2026, &lt;a href="https://docs.github.com/en/actions/reference/security/oidc" rel="noopener noreferrer"&gt;GitHub's OIDC reference&lt;/a&gt; says the default &lt;code&gt;sub&lt;/code&gt; claim includes owner and repository IDs. One example is &lt;code&gt;repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main&lt;/code&gt;. Older repositories keep the name-based format unless you opt in. A credential written in the old format won't match a new repository's token. Copy the subject from the docs, not from an older workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone who can run code on the resource can use its identity.&lt;/strong&gt; Microsoft's example: a user can run code in a Logic App but has no storage access. That user can still read the storage account through the app's managed identity. Grant code-execution roles sparingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel creation can collide.&lt;/strong&gt; In ARM templates, create federated credentials under the same parent identity one at a time. Parallel creation can fail with a 409 conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Azure Managed Identity vs Service Principal: Which Should You Choose?
&lt;/h2&gt;

&lt;p&gt;Work down this list and stop at the first match:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Code runs on Azure compute that supports managed identities.&lt;/strong&gt; Use a managed identity. Pick user-assigned if resources share access or churn often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code runs in GitHub Actions, Kubernetes elsewhere, or another cloud.&lt;/strong&gt; Use a federated credential on an app registration or a user-assigned identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code runs on your laptop.&lt;/strong&gt; Sign in with &lt;code&gt;az login&lt;/code&gt; and let &lt;code&gt;DefaultAzureCredential&lt;/code&gt; use that. Don't create a service principal just for local work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of the above works.&lt;/strong&gt; Use a service principal with a certificate. Keep expiry under 12 months and set a reminder to rotate it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For secrets that still need a vault, see our comparison of &lt;a href="https://devtoolhub.com/aws-secrets-manager-vs-azure-key-vault-vs-gcp-secret-manager/" rel="noopener noreferrer"&gt;AWS, Azure and GCP secret managers&lt;/a&gt;. For the wider picture, start with the &lt;a href="https://devtoolhub.com/azure-security-best-practices-with-examples/" rel="noopener noreferrer"&gt;Azure security best practices&lt;/a&gt; guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is a managed identity the same as a service principal?&lt;/strong&gt;&lt;br&gt;
A: Almost. Every managed identity has a service principal of a special type behind it in Entra ID. The difference is the credential: Azure manages it for a managed identity, while you manage it for a regular service principal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can a managed identity be used outside Azure?&lt;/strong&gt;&lt;br&gt;
A: Not directly. A managed identity serves workloads running on Azure resources. For GitHub Actions or other clouds, federate an app registration or a user-assigned identity with the external provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How long does a client secret last?&lt;/strong&gt;&lt;br&gt;
A: At most 24 months, and Microsoft recommends under 12. &lt;code&gt;az ad sp create-for-rbac&lt;/code&gt; defaults to one year through its &lt;code&gt;--years&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does my managed identity still get a 403 after I added a role?&lt;/strong&gt;&lt;br&gt;
A: The role may not have propagated yet, or the cached token predates it. Azure caches tokens for roughly 24 hours and can't refresh them early. Restarting the app doesn't clear that cache, so the practical fix is to wait. To avoid the delay next time, grant roles directly to a user-assigned identity instead of through a group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I use system-assigned or user-assigned identities?&lt;/strong&gt;&lt;br&gt;
A: Use user-assigned when resources are short-lived or share access. Use system-assigned when permissions should end with the resource, or per-resource audit logs matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A managed identity is a service principal whose credential Azure manages and rotates for you&lt;/li&gt;
&lt;li&gt;Use a managed identity on Azure compute, and federation for GitHub Actions and other clouds&lt;/li&gt;
&lt;li&gt;Client secrets cap at 24 months and Microsoft says not to use them in production&lt;/li&gt;
&lt;li&gt;Managed identity tokens are cached about 24 hours, so role changes can take hours to apply&lt;/li&gt;
&lt;li&gt;Deleted identities leave role assignments behind, and the limit is 5,000 per subscription&lt;/li&gt;
&lt;li&gt;New GitHub repositories use a different OIDC &lt;code&gt;sub&lt;/code&gt; format, so check your federated credential's subject&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever way the Azure managed identity vs service principal choice goes for you, start small. Pick one workload that still uses a client secret and move it to a managed identity or a federated credential. Then delete the secret.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>devops</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>GitHub Actions Lockfile Guide: How actions.lock Works</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:04:40 +0000</pubDate>
      <link>https://dev.to/amareswer/github-actions-lockfile-guide-how-actionslock-works-gnc</link>
      <guid>https://dev.to/amareswer/github-actions-lockfile-guide-how-actionslock-works-gnc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/github-actions-workflow-lockfiles-guide/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;GitHub Actions workflow lockfiles went into &lt;a href="https://github.blog/changelog/2026-10-14-github-actions-workflow-lockfiles-in-public-preview/" rel="noopener noreferrer"&gt;public preview&lt;/a&gt; on October 14, 2026 and became &lt;a href="https://github.blog/changelog/2026-10-28-github-actions-workflow-lockfiles-are-generally-available" rel="noopener noreferrer"&gt;generally available&lt;/a&gt; on October 28. The timing is almost funny: GitHub automated the exact workaround &lt;a href="https://devtoolhub.com/github-actions-security-best-practices/" rel="noopener noreferrer"&gt;this blog told you to do by hand&lt;/a&gt;. A GitHub Actions lockfile pins every action in a workflow, plus its transitive dependencies, to a commit you've actually reviewed, so a tag moving after merge can't quietly swap in different code. Here's what &lt;code&gt;.github/workflows/actions.lock&lt;/code&gt; locks, how to roll it out without breaking CI, and the limits GitHub documents but the announcements skip.&lt;/p&gt;

&lt;p&gt;I haven't run the extension against a real repository for this guide. Everything below comes from GitHub's changelog, GitHub Docs and the &lt;code&gt;gh-actions-lock&lt;/code&gt; README as of November 3, 2026, when the extension was at v0.4.1. The extension is young and its flags have already changed between releases, so treat &lt;code&gt;gh actions-lock --help&lt;/code&gt; as the final word.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Tags and Branches Move After You Review Them
&lt;/h2&gt;

&lt;p&gt;When you review a pull request that adds &lt;code&gt;uses: actions/checkout@v4&lt;/code&gt;, you're reviewing whatever commit &lt;code&gt;v4&lt;/code&gt; points to at that moment. Tags are mutable refs. A maintainer, or someone who compromises a maintainer's account, can repoint &lt;code&gt;v4&lt;/code&gt; tomorrow, and your workflow picks up the new code on its next run. No new pull request, no re-review.&lt;/p&gt;

&lt;p&gt;That's not hypothetical. Several real GitHub Actions supply-chain incidents followed exactly this pattern: compromise the account behind a popular action, repoint the tag, then wait for CI everywhere to pull the new commit. Manual SHA pinning closes this gap one action at a time, but doing it by hand across dozens of workflows and their transitive dependencies doesn't scale, and nobody relocks a hand-pinned ref once it goes stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a GitHub Actions Lockfile Actually Pins
&lt;/h2&gt;

&lt;p&gt;A workflow lockfile lives at &lt;code&gt;.github/workflows/actions.lock&lt;/code&gt;. It's one file per repository, with entries for every workflow you've onboarded, and it's machine-generated: GitHub says never to edit it by hand. It pins a commit SHA to every action your workflows depend on, including transitive dependencies, meaning the actions your actions call internally.&lt;/p&gt;

&lt;p&gt;Each locked dependency records four things: the &lt;code&gt;ref&lt;/code&gt; it was resolved from (a tag or branch), the exact &lt;code&gt;commit&lt;/code&gt; that ref pointed to, and the numeric &lt;code&gt;owner_id&lt;/code&gt; and &lt;code&gt;repo_id&lt;/code&gt; of the source repository. At run time, GitHub Actions uses those two IDs to confirm that the repository behind the name is still the one you locked.&lt;/p&gt;

&lt;p&gt;Once a workflow is onboarded, every action it uses must be in the lockfile, and the locked commit is what runs. That commit also has to be reachable from the recorded branch or tag in the action's own repository. A commit that exists only in a fork, or that's no longer reachable, is rejected when the workflow runs.&lt;/p&gt;

&lt;p&gt;One boundary catches people out: &lt;strong&gt;a lockfile doesn't pin reusable workflow calls.&lt;/strong&gt; GitHub's docs are explicit that &lt;code&gt;jobs.&amp;lt;id&amp;gt;.uses&lt;/code&gt; calls aren't covered, and that to pin a reusable workflow you reference it by its full commit SHA. The dependencies of a reusable workflow are covered by the lockfile in the repository where that workflow is defined, not by yours. So if you call shared workflows from another repository, that repository needs its own lockfile.&lt;/p&gt;

&lt;p&gt;Two more details. A lockfile only controls which action code runs. It doesn't control input parameters, secrets or the runner image. And there are two ways to refer to an action in your own repository: &lt;code&gt;uses: ./path&lt;/code&gt; runs whatever is checked out in the runner's workspace, so the extension can't pin it, while &lt;code&gt;uses: $/path&lt;/code&gt; runs your repository's action at the workflow's own commit, so it's pinned and the actions it uses can be locked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing and Generating Your First actions.lock
&lt;/h2&gt;

&lt;p&gt;Install the &lt;a href="https://github.com/github/gh-actions-lock" rel="noopener noreferrer"&gt;gh-actions-lock extension&lt;/a&gt; through the GitHub CLI. GitHub recommends turning on Dependabot for GitHub Actions first, so the lockfile stays in sync from day one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh extension &lt;span class="nb"&gt;install &lt;/span&gt;github/gh-actions-lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the root of your repository, run it with no arguments. It scans every workflow under &lt;code&gt;.github/workflows/&lt;/code&gt;, pins each resolvable action to a commit SHA, and writes the lockfile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gh actions-lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To onboard a single workflow instead of the whole directory, pass its path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gh actions-lock .github/workflows/ci.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check your &lt;code&gt;uses:&lt;/code&gt; lines before you run it. The lockfile pins whatever each ref resolves to right now, so make sure those are the versions you actually want locked.&lt;/p&gt;

&lt;p&gt;Review the whole diff, not just the lockfile. The extension also rewrites your &lt;code&gt;uses:&lt;/code&gt; references to the specific version it resolved, so &lt;code&gt;octo-org/deploy-action@v3&lt;/code&gt; can become &lt;code&gt;octo-org/deploy-action@v3.1.0&lt;/code&gt;. That makes the reference more specific without changing the commit it points to. If you'd rather keep partial versions like &lt;code&gt;v4&lt;/code&gt; as written, pass &lt;code&gt;--no-narrow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local actions need a decision.&lt;/strong&gt; The extension never follows or locks a &lt;code&gt;./&lt;/code&gt; local action. If a local action in your checkout uses other actions, the run reports a local-action error and writes nothing. You have two options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh actions-lock &lt;span class="nt"&gt;--migrate-local-actions&lt;/span&gt;
gh actions-lock &lt;span class="nt"&gt;--ignore-local-actions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--migrate-local-actions&lt;/code&gt; rewrites same-repository &lt;code&gt;./…&lt;/code&gt; references to &lt;code&gt;$/…&lt;/code&gt; so the action runs as a pinned reference. It only happens when you pass the flag, because the two forms behave differently. &lt;code&gt;--ignore-local-actions&lt;/code&gt; keeps the &lt;code&gt;./&lt;/code&gt; path and downgrades the error to a warning, but a local action that uses an unlocked action will then fail at run time, partway through the job. GitHub recommends the migrate option.&lt;/p&gt;

&lt;p&gt;GitHub Enterprise Server isn't supported. The extension works with github.com and GitHub Enterprise Cloud, including data-residency tenants (&lt;code&gt;*.ghe.com&lt;/code&gt;). For a tenant, run &lt;code&gt;gh auth login --hostname TENANT.ghe.com&lt;/code&gt; and run the tool from the tenant repository checkout. Public actions like &lt;code&gt;actions/checkout&lt;/code&gt; are still read from github.com, so in CI on a tenant you'll want a github.com token with no permissions in &lt;code&gt;GH_ACTIONS_LOCK_GITHUB_COM_TOKEN&lt;/code&gt;. Without one, those lookups are anonymous and share GitHub's limit of 60 requests an hour per IP address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling Out a GitHub Actions Lockfile One Workflow at a Time
&lt;/h2&gt;

&lt;p&gt;You don't have to lock every workflow in one pull request. A scoped run touches only the workflow you name, so you can onboard your highest-risk pipeline first: the one that runs on every pull request, or the one holding deploy credentials. Only a full-directory run prunes entries for deleted workflows, so run it without a path now and then to keep the file clean. Anyone with write access to the repository can do the onboarding.&lt;/p&gt;

&lt;p&gt;Expect a real diff the first time, and check one thing before you merge: your allowed-actions policy. After a workflow is onboarded, the allowlist is checked against locked commit SHAs instead of tags. An entry pinned to a tag, such as &lt;code&gt;actions/checkout@v6&lt;/code&gt;, no longer matches. Use a wildcard or the full commit SHA instead, or onboarded workflows will start failing.&lt;/p&gt;

&lt;p&gt;After onboarding, rerun the tool whenever you add a workflow that has &lt;code&gt;uses:&lt;/code&gt; dependencies, or add or remove a &lt;code&gt;uses:&lt;/code&gt; dependency in an existing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Your GitHub Actions Lockfile Current
&lt;/h2&gt;

&lt;p&gt;Locked pins are sticky by design, and what happens when a ref moves depends on the ref:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A branch or partial version&lt;/strong&gt; such as &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;v3&lt;/code&gt; keeps its locked commit, and the extension reports a ref-moved warning. Check the upstream history, then run &lt;code&gt;gh actions-lock --relock&lt;/code&gt; to advance it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A full version tag&lt;/strong&gt; such as &lt;code&gt;v3.1.0&lt;/code&gt; must point at its locked commit. If the tag moved, you get an error until you run &lt;code&gt;--relock&lt;/code&gt; or change the &lt;code&gt;uses:&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewritten history&lt;/strong&gt;, where the locked commit is no longer in the reference's history (a force-push, for example), is an error that &lt;code&gt;--relock&lt;/code&gt; doesn't fix. After you've reviewed the upstream history, run &lt;code&gt;gh actions-lock --accept-moved&lt;/code&gt;. GitHub says not to use &lt;code&gt;--accept-moved&lt;/code&gt; in automation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dependabot handles the routine bumps. When it opens a version update for an action in an onboarded workflow, it regenerates the matching lockfile entry in the same pull request, with no extra configuration, so the workflow file and the lockfile can't drift apart. It only maintains coverage, though. It won't onboard a workflow that has no lockfile entries yet.&lt;/p&gt;

&lt;p&gt;Treat lockfile changes like any other dependency change. A changed commit is new code your workflows will run. A changed &lt;code&gt;owner_id&lt;/code&gt; or &lt;code&gt;repo_id&lt;/code&gt; should be rare and means the repository behind that name isn't the one you originally locked, so investigate before you merge. Adding &lt;code&gt;.github/workflows/actions.lock&lt;/code&gt; to your &lt;a href="https://devtoolhub.com/github-codeowners-permissions-best-practices/" rel="noopener noreferrer"&gt;CODEOWNERS file&lt;/a&gt; makes sure the right team reviews those diffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforcing the Lockfile: CI Checks, Pre-Commit Hooks, and Org Policy
&lt;/h2&gt;

&lt;p&gt;A lockfile only protects you if something checks it. These are the modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Writes&lt;/th&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh actions-lock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Onboarding and fixing the lockfile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh actions-lock --relock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Advancing moved refs to current upstream commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh actions-lock --verify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;CI: the same checks as a fix run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh actions-lock --verify-local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;Pre-commit: every &lt;code&gt;uses:&lt;/code&gt; has a lockfile entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gh actions-lock --no-fix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Read-only report&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In CI, run &lt;code&gt;gh actions-lock --verify&lt;/code&gt; on pull requests and make it a required check. It checks provenance before merge, and the runner checks it again at job start. The repository ships a ready-made &lt;a href="https://github.com/github/gh-actions-lock/blob/main/.github/workflows/verify-lockfile.yml" rel="noopener noreferrer"&gt;verify-lockfile.yml&lt;/a&gt; you can copy and adapt.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--verify-local&lt;/code&gt; is the fast offline gate for a pre-commit hook. It makes no network calls and needs no authentication, but it only confirms every action reference has a lockfile entry, not that the entry is trustworthy. That's the right trade-off locally, and the wrong one as your only check.&lt;/p&gt;

&lt;p&gt;For several repositories, the &lt;strong&gt;Require lockfile&lt;/strong&gt; policy lets enterprise, organization and repository admins mandate lockfiles instead of relying on every team to wire up that CI step. GitHub's rollout advice is worth following: pilot it first. Evaluate mode, which is only available on GitHub Enterprise Cloud, shows you what would be blocked before anything breaks. The policy blocks runs of targeted workflows that aren't onboarded, including workflows with no &lt;code&gt;uses:&lt;/code&gt; dependencies. It also blocks a calling workflow when a reusable workflow it calls isn't onboarded in its own repository. It's a separate policy from "Require actions to be pinned to a full-length commit SHA", though an onboarded workflow satisfies both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a GitHub Actions Lockfile Does Not Do
&lt;/h2&gt;

&lt;p&gt;Locking a dependency and vetting it are different jobs, and GitHub says so directly: "Locking does not determine whether the dependency itself is safe, so review dependencies before locking them." A lockfile guarantees the code that ran during your review is the code that runs on every later job. It says nothing about whether that code was safe to run in the first place.&lt;/p&gt;

&lt;p&gt;The README also lists limits the announcement doesn't mention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reusable workflows called from another repository aren't locked.&lt;/li&gt;
&lt;li&gt;Container images aren't locked, and any image without an &lt;code&gt;@sha256:&lt;/code&gt; digest produces a warning.&lt;/li&gt;
&lt;li&gt;A lockfile change that moves a floating ref (&lt;code&gt;v4&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;) back to an older commit in its history isn't detected, so review lockfile diffs or pin exact tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it doesn't replace restricting &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; permissions or requiring review on workflow changes. It closes one gap, tag drift after review, not the whole supply chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lockfiles vs Manual SHA Pinning: What Changes, What Doesn't
&lt;/h2&gt;

&lt;p&gt;If you've been pinning actions to a SHA by hand, the practice &lt;a href="https://devtoolhub.com/github-actions-security-best-practices/" rel="noopener noreferrer"&gt;our GitHub Actions security guide&lt;/a&gt; recommends, that wasn't wrong. GitHub has historically recommended it too. But for repositories that adopt a lockfile, GitHub now says SHA pinning inside workflow files is no longer necessary. The lockfile is the better option because it covers transitive dependencies, keeps your &lt;a href="https://devtoolhub.com/github-actions-workflow-yaml-guide/" rel="noopener noreferrer"&gt;workflow YAML&lt;/a&gt; easier to read, and records the identity of the source repository. An onboarded workflow also satisfies the "Require actions to be pinned to a full-length commit SHA" policy.&lt;/p&gt;

&lt;p&gt;Two exceptions keep manual pinning alive. Calls to reusable workflows still need a full commit SHA by hand, because the lockfile doesn't cover them. And workflows you haven't onboarded yet still rely on whatever pinning you've already got.&lt;/p&gt;

&lt;p&gt;For teams starting from zero, skip straight to the lockfile. For teams with hand-pinned SHAs everywhere, you can keep them while you migrate, and drop them once each workflow is onboarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is the GitHub Actions lockfile still in preview?&lt;/strong&gt;&lt;br&gt;
A: No. It was in public preview from October 14, 2026 and became generally available on October 28, 2026, on GitHub Enterprise Cloud (including data-residency tenants), Team and Free plans. GitHub Enterprise Server isn't supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does a GitHub Actions lockfile replace manually pinning actions to a SHA?&lt;/strong&gt;&lt;br&gt;
A: For onboarded workflows, mostly yes. GitHub says SHA pinning inside workflow files is no longer necessary once a repository adopts a lockfile. The exception is reusable workflow calls (&lt;code&gt;jobs.&amp;lt;id&amp;gt;.uses&lt;/code&gt;), which the lockfile doesn't pin, so those still need a full commit SHA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does the lockfile cover reusable workflows?&lt;/strong&gt;&lt;br&gt;
A: No. A reusable workflow's dependencies are covered by the lockfile of the repository where it's defined, not the caller's. If you call shared workflows from another repository, that repository needs its own lockfile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does Dependabot work with actions.lock?&lt;/strong&gt;&lt;br&gt;
A: Yes, for workflows you've already onboarded. When Dependabot bumps an action there, it regenerates the matching lockfile entry in the same pull request. It won't onboard a workflow that has no entries yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What happens if an upstream action legitimately releases a new version?&lt;/strong&gt;&lt;br&gt;
A: Branch and partial-version refs keep their locked commit and give a warning; run &lt;code&gt;gh actions-lock --relock&lt;/code&gt; after checking the upstream history. If a locked commit is no longer in the ref's history, only &lt;code&gt;--accept-moved&lt;/code&gt; clears it, and GitHub says never to use that flag in automation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions workflow lockfiles went generally available October 28, 2026 (public preview from October 14) on Enterprise Cloud, Team and Free plans&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.github/workflows/actions.lock&lt;/code&gt; pins every action and its transitive dependencies to a reviewed commit, but not reusable workflow calls (&lt;code&gt;jobs.&amp;lt;id&amp;gt;.uses&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Install with &lt;code&gt;gh extension install github/gh-actions-lock&lt;/code&gt;, then run &lt;code&gt;gh actions-lock&lt;/code&gt;, and review the full diff: it also rewrites &lt;code&gt;uses:&lt;/code&gt; refs to specific versions&lt;/li&gt;
&lt;li&gt;Local &lt;code&gt;./&lt;/code&gt; actions are a blocking error until you pass &lt;code&gt;--migrate-local-actions&lt;/code&gt; or &lt;code&gt;--ignore-local-actions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;After onboarding, allowed-actions policies check locked SHAs, so tag-based allowlist entries stop matching&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;--verify&lt;/code&gt; in CI and &lt;code&gt;--verify-local&lt;/code&gt; as an offline pre-commit gate; &lt;code&gt;--relock&lt;/code&gt; advances moved refs, and &lt;code&gt;--accept-moved&lt;/code&gt; is never for automation&lt;/li&gt;
&lt;li&gt;Container images and floating-ref rollbacks aren't covered, and locking isn't vetting: review dependencies before you lock them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already pin actions by hand, a GitHub Actions lockfile is the next step. Onboard your riskiest workflow first, fix the allowlist, add &lt;code&gt;--verify&lt;/code&gt; to CI, and keep calling reusable workflows by full SHA.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>devsecops</category>
      <category>cicd</category>
      <category>security</category>
    </item>
    <item>
      <title>Ollama Docker Setup: GPU, Volumes and Compose</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Tue, 03 Nov 2026 13:37:41 +0000</pubDate>
      <link>https://dev.to/amareswer/ollama-docker-setup-gpu-volumes-and-compose-1lca</link>
      <guid>https://dev.to/amareswer/ollama-docker-setup-gpu-volumes-and-compose-1lca</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ollama&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Setup:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;GPU,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Volumes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Compose"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ollama&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;setup&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tested&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;29.4.3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Ollama&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0.40.13:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;persistent&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volumes,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;GPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;flags&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NVIDIA&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AMD,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Compose&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;gotchas."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama, docker, devops, ai&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://devtoolhub.com/ollama-docker-setup-gpu-volumes/&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; Originally published on [DevToolHub](https://devtoolhub.com/ollama-docker-setup-gpu-volumes/).&lt;/span&gt;

Running Ollama in a container takes one &lt;span class="sb"&gt;`docker run`&lt;/span&gt; command. That command also decides two things the docs bury: whether your models survive a rebuild, and whether your GPU gets used. This Ollama Docker setup guide covers both. I ran it on Docker 29.4.3 with Ollama 0.40.13 and hit three problems the one-liner doesn't warn you about.

&lt;span class="gu"&gt;## What Is the Minimum Ollama Docker Setup?&lt;/span&gt;

The minimum Ollama Docker setup is one container with a named volume and port 11434 published. The official &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ollama Docker docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://docs.ollama.com/docker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; give this CPU-only command, and it worked as written:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
First, check that the API answers, then pull and run a model inside the container:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
curl &lt;a href="http://localhost:11434/" rel="noopener noreferrer"&gt;http://localhost:11434/&lt;/a&gt;&lt;br&gt;
docker exec -it ollama ollama run llama3.2&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The first curl returns `Ollama is running`. The server logs show what hardware it found, and on my Mac the line read `inference compute id=cpu`. That log line is your first GPU check. The second check is `docker exec ollama ollama ps`, whose `PROCESSOR` column read `100% CPU` for a loaded model.

Inside the container, Ollama binds to `0.0.0.0:11434` by default. That is why the published port works without setting `OLLAMA_HOST` yourself.

## How Do Volumes Keep Ollama Models Between Containers?

A named volume mounted at `/root/.ollama` keeps your models when you delete and recreate the container. Without it, every `docker rm` throws away every model you pulled, and the next container starts empty.

I tested both cases with a 270 MB model, `smollm2:135m`:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;/p&gt;
&lt;h1&gt;
  
  
  Same volume, new container: the model is still there
&lt;/h1&gt;

&lt;p&gt;docker rm -f ollama&lt;br&gt;
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama&lt;br&gt;
docker exec ollama ollama list      # smollm2:135m listed&lt;/p&gt;
&lt;h1&gt;
  
  
  No volume, new container: nothing
&lt;/h1&gt;

&lt;p&gt;docker rm -f ollama&lt;br&gt;
docker run -d -p 11434:11434 --name ollama ollama/ollama&lt;br&gt;
docker exec ollama ollama list      # empty&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
However, the volume holds more than models. Mounting a bind directory showed four entries in it: `models`, `cache`, `id_ed25519` and `id_ed25519.pub`. Ollama generates that key pair on first start ("Couldn't find '/root/.ollama/id_ed25519'. Generating new private key" in the log). So if you lose the volume, you lose the key too, not just the downloads.

⚠️ **Note:** the volume name changes between `docker run` and Compose. Compose prefixes volume names with the project name, so a volume called `ollama` in `compose.yaml` became `comp_ollama` on my machine. Models you pulled with the plain `docker run` command above won't show up. Set `name: ollama` under the volume to pin it, as in the file further down.

## How Do You Enable GPU in an Ollama Docker Setup?

For NVIDIA, install the NVIDIA Container Toolkit on the host and register it with Docker. Then add `--gpus=all` to the run command. AMD uses the `rocm` image tag with the `/dev/kfd` and `/dev/dri` devices. Docker Desktop on macOS has no GPU passthrough at all.

### NVIDIA Ollama Docker Setup

Ollama's docs walk through the toolkit install for apt and yum, and the [NVIDIA install guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) has the current repository steps. After installing the package, point Docker at it and restart the daemon:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
sudo nvidia-ctk runtime configure --runtime=docker&lt;br&gt;
sudo systemctl restart docker&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Then start the container with every GPU visible:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
On top of that, your card needs compute capability 5.0 or higher and a driver version 550 or newer, according to the [Ollama GPU docs](https://docs.ollama.com/gpu). Cards on compute capability 5.0 through 6.2 need driver 570 or newer.

### AMD Ollama Docker Setup

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
docker run -d --device /dev/kfd --device /dev/dri -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama:rocm&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Plus, the `rocm` tag is the smaller download. Docker Hub lists the `0.40.13` amd64 image at 3.39 GB and `0.40.13-rocm` at 1.45 GB.

### Ollama Docker Setup on macOS

Ollama's FAQ is blunt: GPU acceleration is not available for Docker Desktop on macOS. On my Mac, `--gpus=all` failed with `failed to discover GPU vendor from CDI: no known GPU vendor found`. So if you want Apple GPU speed, run Ollama natively and keep Docker for everything else. [Ollama vs LM Studio](https://devtoolhub.com/ollama-vs-lm-studio/) covers the native install.

### How do you confirm the container is really using the GPU?

Run a prompt, then check `docker exec ollama ollama ps`. A `PROCESSOR` value of `100% GPU` means the model sits entirely in VRAM. `100% CPU` means no GPU was found. A split like `48%/52% CPU/GPU` means the model sits partly in each. Because of that, a healthy status proves nothing about the GPU.

## How Do You Write an Ollama Docker Compose File?

An Ollama Docker Compose file needs four things beyond the image. Those are a named volume, a localhost port binding, a healthcheck, and an optional one-shot service that pulls your model. This is the file I tested, with the GPU block split out into its own file so the base file runs anywhere:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
yaml&lt;/p&gt;
&lt;h1&gt;
  
  
  compose.yaml
&lt;/h1&gt;

&lt;p&gt;services:&lt;br&gt;
  ollama:&lt;br&gt;
    image: ollama/ollama:0.40.13&lt;br&gt;
    container_name: ollama&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    ports:&lt;br&gt;
      - "127.0.0.1:11434:11434"&lt;br&gt;
    environment:&lt;br&gt;
      OLLAMA_KEEP_ALIVE: 30m&lt;br&gt;
      OLLAMA_NUM_PARALLEL: 2&lt;br&gt;
    volumes:&lt;br&gt;
      - ollama:/root/.ollama&lt;br&gt;
    healthcheck:&lt;br&gt;
      test: ["CMD", "ollama", "list"]&lt;br&gt;
      interval: 10s&lt;br&gt;
      timeout: 5s&lt;br&gt;
      retries: 5&lt;br&gt;
      start_period: 10s&lt;/p&gt;

&lt;p&gt;pull-model:&lt;br&gt;
    image: ollama/ollama:0.40.13&lt;br&gt;
    depends_on:&lt;br&gt;
      ollama:&lt;br&gt;
        condition: service_healthy&lt;br&gt;
    environment:&lt;br&gt;
      OLLAMA_HOST: ollama:11434&lt;br&gt;
    entrypoint: ["ollama", "pull", "smollm2:135m"]&lt;br&gt;
    restart: "no"&lt;/p&gt;

&lt;p&gt;volumes:&lt;br&gt;
  ollama:&lt;br&gt;
    name: ollama&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
yaml&lt;/p&gt;
&lt;h1&gt;
  
  
  compose.gpu.yaml  (NVIDIA hosts only)
&lt;/h1&gt;

&lt;p&gt;services:&lt;br&gt;
  ollama:&lt;br&gt;
    deploy:&lt;br&gt;
      resources:&lt;br&gt;
        reservations:&lt;br&gt;
          devices:&lt;br&gt;
            - driver: nvidia&lt;br&gt;
              count: all&lt;br&gt;
              capabilities: [gpu]&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Start it on a CPU host with `docker compose up -d`. On a GPU host, layer the second file on top:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
docker compose -f compose.yaml -f compose.gpu.yaml up -d&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
### Why use `ollama list` as the healthcheck in an Ollama Docker setup?

The image ships without `curl` or `wget`, so the usual `curl -f http://localhost:11434` healthcheck fails with a missing binary. `ollama list` talks to the local server, so it works as a probe. With it, `pull-model` waited for the `Healthy` state, pulled the model, and exited with code 0.

### Why does the GPU block get its own file?

Because Compose refuses to start the service on a host without the NVIDIA runtime. With the `deploy` block in the single file, `docker compose up` on my Mac stopped with `could not select device driver "nvidia" with capabilities: [[gpu]]`. The Docker docs say the `capabilities` field is required. They also say `count` and `device_ids` are mutually exclusive. Check those two first if your GPU block fails on a real GPU host. See the [Compose GPU support page](https://docs.docker.com/compose/how-tos/gpu-support/) for the full field list.

### Why bind the port to 127.0.0.1?

Because `-p 11434:11434` publishes on every interface. After a plain run, `docker port` showed `0.0.0.0:11434` and `[::]:11434`. The `127.0.0.1:11434:11434` form in the file above showed `127.0.0.1:11434` only. Docker also publishes ports ahead of `ufw` rules, which its own docs cover under [Docker and ufw](https://docs.docker.com/engine/network/packet-filtering-firewalls/). If this host faces the internet, read [the Ollama VPS hardening guide](https://devtoolhub.com/ollama-production-vps-hardening/) before you publish anything.

## Which Environment Variables Matter in Docker?

Five variables cover most tuning, and you set them with `-e` on `docker run` or under `environment:` in Compose. Defaults below come from the server config line Ollama prints at startup and from the [Ollama FAQ](https://docs.ollama.com/faq).

| Variable | Default | What it does |
|---|---|---|
| `OLLAMA_KEEP_ALIVE` | `5m` | How long an idle model stays in memory |
| `OLLAMA_NUM_PARALLEL` | `1` | Parallel requests per model; RAM scales with this times the context length |
| `OLLAMA_MAX_LOADED_MODELS` | 3 per GPU, or 3 on CPU | Models loaded at once, if they fit |
| `OLLAMA_MODELS` | `/root/.ollama/models` | Where models live inside the container |
| `OLLAMA_VULKAN` | `true` | Vulkan GPU support, bundled in the image |

One more detail from the startup log: with no GPU, Ollama picked a default context of 4096 tokens (`default_num_ctx=4096`). Set `OLLAMA_CONTEXT_LENGTH` if your prompts need more, and size memory for it. [Ollama hardware requirements](https://devtoolhub.com/ollama-hardware-requirements/) has the RAM and VRAM math.

## What Breaks in an Ollama Docker Setup?

Three things broke or surprised me, and the first one costs the most.

⚠️ **`docker compose down -v` deletes the models volume, and the `-v` flag is the only difference.** Plain `docker compose down` stopped the containers and left the `ollama` volume in place. Adding `-v` removed it. Models and key pair went with it. Use it only when you want a clean slate:

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

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
docker compose down        # keeps the ollama volume&lt;br&gt;
docker compose down -v     # deletes the volume and every model in it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The other two:

- **`latest` moves fast.** Docker Hub showed `0.40.12` on 27 October, release candidates on the 28th, and `0.40.13` on the 29th. Pin the tag in Compose so a restart doesn't pull a new version while you're debugging.
- **But the image is big.** The amd64 image is 3.39 GB compressed on Docker Hub. The arm64 image used 4.29 GB on my disk once pulled. Budget for that before the first model.

## What I Did Not Test

I tested everything on a Mac with no NVIDIA, AMD or Vulkan GPU. So none of the GPU paths ran against real hardware. I did not run the NVIDIA toolkit install, the `rocm` image, Vulkan, Windows with WSL2 or Jetson. Multi-GPU selection with `device_ids` is untested too. Those steps come from the official docs I linked, and the only GPU behavior I observed was the failure messages above. Everything else in this guide ran as written.

## Frequently Asked Questions

**Q: How do I run Ollama in Docker with GPU support?**
A: On NVIDIA, install the NVIDIA Container Toolkit, run `sudo nvidia-ctk runtime configure --runtime=docker`, restart Docker, then start the container with `--gpus=all`. On AMD, use the `ollama/ollama:rocm` image with `--device /dev/kfd --device /dev/dri`. Confirm with `ollama ps`.

**Q: Where does Ollama store models in Docker?**
A: Inside the container at `/root/.ollama/models`. Mount a named volume at `/root/.ollama` so models survive container removal. The same directory holds the `id_ed25519` key pair Ollama generates on first start.

**Q: Why is Ollama using the CPU inside Docker?**
A: Ollama found no GPU the container can access. Check that you passed `--gpus=all` or the AMD devices. Check that the host driver meets the version requirement. Then run `docker exec ollama ollama ps` and look for `100% CPU`. Docker Desktop on macOS can never pass a GPU through.

**Q: Can I use Ollama in Docker on a Mac?**
A: Yes, on the CPU only. Ollama's FAQ states GPU acceleration is unavailable for Docker Desktop on macOS. Run Ollama natively for Apple GPU performance.

**Quick Summary:**
- Mount a named volume at `/root/.ollama`, or every container removal deletes your models and the key pair.
- Compose prefixes volume names, so set `name: ollama` to share one volume between `docker run` and Compose.
- Check the GPU with `docker exec ollama ollama ps`, not the container's health status.
- Put the NVIDIA `deploy` block in a separate override file, because the single-file version fails on hosts without the runtime.
- The image has no `curl`, so use `ollama list` as the healthcheck, and bind the port to `127.0.0.1` unless you mean to expose it.

Your Ollama Docker setup is now running, so point your apps at it with the [Ollama API guide](https://devtoolhub.com/ollama-api-guide/).

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

&lt;/div&gt;



</description>
      <category>ollama</category>
      <category>docker</category>
      <category>dockercompose</category>
      <category>gpu</category>
    </item>
    <item>
      <title>Podman Checkpoint CVE: How a Sandbox Bypass Works</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:03:31 +0000</pubDate>
      <link>https://dev.to/amareswer/podman-checkpoint-cve-how-a-sandbox-bypass-works-19kp</link>
      <guid>https://dev.to/amareswer/podman-checkpoint-cve-how-a-sandbox-bypass-works-19kp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/podman-checkpoint-cve-sandbox-bypass/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you run Podman in production, a container image can now disable the sandbox restrictions you explicitly asked for, just by carrying one annotation. CVE-2026-94603 is a podman checkpoint handling bug that tricks &lt;code&gt;podman run&lt;/code&gt; into treating an ordinary image as a restored checkpoint, which silently overrides flags like &lt;code&gt;--cap-drop=ALL&lt;/code&gt;. Here's exactly how the bypass works, who it actually reaches, and what you need to patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happened: A Crafted Image Can Turn Off Your Sandbox
&lt;/h2&gt;

&lt;p&gt;Podman's &lt;code&gt;run&lt;/code&gt; command is supposed to enforce whatever sandboxing flags you pass it. Run it with &lt;code&gt;--cap-drop=ALL&lt;/code&gt; and you expect zero Linux capabilities inside that container, no exceptions.&lt;/p&gt;

&lt;p&gt;CVE-2026-94603 breaks that guarantee. A container image carrying a specific annotation makes Podman treat your &lt;code&gt;run&lt;/code&gt; command as if it were restoring a checkpoint instead of starting a fresh container. Checkpoint restores are allowed to bring their own capability set with them, so your flags get silently dropped. No error, no warning — the container just starts with weaker isolation than you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Annotation Trick Behind the Podman Checkpoint Bug
&lt;/h2&gt;

&lt;p&gt;The mechanism is narrow and specific, not a sprawling exploit chain. Podman has supported checkpoint and restore since version 4.4.0. It lets you snapshot a running container's process state, including open file descriptors, memory, and capabilities. You can restore that snapshot later, even on a different host. Podman identifies a checkpoint image by the &lt;code&gt;io.podman.annotations.checkpoint.runtime.name&lt;/code&gt; annotation baked into the image's OCI metadata.&lt;/p&gt;

&lt;p&gt;The bug is that &lt;code&gt;podman run&lt;/code&gt; never verified the image actually came from a real &lt;code&gt;podman container checkpoint&lt;/code&gt; export. It only checked for the annotation's presence. So an attacker can take any ordinary image and add that one annotation. Running &lt;code&gt;podman run&lt;/code&gt; against that image then skips your sandboxing flags. It applies whatever capability set the fake checkpoint metadata specifies instead. One annotation, no memory corruption, no privilege escalation chain — just a trust check that assumed only genuine checkpoints would ever carry that label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Podman Checkpoint Restores Get Special Treatment
&lt;/h2&gt;

&lt;p&gt;This isn't an arbitrary design flaw. Checkpoint and restore has a real reason to behave differently from a normal &lt;code&gt;run&lt;/code&gt;. When you restore a checkpoint, you're not starting a fresh container — you're resuming a process that was already running with a specific set of capabilities, namespaces, and open resources. If Podman forced your current command-line flags onto a restored process, the restore would frequently fail or behave in ways the original workload never expected.&lt;/p&gt;

&lt;p&gt;So checkpoint restores have always been allowed to carry their own privilege configuration, overriding whatever you pass on the command line. That's reasonable when the checkpoint data is genuinely trustworthy. The gap was that &lt;code&gt;podman run&lt;/code&gt; had no way to distinguish a real checkpoint from an image someone simply labeled to look like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's Exposed: Rootful Podman Only
&lt;/h2&gt;

&lt;p&gt;This is scoped tighter than the headlines suggest. Checkpoint and restore depends on CRIU (Checkpoint/Restore In Userspace), a Linux feature that needs privileges rootless containers don't have access to. Both Red Hat's CVE record and the GitHub security advisory behind it confirm the same thing directly. Rootless containers are not affected, because checkpoint and restore is unavailable in that mode.&lt;/p&gt;

&lt;p&gt;So if you're already running Podman rootless, this bug doesn't reach you at all. That's been the project's &lt;a href="https://devtoolhub.com/docker-vs-podman-which-container-tool-should-you-use/" rel="noopener noreferrer"&gt;recommended default for years, because a compromised container only affects that user's own privileges&lt;/a&gt;. It's specifically a rootful-Podman problem — anyone running &lt;code&gt;podman&lt;/code&gt; as root, including CI runners, build servers, and any workflow that pulls untrusted images with elevated privileges.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CVSS Score Depends on Who You Ask
&lt;/h2&gt;

&lt;p&gt;This part is worth being precise about, because the sources genuinely disagree, and you should know why rather than just picking a number. &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-94603" rel="noopener noreferrer"&gt;NVD's record&lt;/a&gt; is sourced from the GitHub Security Advisory the Podman maintainers filed. It scores this CVSS 3.1 &lt;strong&gt;10.0 (Critical)&lt;/strong&gt;, vector &lt;code&gt;AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H&lt;/code&gt;. That's a network attack vector, no privileges needed, no user interaction required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://access.redhat.com/security/cve/cve-2026-94603" rel="noopener noreferrer"&gt;Red Hat's own CVE record&lt;/a&gt; scores it lower, &lt;strong&gt;8.6 (Important)&lt;/strong&gt;, vector &lt;code&gt;AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H&lt;/code&gt;. Red Hat models the attack vector as local rather than network, and it requires user interaction. That matches the exploitation path both advisories describe. An attacker has to entice someone into running a specially crafted image. That's a local action, not a remote, zero-interaction network attack.&lt;/p&gt;

&lt;p&gt;Both scores describe the same flaw; they disagree on how to model the attack vector. Red Hat's local, interaction-required framing better matches how you'd actually get hit in practice. But don't let the lower number talk you out of patching — a full capability-set bypass on a container you expected to be locked down is serious under either score.&lt;/p&gt;

&lt;h2&gt;
  
  
  Affected Versions and What the Fix Actually Changed
&lt;/h2&gt;

&lt;p&gt;CVE-2026-94603 affects Podman &lt;code&gt;&amp;gt;=4.4.0, &amp;lt;5.8.8&lt;/code&gt; and &lt;code&gt;&amp;gt;=6.0.0, &amp;lt;6.1.3&lt;/code&gt;. It's fixed in &lt;code&gt;5.8.8&lt;/code&gt; and &lt;code&gt;6.1.3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix isn't a patched validation check. Maintainers removed checkpoint-image support from &lt;code&gt;podman run&lt;/code&gt; entirely instead. The fix commit explains the reasoning directly: checkpoints are an extremely privileged operation that &lt;code&gt;podman run&lt;/code&gt; was never built to constrain. So Podman now rejects any checkpoint-annotated image passed to &lt;code&gt;run&lt;/code&gt; outright. The error is generated server-side during spec processing, instead of the old client-side check this bug exploited. That closes the whole bypass category, not just the one annotation.&lt;/p&gt;

&lt;p&gt;Checkpoint and restore itself still works after the fix, just through &lt;code&gt;podman container restore&lt;/code&gt;, which is a deliberate, audited operation instead of something a &lt;code&gt;run&lt;/code&gt; command can be tricked into.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Check Your Podman Checkpoint Exposure and Patch
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;podman version&lt;/code&gt; and compare it against the ranges above. If you're anywhere from &lt;code&gt;4.4.0&lt;/code&gt; through &lt;code&gt;5.8.7&lt;/code&gt;, or &lt;code&gt;6.0.0&lt;/code&gt; through &lt;code&gt;6.1.2&lt;/code&gt;, and you run Podman rootful, you're exposed. Upgrade to &lt;code&gt;5.8.8&lt;/code&gt; or &lt;code&gt;6.1.3&lt;/code&gt;, whichever major line you track, and the checkpoint-annotation bypass goes away because the feature it relied on no longer exists in &lt;code&gt;podman run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you can't patch immediately, the practical mitigation is the same advice Podman has given for years: don't run untrusted images as root. Pair that with runtime detection. A tool like &lt;a href="https://devtoolhub.com/falco-kubernetes-runtime-security/" rel="noopener noreferrer"&gt;Falco can flag containers that start with an unexpected capability set&lt;/a&gt;. That catches this kind of silent sandbox downgrade even before you patch. Pinning digests and avoiding arbitrary registry images with elevated privileges are good general habits too. Our &lt;a href="https://devtoolhub.com/docker-best-practices-a-beginners-guide/" rel="noopener noreferrer"&gt;Docker best practices guide&lt;/a&gt; covers these, and most of it applies just as directly to Podman.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does CVE-2026-94603 affect rootless Podman?&lt;/strong&gt;&lt;br&gt;
A: No. Both Red Hat's advisory and the GitHub security advisory confirm rootless containers aren't affected, because checkpoint and restore requires CRIU privileges that rootless mode doesn't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What's the real CVSS score for this podman checkpoint bug?&lt;/strong&gt;&lt;br&gt;
A: Sources disagree. NVD lists 10.0 (Critical) based on the GitHub advisory's network/no-interaction scoring. Red Hat lists 8.6 (Important), scoring the attack vector as local with required user interaction, which matches how the bug is actually triggered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What annotation triggers the bypass?&lt;/strong&gt;&lt;br&gt;
A: &lt;code&gt;io.podman.annotations.checkpoint.runtime.name&lt;/code&gt;. Any image carrying it gets treated as a checkpoint restore by vulnerable versions of &lt;code&gt;podman run&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I still use checkpoint and restore after patching?&lt;/strong&gt;&lt;br&gt;
A: Yes, through &lt;code&gt;podman container restore&lt;/code&gt;. The fix removed checkpoint handling from &lt;code&gt;podman run&lt;/code&gt; specifically, not from Podman's checkpoint feature overall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do I need to change anything besides upgrading Podman?&lt;/strong&gt;&lt;br&gt;
A: Not strictly, but it's worth confirming any automation that runs images as root isn't pulling from untrusted registries, since that's the actual exposure path this bug needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CVE-2026-94603 lets a crafted image disable your sandbox flags by faking a &lt;code&gt;io.podman.annotations.checkpoint.runtime.name&lt;/code&gt; checkpoint annotation.&lt;/li&gt;
&lt;li&gt;Affects Podman &lt;code&gt;&amp;gt;=4.4.0, &amp;lt;5.8.8&lt;/code&gt; and &lt;code&gt;&amp;gt;=6.0.0, &amp;lt;6.1.3&lt;/code&gt;; fixed in &lt;code&gt;5.8.8&lt;/code&gt; and &lt;code&gt;6.1.3&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rootless Podman is not affected. Checkpoint and restore needs CRIU privileges rootless mode doesn't have.&lt;/li&gt;
&lt;li&gt;NVD scores it 10.0 (Critical). Red Hat scores it 8.6 (Important) based on a local, user-interaction-required attack path. Both describe the same flaw.&lt;/li&gt;
&lt;li&gt;The fix removed checkpoint-image support from &lt;code&gt;podman run&lt;/code&gt; entirely rather than patching the annotation check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underlying lesson from this podman checkpoint bypass isn't just "upgrade." Any feature allowed to override your explicit security flags needs its trust boundary checked, not just its presence flag. Run &lt;code&gt;podman version&lt;/code&gt;. Patch to 5.8.8 or 6.1.3 if you're rootful. And if you haven't already moved your default workflows to rootless, this is a good reason to.&lt;/p&gt;

</description>
      <category>podman</category>
      <category>security</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>AWS Secrets Manager vs Azure Key Vault vs GCP Secret Manager</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:30:25 +0000</pubDate>
      <link>https://dev.to/amareswer/aws-secrets-manager-vs-azure-key-vault-vs-gcp-secret-manager-2mf6</link>
      <guid>https://dev.to/amareswer/aws-secrets-manager-vs-azure-key-vault-vs-gcp-secret-manager-2mf6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/aws-secrets-manager-vs-azure-key-vault-vs-gcp-secret-manager/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pick the secrets manager that lives in the cloud your workloads already run in. The AWS Secrets Manager vs Azure Key Vault vs GCP Secret Manager choice then comes down to three things the product pages bury: how each one bills, which one rotates secrets for you, and how hard each one makes an accidental delete.&lt;/p&gt;

&lt;p&gt;I pulled every number below from the vendors' own pricing, quota and rotation pages on 2026-10-28, and I ran the cost scenarios myself. But I did not create secrets in any of the three clouds for this post. The AWS and Azure flags match the installed CLIs (aws-cli 2.38.6, azure-cli 2.90.0). The gcloud commands come from Google's docs only, because gcloud would not start on the machine I wrote this on.&lt;/p&gt;

&lt;p&gt;[IMAGE: articles/images/2026-10-28-aws-secrets-manager-vs-azure-key-vault-vs-gcp-secret-manager-diagram.png | alt: "Billing unit, rotation and read limits for three cloud secret stores side by side"]&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Difference Between AWS Secrets Manager vs Azure Key Vault vs GCP?
&lt;/h2&gt;

&lt;p&gt;All three store secret strings encrypted, keep versions, and gate reads with IAM. The differences that change your design are size and rate limits, who ships rotation, and the billing unit. AWS bills per secret, Azure bills per operation, and Google bills per active secret version.&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;AWS Secrets Manager&lt;/th&gt;
&lt;th&gt;Azure Key Vault&lt;/th&gt;
&lt;th&gt;GCP Secret Manager&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Max secret size&lt;/td&gt;
&lt;td&gt;65,536 bytes&lt;/td&gt;
&lt;td&gt;25 KB&lt;/td&gt;
&lt;td&gt;64 KiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read limit&lt;/td&gt;
&lt;td&gt;10,000 &lt;code&gt;GetSecretValue&lt;/code&gt; per second, per Region&lt;/td&gt;
&lt;td&gt;4,000 transactions per 10 seconds, per vault&lt;/td&gt;
&lt;td&gt;90,000 access requests per minute, per project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versions&lt;/td&gt;
&lt;td&gt;100 per secret, unlabeled ones pruned&lt;/td&gt;
&lt;td&gt;No cap, but backup fails past 500&lt;/td&gt;
&lt;td&gt;Not stated on the quotas page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing unit&lt;/td&gt;
&lt;td&gt;Per secret, plus per call&lt;/td&gt;
&lt;td&gt;Per operation&lt;/td&gt;
&lt;td&gt;Per active version, plus per access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in rotation&lt;/td&gt;
&lt;td&gt;Yes, via Lambda or managed rotation&lt;/td&gt;
&lt;td&gt;No for secrets&lt;/td&gt;
&lt;td&gt;No, notification only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Converted to seconds, AWS allows 10,000 reads per Region, but Azure allows only 400 per vault, and every operation except create shares that budget. Google sits between them at 1,500 per project.&lt;/p&gt;

&lt;p&gt;So a fleet that cold-starts and fetches secrets at once can hit throttling on Azure first. Spreading hot secrets across vaults helps, up to the subscription-wide cap of five times the per-vault limit. That advice is my inference from Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/key-vault/general/service-limits" rel="noopener noreferrer"&gt;service limits table&lt;/a&gt;, not Microsoft guidance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Much Do AWS Secrets Manager vs Azure Key Vault vs GCP Secret Manager Cost?
&lt;/h2&gt;

&lt;p&gt;For 100 secrets and 1 million reads a month, list prices work out to $45.00 on AWS, $3.00 on Azure and $8.61 on GCP. AWS costs the most because it charges $0.40 per secret every month. Azure's pricing lists no storage charge for secrets, so you pay only for operations.&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;Storage&lt;/th&gt;
&lt;th&gt;Reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;$0.40 per secret per month&lt;/td&gt;
&lt;td&gt;$0.05 per 10,000 calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure (Standard)&lt;/td&gt;
&lt;td&gt;None listed&lt;/td&gt;
&lt;td&gt;$0.03 per 10,000 operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP&lt;/td&gt;
&lt;td&gt;$0.000082192 per active version per hour after 6 free versions&lt;/td&gt;
&lt;td&gt;$0.03 per 10,000 accesses after 10,000 free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I took the Azure rate from Microsoft's retail prices API for East US. For Google I multiplied the hourly rate by 730 hours, which is about $0.06 per version per month.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secrets&lt;/th&gt;
&lt;th&gt;Reads per month&lt;/th&gt;
&lt;th&gt;Active versions each&lt;/th&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;Azure&lt;/th&gt;
&lt;th&gt;GCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$45.00&lt;/td&gt;
&lt;td&gt;$3.00&lt;/td&gt;
&lt;td&gt;$8.61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;10,000,000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$450.00&lt;/td&gt;
&lt;td&gt;$30.00&lt;/td&gt;
&lt;td&gt;$89.61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;$45.00&lt;/td&gt;
&lt;td&gt;$3.00&lt;/td&gt;
&lt;td&gt;$32.61&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the GCP trap. Google bills enabled and disabled versions, but not destroyed ones. So old versions you never destroy keep costing money. AWS avoids this because it bills per secret and prunes unlabeled versions itself once a secret passes 100.&lt;/p&gt;

&lt;p&gt;These list prices in USD exclude free-tier credits, KMS, Pub/Sub, and the Lambda or function you run for rotation. Also check the live pricing pages before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Secrets Manager Rotates Secrets for You?
&lt;/h2&gt;

&lt;p&gt;Only AWS rotates a secret end to end. Azure and Google deliver the trigger and leave the rotation code to you. Because of that, the gap matters more than the price difference if you manage database passwords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS&lt;/strong&gt; calls a Lambda function on your schedule. The function runs four steps: &lt;code&gt;create_secret&lt;/code&gt;, &lt;code&gt;set_secret&lt;/code&gt;, &lt;code&gt;test_secret&lt;/code&gt; and &lt;code&gt;finish_secret&lt;/code&gt;. You set the schedule with a &lt;code&gt;cron()&lt;/code&gt; or &lt;code&gt;rate()&lt;/code&gt; expression plus a window &lt;code&gt;Duration&lt;/code&gt;. Secrets owned by other AWS services use managed rotation instead, which needs no Lambda. Per the &lt;a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotate-secrets_lambda.html" rel="noopener noreferrer"&gt;rotation docs&lt;/a&gt;, if any step fails, Secrets Manager retries the whole rotation during the open windows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager rotate-secret &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--secret-id&lt;/span&gt; MySecret &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--rotation-lambda-arn&lt;/span&gt; arn:aws:lambda:&amp;lt;aws-region&amp;gt;:&amp;lt;account-id&amp;gt;:function:my-rotation-function &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--rotation-rules&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ScheduleExpression&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;cron(0 16 1,15 * ? *)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Duration&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;2h&lt;/span&gt;&lt;span class="se"&gt;\"&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;Azure&lt;/strong&gt; autorotates keys, not secrets. For a secret, Key Vault publishes a &lt;code&gt;SecretNearExpiry&lt;/code&gt; event to Event Grid 30 days before expiry. Event Grid calls a function you wrote, which creates the new version and then updates the target service. Microsoft's own tutorial warns there can be a lag between those two steps. During that window, the secret in Key Vault can't authenticate. Event Grid also retries for two hours if a step fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GCP&lt;/strong&gt; sends a &lt;code&gt;SECRET_ROTATE&lt;/code&gt; message to a Pub/Sub topic and stops there. The &lt;a href="https://docs.cloud.google.com/secret-manager/docs/secret-rotation" rel="noopener noreferrer"&gt;rotation docs&lt;/a&gt; set &lt;code&gt;rotation_period&lt;/code&gt; to a minimum of one hour, and &lt;code&gt;next_rotation_time&lt;/code&gt; must sit at least five minutes ahead. Also, scheduled rotations are skipped while another is in flight. Failed sends retry for up to seven days, then the rotation is canceled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud secrets create SECRET_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--replication-policy&lt;/span&gt; &lt;span class="s2"&gt;"automatic"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--next-rotation-time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_ROTATION_TIME"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rotation-period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ROTATION_PERIOD"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topics&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"projects/your-project-id/topics/your-topic-name"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How Do You Create and Read a Secret in Each Cloud?
&lt;/h2&gt;

&lt;p&gt;Each CLI takes a file instead of a literal value, and you should use that path. AWS warns that command history and shell utilities can expose parameters, and Google discourages piping through &lt;code&gt;echo&lt;/code&gt; for the same reason. So pass the value from a file, then delete the file afterward.&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;# AWS&lt;/span&gt;
aws secretsmanager create-secret &lt;span class="nt"&gt;--name&lt;/span&gt; MyTestSecret &lt;span class="nt"&gt;--secret-string&lt;/span&gt; file://mycreds.json
aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; MyTestSecret &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text

&lt;span class="c"&gt;# Azure&lt;/span&gt;
az keyvault secret &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; MySecretName &lt;span class="nt"&gt;--vault-name&lt;/span&gt; MyKeyVault &lt;span class="nt"&gt;--file&lt;/span&gt; /path/to/file &lt;span class="nt"&gt;--encoding&lt;/span&gt; utf-8
az keyvault secret show &lt;span class="nt"&gt;--name&lt;/span&gt; MySecretName &lt;span class="nt"&gt;--vault-name&lt;/span&gt; MyKeyVault &lt;span class="nt"&gt;--query&lt;/span&gt; value &lt;span class="nt"&gt;-o&lt;/span&gt; tsv

&lt;span class="c"&gt;# GCP&lt;/span&gt;
gcloud secrets create SECRET_ID &lt;span class="nt"&gt;--replication-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"automatic"&lt;/span&gt;
gcloud secrets versions add SECRET_ID &lt;span class="nt"&gt;--data-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"FILE_PATH"&lt;/span&gt;
gcloud secrets versions access latest &lt;span class="nt"&gt;--secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SECRET_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a version argument, each reads the newest value, but each names it differently. AWS returns the &lt;code&gt;AWSCURRENT&lt;/code&gt; stage by default, &lt;code&gt;az keyvault secret show&lt;/code&gt; uses the latest version, and Google accepts &lt;code&gt;latest&lt;/code&gt; as the version ID. For Azure access, Microsoft recommends Azure RBAC and calls access policies legacy. If you authenticate from a pipeline, our &lt;a href="https://devtoolhub.com/azure-service-principal-guide/" rel="noopener noreferrer"&gt;Azure service principal guide&lt;/a&gt; covers the identity side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Deletion and Recovery Gotchas?
&lt;/h2&gt;

&lt;p&gt;All three make deletion harder than you expect, but the rules differ. Know them before an incident, because a deleted secret can block you from recreating it under the same name.&lt;/p&gt;

&lt;p&gt;Azure Key Vault turns soft delete on for new vaults, and you can't switch it off. Deleted secrets stay recoverable for 7 to 90 days, with 90 as the default. You set that interval only at vault creation, so you can't change it later. While a secret sits in the deleted state, you can't create another secret with the same name in that vault. Purge protection is optional and also off by default. The &lt;code&gt;az keyvault create&lt;/code&gt; command exposes &lt;code&gt;--retention-days&lt;/code&gt;, &lt;code&gt;--enable-purge-protection&lt;/code&gt; and &lt;code&gt;--enable-rbac-authorization&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;AWS makes the secret inaccessible immediately and schedules deletion after a recovery window of at least seven days. Plus, nothing is billed for a secret marked for deletion. ⚠️ Note: &lt;code&gt;--force-delete-without-recovery&lt;/code&gt; skips the window, and you can't undo it. Also, a replicated primary secret can't be deleted until you remove its replicas.&lt;/p&gt;

&lt;p&gt;On Google, the replication policy is permanent: the locations can't be updated after you create the secret. With user-managed replication, an outage in one chosen region makes creates and updates fail rather than leaving data inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Should You Choose: AWS Secrets Manager vs Azure Key Vault vs GCP?
&lt;/h2&gt;

&lt;p&gt;Choose the store in the cloud where the workload runs, then let one of these exceptions override that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you need&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;Turnkey database-password rotation&lt;/td&gt;
&lt;td&gt;AWS, the only one that ships it, so budget $0.40 per secret per month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thousands of small secrets at low cost&lt;/td&gt;
&lt;td&gt;Azure, the cheapest in every scenario above, with GCP second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bursty reads&lt;/td&gt;
&lt;td&gt;AWS, which gives the most headroom per Region, so plan vault layout on Azure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All three clouds or on-prem&lt;/td&gt;
&lt;td&gt;A neutral store, because reading across clouds means managing a credential for the other cloud's IAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the neutral-store route, our &lt;a href="https://devtoolhub.com/understanding-hashicorp-vault-authentication-for-automation/" rel="noopener noreferrer"&gt;HashiCorp Vault authentication guide&lt;/a&gt; covers how workloads sign in. If you already picked Azure, our &lt;a href="https://devtoolhub.com/best-practices-for-azure-key-vault-safeguarding-your-secrets/" rel="noopener noreferrer"&gt;Key Vault best practices post&lt;/a&gt; goes deeper on access control. For Terraform users, &lt;a href="https://devtoolhub.com/terraform-1-10-secure-secrets-with-ephemeral-values/" rel="noopener noreferrer"&gt;ephemeral values&lt;/a&gt; keep secrets out of state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did Not Test
&lt;/h2&gt;

&lt;p&gt;I did not measure latency, real throttling behavior, or a full rotation run on any cloud. I also did not test organization policies or cross-account access. Everything above comes from the vendors' documentation and my own arithmetic on their list prices. Treat the cost table as a model of your bill, not a quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Which is cheaper, AWS Secrets Manager or Azure Key Vault?&lt;/strong&gt;&lt;br&gt;
A: Azure Key Vault, at list price. At 100 secrets and 1 million reads a month it costs $3.00, against $45.00 on AWS. AWS charges $0.40 per secret per month, while Azure lists no storage charge for secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does Azure Key Vault rotate secrets automatically?&lt;/strong&gt;&lt;br&gt;
A: Not natively. Key Vault autorotates keys, with a minimum interval of seven days. For secrets, you wire an Event Grid event to a function that writes the new version and updates the target service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does GCP Secret Manager rotate secrets on its own?&lt;/strong&gt;&lt;br&gt;
A: No. It publishes a &lt;code&gt;SECRET_ROTATE&lt;/code&gt; message to a Pub/Sub topic on your schedule. You build the subscriber that creates the new version and deploys it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I recover a deleted secret in AWS Secrets Manager?&lt;/strong&gt;&lt;br&gt;
A: Yes, during the recovery window, which is at least seven days. Use &lt;code&gt;restore-secret&lt;/code&gt; before the &lt;code&gt;DeletionDate&lt;/code&gt;. A secret deleted with &lt;code&gt;--force-delete-without-recovery&lt;/code&gt; is gone for good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS bills $0.40 per secret, Azure bills per operation only, and GCP bills per active version, so old undestroyed GCP versions cost money&lt;/li&gt;
&lt;li&gt;At 100 secrets and 1 million reads a month, list prices came to $45.00 on AWS, $3.00 on Azure and $8.61 on GCP&lt;/li&gt;
&lt;li&gt;Only AWS rotates secrets end to end. Azure and GCP send a trigger and you write the rotation code&lt;/li&gt;
&lt;li&gt;Azure reads are capped at 400 a second per vault, against 10,000 a second per Region on AWS&lt;/li&gt;
&lt;li&gt;Azure soft delete can't be disabled, and Google's replication locations can't be changed after creation&lt;/li&gt;
&lt;li&gt;I verified the AWS and Azure flags against the installed CLIs and ran no secrets in any cloud&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you commit, plug your own secret and read counts into the three price formulas above, then decide between AWS Secrets Manager vs Azure Key Vault vs GCP Secret Manager on your numbers.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>azure</category>
      <category>googlecloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>Git Reset vs Revert vs Restore: Which to Use</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:55:03 +0000</pubDate>
      <link>https://dev.to/amareswer/git-reset-vs-revert-vs-restore-which-to-use-17jg</link>
      <guid>https://dev.to/amareswer/git-reset-vs-revert-vs-restore-which-to-use-17jg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/git-reset-vs-revert-vs-restore/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use &lt;code&gt;git reset&lt;/code&gt; to move your branch back, &lt;code&gt;git revert&lt;/code&gt; to undo a commit with a new commit, and &lt;code&gt;git restore&lt;/code&gt; to fix files without touching history. The git reset vs revert choice comes down to one question: has anyone else pulled the commit? If yes, revert it. If no, reset it.&lt;/p&gt;

&lt;p&gt;I ran all three on Git 2.56.0 in a scratch repo, including a push to a bare remote, so every output below is real. This guide covers the three reset modes, how to undo your last commit and keep the changes, what happens when you reset a pushed commit, and how to recover after a hard reset. It is part of our &lt;a href="https://devtoolhub.com/git-github-actions-guide/" rel="noopener noreferrer"&gt;complete Git and GitHub Actions guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;[IMAGE: articles/images/2026-10-27-git-reset-vs-revert-vs-restore-diagram.png | alt: "Decision flow for undoing a commit, picking reset, revert or restore"]&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Difference Between Git Reset vs Revert vs Restore?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git reset&lt;/code&gt; moves the current branch to another commit. &lt;code&gt;git revert&lt;/code&gt; adds a new commit that cancels an older one, so history stays intact. &lt;code&gt;git restore&lt;/code&gt; copies file contents from a commit or the index into your working tree or index, and never moves the branch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it changes&lt;/th&gt;
&lt;th&gt;History&lt;/th&gt;
&lt;th&gt;Safe on pushed commits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git reset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Branch pointer, plus index and files depending on the mode&lt;/td&gt;
&lt;td&gt;Rewritten&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git revert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adds a new commit&lt;/td&gt;
&lt;td&gt;Kept&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git restore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Files in the working tree or index&lt;/td&gt;
&lt;td&gt;Untouched&lt;/td&gt;
&lt;td&gt;Not applicable, it touches no commits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test repo has three commits, already pushed to a bare remote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;d026baa change app to v3, add notes
1350c0e change app to v2
7974ef4 add app v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last commit changes &lt;code&gt;app.txt&lt;/code&gt; to &lt;code&gt;v3&lt;/code&gt; and adds &lt;code&gt;notes.txt&lt;/code&gt;. Every example below undoes that commit in a different way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Git Reset Works: Soft, Mixed and Hard
&lt;/h2&gt;

&lt;p&gt;A reset has three modes, and they differ in what they do with your changes. &lt;code&gt;--soft&lt;/code&gt; moves the branch and leaves the changes staged. &lt;code&gt;--mixed&lt;/code&gt;, the default, leaves them in your files but unstaged. &lt;code&gt;--hard&lt;/code&gt; throws them away. According to the &lt;a href="https://git-scm.com/docs/git-reset" rel="noopener noreferrer"&gt;git reset docs&lt;/a&gt;, Git also sets &lt;code&gt;ORIG_HEAD&lt;/code&gt; to the old branch tip before every reset, which matters for recovery later.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Index (staged)&lt;/th&gt;
&lt;th&gt;Working tree&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--soft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moves&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--mixed&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Moves&lt;/td&gt;
&lt;td&gt;Reset to match&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--hard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moves&lt;/td&gt;
&lt;td&gt;Reset to match&lt;/td&gt;
&lt;td&gt;Reset to match&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is &lt;code&gt;--soft HEAD~1&lt;/code&gt;. Both changes stay staged, ready to recommit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--soft&lt;/span&gt; HEAD~1
git status &lt;span class="nt"&gt;-sb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## main
M  app.txt
A  notes.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;--mixed&lt;/code&gt;, which is what a bare &lt;code&gt;git reset HEAD~1&lt;/code&gt; runs. The edit to &lt;code&gt;app.txt&lt;/code&gt; stays in the file, but nothing is staged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unstaged changes after reset:
M   app.txt
## main
 M app.txt
?? notes.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the last line. &lt;code&gt;notes.txt&lt;/code&gt; was a new file in the undone commit, so after a mixed reset it shows up as untracked (&lt;code&gt;??&lt;/code&gt;). Git has not deleted it, and &lt;code&gt;git add notes.txt&lt;/code&gt; puts it back under version control.&lt;/p&gt;

&lt;p&gt;⚠️ Note: &lt;code&gt;git reset --hard&lt;/code&gt; discards every uncommitted change to tracked files across the whole repository, not just one file. Run &lt;code&gt;git status&lt;/code&gt; first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD~1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEAD is now at 1350c0e change app to v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app.txt&lt;/code&gt; went back to &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;notes.txt&lt;/code&gt; was gone. An untracked file I had left in the folder survived, because a hard reset only touches files Git tracks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Reset vs Revert for Your Last Commit: Keeping Your Changes
&lt;/h2&gt;

&lt;p&gt;To undo your last commit and keep the work, run &lt;code&gt;git reset --soft HEAD~1&lt;/code&gt; if you want the changes staged, or &lt;code&gt;git reset HEAD~1&lt;/code&gt; if you want them unstaged. Both leave your files exactly as they were. Only the commit disappears.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--soft&lt;/span&gt; HEAD~1   &lt;span class="c"&gt;# changes stay staged&lt;/span&gt;
git reset HEAD~1          &lt;span class="c"&gt;# changes stay in files, unstaged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only works for commits nobody else has pulled. The branch moves back, so a commit you already pushed no longer matches the remote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Reset vs Revert After You Push
&lt;/h2&gt;

&lt;p&gt;After you push, use &lt;code&gt;git revert&lt;/code&gt;. A reset on a pushed commit makes your branch diverge from the remote, and the push fails. I reset the pushed branch with &lt;code&gt;--hard HEAD~1&lt;/code&gt; and tried to push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ! [rejected]        main -&amp;gt; main (non-fast-forward)
error: failed to push some refs to '../remote.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only a force push gets that through, and a force push rewrites history for everyone who already pulled it. Whether that is even allowed depends on your workflow, so check how &lt;a href="https://devtoolhub.com/git-workflows-gitflow-githubflow-trunk-based/" rel="noopener noreferrer"&gt;Git Flow, GitHub Flow and trunk-based development&lt;/a&gt; treat shared branches.&lt;/p&gt;

&lt;p&gt;Revert avoids the problem. It adds a commit instead of removing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &lt;span class="nt"&gt;--no-edit&lt;/span&gt; HEAD
git push origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bccdd1d Revert "change app to v3, add notes"
d026baa change app to v3, add notes
1350c0e change app to v2
7974ef4 add app v1
To ../remote.git
   d026baa..bccdd1d  main -&amp;gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push worked as a plain fast-forward. &lt;code&gt;app.txt&lt;/code&gt; was back to &lt;code&gt;v2&lt;/code&gt; and &lt;code&gt;notes.txt&lt;/code&gt; was gone, but the original commit and the revert both stay in the log. That record is the point, because anyone who pulled &lt;code&gt;d026baa&lt;/code&gt; can see exactly what undid it.&lt;/p&gt;

&lt;p&gt;Revert also works on a commit that is not the latest. &lt;code&gt;git revert 1350c0e&lt;/code&gt; reverted &lt;code&gt;change app to v2&lt;/code&gt; from further back in the log and added a new commit on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Git Restore Fixes Files Without Moving HEAD
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git restore&lt;/code&gt; changes files, not commits. By default it overwrites your working tree copy. With &lt;code&gt;--staged&lt;/code&gt; it only touches the index. With &lt;code&gt;--source&lt;/code&gt; it pulls the file from any commit. The &lt;a href="https://git-scm.com/docs/git-restore" rel="noopener noreferrer"&gt;git restore docs&lt;/a&gt; describe the working tree as the default location when you pass neither &lt;code&gt;--staged&lt;/code&gt; nor &lt;code&gt;--worktree&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;⚠️ Note: &lt;code&gt;git restore &amp;lt;file&amp;gt;&lt;/code&gt; throws away your unstaged edits to that file, and Git does not ask first. It exited with code 0 and printed nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"oops"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; app.txt
git restore app.txt        &lt;span class="c"&gt;# edit gone, file is back to v2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To unstage a file but keep your edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add app.txt
git restore &lt;span class="nt"&gt;--staged&lt;/span&gt; app.txt
git status &lt;span class="nt"&gt;-sb&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## main
 M app.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The older &lt;code&gt;git reset app.txt&lt;/code&gt; gave the same status in my test. Use whichever you remember, but &lt;code&gt;restore --staged&lt;/code&gt; says what it does.&lt;/p&gt;

&lt;p&gt;To pull one file from an old commit without moving the branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git restore &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;7974ef4 app.txt
&lt;span class="nb"&gt;cat &lt;/span&gt;app.txt                &lt;span class="c"&gt;# v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file now shows as modified (&lt;code&gt;M app.txt&lt;/code&gt;). To undo that, run &lt;code&gt;git restore --source=HEAD --staged --worktree app.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can You Recover After a Git Hard Reset?
&lt;/h2&gt;

&lt;p&gt;You can recover committed work, but not uncommitted edits. A hard reset only moves the branch, so the old commit still exists. &lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt; jumps straight back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; ORIG_HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEAD is now at d026baa change app to v3, add notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git reflog&lt;/code&gt; lists every earlier position of &lt;code&gt;HEAD&lt;/code&gt;, so you can also reset to &lt;code&gt;HEAD@{1}&lt;/code&gt;. In my test the reflog showed &lt;code&gt;d026baa HEAD@{1}: commit: change app to v3, add notes&lt;/code&gt; right after the reset. For more recovery steps, see our &lt;a href="https://devtoolhub.com/git-troubleshooting-recovery-expert/" rel="noopener noreferrer"&gt;Git troubleshooting and recovery guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Uncommitted work is different. I appended a line to &lt;code&gt;app.txt&lt;/code&gt;, ran &lt;code&gt;git reset --hard&lt;/code&gt;, and the line vanished. The newest reflog entry was just &lt;code&gt;reset: moving to HEAD&lt;/code&gt;, because Git never recorded that edit. So commit or stash before you reset.&lt;/p&gt;

&lt;p&gt;If you want a reset that refuses to lose local edits, use &lt;code&gt;--keep&lt;/code&gt;. With a local change in &lt;code&gt;app.txt&lt;/code&gt;, &lt;code&gt;git reset --keep HEAD~1&lt;/code&gt; stopped with &lt;code&gt;error: Entry 'app.txt' not uptodate. Cannot merge.&lt;/code&gt; instead of overwriting the edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverting Merge Commits and Resolving Conflicts
&lt;/h2&gt;

&lt;p&gt;A merge commit has two parents, so &lt;code&gt;git revert&lt;/code&gt; needs to know which side to keep. Without &lt;code&gt;-m&lt;/code&gt;, Git stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: commit 33e0fc4264dac85dea5f1897db2b5e31e9478c20 is a merge but no -m option was given.
fatal: revert failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass &lt;code&gt;-m 1&lt;/code&gt; to keep the first parent, which is the branch you merged into. The &lt;a href="https://git-scm.com/docs/git-revert" rel="noopener noreferrer"&gt;git revert docs&lt;/a&gt; say parent numbers start at 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &lt;span class="nt"&gt;--no-edit&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 1 HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my test that removed the merged feature's file (&lt;code&gt;feat.txt&lt;/code&gt;) and kept the work from &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reverts can also conflict. I reverted an older commit that created &lt;code&gt;c.txt&lt;/code&gt; after a later commit had changed the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONFLICT (modify/delete): c.txt deleted in parent of a26f5ed (add c.txt line A) and modified in HEAD.
error: could not revert a26f5ed... add c.txt line A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git status&lt;/code&gt; showed &lt;code&gt;UD c.txt&lt;/code&gt;. Fix the file and run &lt;code&gt;git revert --continue&lt;/code&gt;, or back out with &lt;code&gt;git revert --abort&lt;/code&gt;, which restored the clean branch. For the usual conflict markers, see our &lt;a href="https://devtoolhub.com/how-to-handle-merge-conflicts-in-git/" rel="noopener noreferrer"&gt;merge conflict guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Reset vs Revert vs Restore: Which to Use?
&lt;/h2&gt;

&lt;p&gt;Pick by situation, not by habit. Keep this table next to your &lt;a href="https://devtoolhub.com/git-cheat-sheet/" rel="noopener noreferrer"&gt;Git cheat sheet&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Undo your last local commit, keep the work staged&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git reset --soft HEAD~1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo your last local commit, keep the work unstaged&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git reset HEAD~1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo your last local commit and discard the work&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git reset --hard HEAD~1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo a commit you already pushed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git revert &amp;lt;sha&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo a merge you already pushed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git revert -m 1 &amp;lt;merge-sha&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstage a file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git restore --staged &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discard edits to a file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git restore &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get an old version of one file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git restore --source=&amp;lt;sha&amp;gt; &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between git reset and git revert?&lt;/strong&gt;&lt;br&gt;
A: &lt;code&gt;git reset&lt;/code&gt; moves your branch to an earlier commit and rewrites history. &lt;code&gt;git revert&lt;/code&gt; adds a new commit that cancels an earlier one and keeps history intact. Reset suits local commits nobody has pulled, and revert suits anything pushed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does git reset delete commits?&lt;/strong&gt;&lt;br&gt;
A: Not right away. The commits drop off your branch, but they stay reachable through &lt;code&gt;git reflog&lt;/code&gt; and &lt;code&gt;ORIG_HEAD&lt;/code&gt;. In my test, &lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt; brought the commit straight back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What does git reset HEAD~1 do?&lt;/strong&gt;&lt;br&gt;
A: It moves your branch back one commit and unstages that commit's changes. Your files stay as they were, and new files from the commit become untracked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I revert a commit that is not the latest?&lt;/strong&gt;&lt;br&gt;
A: Yes. &lt;code&gt;git revert &amp;lt;sha&amp;gt;&lt;/code&gt; works on any commit and adds a new commit on top. It can conflict if later commits touched the same lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I undo a git reset?&lt;/strong&gt;&lt;br&gt;
A: Run &lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt; right after the reset, or find the old commit in &lt;code&gt;git reflog&lt;/code&gt; and reset to it. This only recovers committed work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git reset&lt;/code&gt; moves your branch, &lt;code&gt;git revert&lt;/code&gt; adds a commit that cancels one, and &lt;code&gt;git restore&lt;/code&gt; fixes files without moving anything&lt;/li&gt;
&lt;li&gt;Reset a commit only if nobody has pulled it. Resetting a pushed commit made my push fail with &lt;code&gt;non-fast-forward&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--soft&lt;/code&gt; keeps changes staged, &lt;code&gt;--mixed&lt;/code&gt; keeps them unstaged, and &lt;code&gt;--hard&lt;/code&gt; discards them&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git reset --hard ORIG_HEAD&lt;/code&gt; recovers committed work, but uncommitted edits are gone for good&lt;/li&gt;
&lt;li&gt;Reverting a merge needs &lt;code&gt;-m 1&lt;/code&gt;, and &lt;code&gt;git revert --abort&lt;/code&gt; backs out of a conflict&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git restore &amp;lt;file&amp;gt;&lt;/code&gt; discards unstaged edits without asking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before your next undo, settle the git reset vs revert question first: run &lt;code&gt;git log --oneline -3&lt;/code&gt; and check whether the commit is on the remote. If it is, use &lt;code&gt;git revert&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Ansible Lint GitHub Actions: Fail PRs on Bad Playbooks</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:55:41 +0000</pubDate>
      <link>https://dev.to/amareswer/ansible-lint-github-actions-fail-prs-on-bad-playbooks-1kon</link>
      <guid>https://dev.to/amareswer/ansible-lint-github-actions-fail-prs-on-bad-playbooks-1kon</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/ansible-lint-github-actions/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ran ansible-lint against a four-task playbook that looked fine at a glance. It reported 14 violations and exited with code 2. Setting up that same check as an Ansible Lint GitHub Actions workflow takes about ten lines of YAML, so the broken playbook never reaches &lt;code&gt;main&lt;/code&gt;. This guide covers the workflow, the profile you should enforce, and a trap with ignore files that I hit while testing.&lt;/p&gt;

&lt;p&gt;Everything below was run with ansible-lint 26.10.1 and ansible-core 2.21.5 on Python 3.14.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Add an Ansible Lint GitHub Actions Check?
&lt;/h2&gt;

&lt;p&gt;An Ansible Lint GitHub Actions check turns style and safety rules into a required check on every pull request. A reviewer no longer has to spot a missing &lt;code&gt;mode:&lt;/code&gt; on a &lt;code&gt;copy&lt;/code&gt; task or a &lt;code&gt;shell&lt;/code&gt; call that runs on every play. The check fails, the PR stays red, and the author fixes it before anyone reads the diff.&lt;/p&gt;

&lt;p&gt;It also catches the quiet problems. For example, tasks that report "changed" on every run and &lt;code&gt;state: latest&lt;/code&gt; installs that upgrade packages unpredictably. Both break idempotency, which is the property that makes a playbook safe to re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Ansible Lint Catches in a Real Playbook
&lt;/h2&gt;

&lt;p&gt;First, here is the playbook I tested. It installs nginx, copies a config, restarts the service, and unpacks a tarball:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure web servers&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;tasks&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;install nginx&lt;/span&gt;
      &lt;span class="na"&gt;apt&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;nginx&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&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;Copy site config&lt;/span&gt;
      &lt;span class="na"&gt;copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx.conf&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/nginx/nginx.conf&lt;/span&gt;

    &lt;span class="pi"&gt;-&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;systemctl restart nginx&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;Fetch release tarball&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;curl -s https://example.com/app.tgz | tar xz -C /opt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;ansible-lint site.yml&lt;/code&gt; printed this summary line and exited with code 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed: 14 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'min'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 14 violations came from nine rule IDs. Four are &lt;code&gt;fqcn&lt;/code&gt; hits, one per short module name. The rest are the ones that matter in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unpinned installs trip &lt;code&gt;package-latest&lt;/code&gt;: "Package installs should not use latest."&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;copy&lt;/code&gt; task has no &lt;code&gt;mode&lt;/code&gt;, which trips &lt;code&gt;risky-file-permissions&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Calling &lt;code&gt;systemctl&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt; through shell trips &lt;code&gt;command-instead-of-module&lt;/code&gt;, because the &lt;code&gt;systemd&lt;/code&gt; and &lt;code&gt;get_url&lt;/code&gt; modules exist for that.&lt;/li&gt;
&lt;li&gt;Both shell tasks report changed on every run (&lt;code&gt;no-changed-when&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A pipe without &lt;code&gt;pipefail&lt;/code&gt; means a failed &lt;code&gt;curl&lt;/code&gt; can still produce a green task (&lt;code&gt;risky-shell-pipe&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;One task has no name at all (&lt;code&gt;name[missing]&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reviewer skimming this in a PR would probably approve it. The linter, however, doesn't skim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Minimal Ansible Lint GitHub Actions Workflow
&lt;/h2&gt;

&lt;p&gt;The official action takes a handful of inputs. Per the &lt;a href="https://docs.ansible.com/projects/lint/installing/" rel="noopener noreferrer"&gt;ansible-lint installation docs&lt;/a&gt;, they are &lt;code&gt;args&lt;/code&gt;, &lt;code&gt;setup_python&lt;/code&gt;, &lt;code&gt;python_version&lt;/code&gt; (default &lt;code&gt;3.14&lt;/code&gt;), &lt;code&gt;working_directory&lt;/code&gt;, and &lt;code&gt;requirements_file&lt;/code&gt;. Create &lt;code&gt;.github/workflows/ansible-lint.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ansible-lint&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**.yml"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**.yaml"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.ansible-lint"&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt;
    &lt;span class="na"&gt;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;ansible/ansible-lint@9bce2bfb8f4a984e6b13767bc0d6bf0a49492164&lt;/span&gt; &lt;span class="c1"&gt;# v26.10.1&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;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--profile&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;production"&lt;/span&gt;
          &lt;span class="na"&gt;requirements_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requirements.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details here are deliberate.&lt;/p&gt;

&lt;p&gt;First, the action is pinned to a commit SHA. The docs example uses &lt;code&gt;@main&lt;/code&gt;, which means your lint rules change whenever upstream merges something. The SHA above is the &lt;code&gt;v26.10.1&lt;/code&gt; tag, which I resolved through the GitHub API. If you're tightening the rest of your pipeline too, the &lt;a href="https://devtoolhub.com/github-actions-security-best-practices/" rel="noopener noreferrer"&gt;GitHub Actions security guide&lt;/a&gt; explains why tags are a weak pin.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;permissions: contents: read&lt;/code&gt; keeps the token minimal, because a linter needs nothing more.&lt;/p&gt;

&lt;p&gt;Third, &lt;code&gt;requirements_file&lt;/code&gt; installs the roles and collections your playbooks import. Skip it and you get load failures on any playbook that uses a collection module. Keep the setting in the file, not in extra action inputs. The docs state that extra options won't be added to the action, so behavior changes go in a &lt;code&gt;.ansible-lint&lt;/code&gt; config file instead.&lt;/p&gt;

&lt;p&gt;For a refresher on how workflow files fit together, see the &lt;a href="https://devtoolhub.com/github-actions-workflow-yaml-guide/" rel="noopener noreferrer"&gt;workflow YAML explainer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Profile Should You Enforce?
&lt;/h2&gt;

&lt;p&gt;ansible-lint groups rules into six cumulative profiles: &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;basic&lt;/code&gt;, &lt;code&gt;moderate&lt;/code&gt;, &lt;code&gt;safety&lt;/code&gt;, &lt;code&gt;shared&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;. Each one extends the one before it, according to the &lt;a href="https://docs.ansible.com/projects/lint/profiles/" rel="noopener noreferrer"&gt;profiles documentation&lt;/a&gt;. Pick one with &lt;code&gt;--profile&lt;/code&gt; on the command line or &lt;code&gt;profile:&lt;/code&gt; in &lt;code&gt;.ansible-lint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I ran the same broken playbook against each profile:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;basic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;moderate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;safety&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;min&lt;/code&gt; profile only checks that Ansible can load the file. Because our playbook passes it, the default run said the last profile met was &lt;code&gt;min&lt;/code&gt;. That is a low bar, so don't stop there.&lt;/p&gt;

&lt;p&gt;For a new repo, start at &lt;code&gt;production&lt;/code&gt;. In practice that is the setting most teams end up on anyway. For an existing one, start at &lt;code&gt;basic&lt;/code&gt; and move up one profile at a time as you clean up. Specifically, the &lt;code&gt;safety&lt;/code&gt; profile is where the rules about &lt;code&gt;latest&lt;/code&gt;, risky permissions, and shell pipes show up, so it's the first level that protects you from real incidents.&lt;/p&gt;

&lt;p&gt;The fixed version of the playbook passes &lt;code&gt;--profile production&lt;/code&gt; with exit code 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure web servers&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
  &lt;span class="na"&gt;become&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;tasks&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;Install nginx&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.apt&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;nginx&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&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;Copy site config&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx.conf&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/nginx/nginx.conf&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&lt;/span&gt;
      &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart nginx&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;Download release tarball&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.get_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://example.com/app.tgz&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/app.tgz&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0644"&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;Unpack release tarball&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.unarchive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/app.tgz&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt&lt;/span&gt;
        &lt;span class="na"&gt;remote_src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;handlers&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;Restart nginx&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.service&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;nginx&lt;/span&gt;
        &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restarted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restart moved into a handler. As a result, nginx only restarts when the config actually changes. That one change removes a &lt;code&gt;no-changed-when&lt;/code&gt; hit and makes the play idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;--fix&lt;/code&gt; Actually Fixes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ansible-lint --fix&lt;/code&gt; rewrites the file in place. On my test playbook it cut the failures from 14 to 8. It capitalized the task name, added &lt;code&gt;ansible.builtin.&lt;/code&gt; to every module, and turned &lt;code&gt;shell: systemctl restart nginx&lt;/code&gt; into &lt;code&gt;ansible.builtin.command&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, it could not fix the rest. The log said it plainly: "Rule specific fix not applied for: name[missing]". It also left &lt;code&gt;latest&lt;/code&gt;, the missing &lt;code&gt;mode&lt;/code&gt;, and both &lt;code&gt;no-changed-when&lt;/code&gt; hits alone, because those need a human to decide what the right behavior is.&lt;/p&gt;

&lt;p&gt;⚠️ Note: &lt;code&gt;--fix&lt;/code&gt; modifies your files. Run it on a clean branch and read the diff before you commit. Don't run it in CI and push the result back unattended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adopting Ansible Lint GitHub Actions Checks on an Existing Repo
&lt;/h2&gt;

&lt;p&gt;Turning on a strict check for a repo with hundreds of old violations makes every PR red on day one. The usual answer is &lt;code&gt;ansible-lint --generate-ignore&lt;/code&gt;, which writes a &lt;code&gt;.ansible-lint-ignore&lt;/code&gt; file. Each line holds a filename and a rule ID, and ansible-lint stops failing on those pairs.&lt;/p&gt;

&lt;p&gt;Here is what that file looked like for my test playbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;site.yml command-instead-of-module
site.yml command-instead-of-shell
site.yml fqcn[action-core]
site.yml name[casing]
site.yml name[missing]
site.yml no-changed-when
site.yml package-latest
site.yml risky-file-permissions
site.yml risky-shell-pipe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After generating it, the same run reported &lt;code&gt;Passed: 0 failure(s), 14 warning(s)&lt;/code&gt; and exited with code 0. Because the check is green, you can merge the workflow and start enforcing rules on new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an Ignore File Can Hide New Violations
&lt;/h2&gt;

&lt;p&gt;The ignore file works per file and per rule. It does not work per line. I found this out by appending a new task to &lt;code&gt;site.yml&lt;/code&gt; that ran &lt;code&gt;rm -rf /tmp/x&lt;/code&gt; through &lt;code&gt;ansible.builtin.command&lt;/code&gt; with no &lt;code&gt;changed_when&lt;/code&gt;. That is a fresh &lt;code&gt;no-changed-when&lt;/code&gt; violation.&lt;/p&gt;

&lt;p&gt;As a result, the run printed &lt;code&gt;Passed: 0 failure(s), 15 warning(s)&lt;/code&gt; and exited with code 0. The new violation showed up as a warning marked &lt;code&gt;# ignored&lt;/code&gt;, and the PR stayed green.&lt;/p&gt;

&lt;p&gt;This means an ignore entry for &lt;code&gt;no-changed-when&lt;/code&gt; in a file silences that rule for every future task in that file. A few ways to deal with it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Treat &lt;code&gt;.ansible-lint-ignore&lt;/code&gt; as debt, not a setting. Review it in PRs and shrink it over time.&lt;/li&gt;
&lt;li&gt;Prefer fixing a rule across the repo, then deleting its ignore lines, over leaving ignores in place.&lt;/li&gt;
&lt;li&gt;Don't expect &lt;code&gt;--strict&lt;/code&gt; to help. It makes warnings fail the run, and ignored violations count as warnings. With the ignore file in place, &lt;code&gt;--strict&lt;/code&gt; exited with code 2 on my test, so it can't gate new code while old ignores exist.&lt;/li&gt;
&lt;li&gt;Put new playbooks in new files. Rules ignored for &lt;code&gt;legacy.yml&lt;/code&gt; don't apply to &lt;code&gt;new.yml&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Running Ansible Lint Locally Before Your GitHub Actions Run
&lt;/h2&gt;

&lt;p&gt;Plus, a failing PR check is slower than a failing terminal. Install the same version the workflow uses and run it before you push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv/bin/pip &lt;span class="nb"&gt;install &lt;/span&gt;ansible-core ansible-lint
.venv/bin/ansible-lint &lt;span class="nt"&gt;--profile&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠️ Note: use Python 3.14 or older. On Python 3.15, ansible-core 2.21.5 printed a warning that it supports Python up to 3.14, and &lt;code&gt;ansible&lt;/code&gt; itself crashed with &lt;code&gt;module 'dataclasses' has no attribute '_is_type'&lt;/code&gt;. The GitHub Action defaults to 3.14, so CI won't hit this, but a fresh laptop with the newest Python might.&lt;/p&gt;

&lt;p&gt;ansible-lint also supports several output formats through &lt;code&gt;-f&lt;/code&gt;: &lt;code&gt;brief&lt;/code&gt;, &lt;code&gt;full&lt;/code&gt;, &lt;code&gt;md&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;codeclimate&lt;/code&gt;, &lt;code&gt;quiet&lt;/code&gt;, &lt;code&gt;pep8&lt;/code&gt;, and &lt;code&gt;sarif&lt;/code&gt;. Use &lt;code&gt;pep8&lt;/code&gt; for editor integration and &lt;code&gt;codeclimate&lt;/code&gt; if your CI tool consumes it.&lt;/p&gt;

&lt;p&gt;If you manage infrastructure with both tools, linting is where Ansible and Terraform converge. The &lt;a href="https://devtoolhub.com/terraform-docs-github-action/" rel="noopener noreferrer"&gt;Terraform docs action&lt;/a&gt; follows the same pattern of a single CI job that fails the PR. For picking between the tools themselves, see &lt;a href="https://devtoolhub.com/opentofu-vs-terraform/" rel="noopener noreferrer"&gt;OpenTofu vs Terraform&lt;/a&gt;. And if you want the wider picture of what breaks pipelines, the &lt;a href="https://devtoolhub.com/git-github-actions-guide/" rel="noopener noreferrer"&gt;GitHub Actions incidents guide&lt;/a&gt; is the place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does ansible-lint fail the build by default?&lt;/strong&gt;&lt;br&gt;
A: Yes. ansible-lint exits with code 2 when it finds violations, and a non-zero exit fails the GitHub Actions step. Warnings from your &lt;code&gt;warn_list&lt;/code&gt; or ignore file don't fail the run unless you pass &lt;code&gt;--strict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Which ansible-lint profile should I use in CI?&lt;/strong&gt;&lt;br&gt;
A: Use &lt;code&gt;production&lt;/code&gt; for new repos and &lt;code&gt;basic&lt;/code&gt; for existing ones, then raise it one level at a time. The profiles are cumulative, so &lt;code&gt;safety&lt;/code&gt; includes everything in &lt;code&gt;basic&lt;/code&gt; and &lt;code&gt;moderate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I pin the ansible-lint action to a tag or a SHA?&lt;/strong&gt;&lt;br&gt;
A: Pin to a commit SHA and leave the tag in a comment. Tags can move, and the official docs example uses &lt;code&gt;@main&lt;/code&gt;, which changes under you. Dependabot can bump a SHA-pinned action for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can ansible-lint fix problems automatically?&lt;/strong&gt;&lt;br&gt;
A: Partly. &lt;code&gt;ansible-lint --fix&lt;/code&gt; handled 6 of 14 violations on my test playbook, mostly module names and casing. It also can't decide &lt;code&gt;changed_when&lt;/code&gt; conditions, file modes, or package versions for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does my CI check pass when I know a task is wrong?&lt;/strong&gt;&lt;br&gt;
A: Check &lt;code&gt;.ansible-lint-ignore&lt;/code&gt; first. An entry there silences a rule for the whole file, so new violations of that rule pass as warnings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ansible-lint&lt;/code&gt; exits with code 2 on violations, so it works as a required PR check with no extra scripting&lt;/li&gt;
&lt;li&gt;A four-task test playbook produced 14 violations across nine rule IDs; &lt;code&gt;--profile min&lt;/code&gt; passed, &lt;code&gt;production&lt;/code&gt; failed all 14&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;ansible/ansible-lint&lt;/code&gt; pinned to a commit SHA (&lt;code&gt;v26.10.1&lt;/code&gt; is &lt;code&gt;9bce2bfb8f4a984e6b13767bc0d6bf0a49492164&lt;/code&gt;), not the docs example's &lt;code&gt;@main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pass &lt;code&gt;requirements_file&lt;/code&gt; so roles and collections install before linting&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--fix&lt;/code&gt; resolved 6 of 14 and left the judgment calls; read the diff before committing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--generate-ignore&lt;/code&gt; is per file and per rule, so new violations of an ignored rule pass silently, and &lt;code&gt;--strict&lt;/code&gt; fails while ignores exist&lt;/li&gt;
&lt;li&gt;Local installs need Python 3.14 or older for ansible-core 2.21.5&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with &lt;code&gt;--profile basic&lt;/code&gt; on your current repo, generate the ignore file once, and commit the Ansible Lint GitHub Actions workflow. Then delete one ignore line per week until the file is empty.&lt;/p&gt;

</description>
      <category>ansible</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Istio AuthorizationPolicy Bypass: HTTP/2 Method-Case Bug</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:55:30 +0000</pubDate>
      <link>https://dev.to/amareswer/istio-authorizationpolicy-bypass-http2-method-case-bug-3ii8</link>
      <guid>https://dev.to/amareswer/istio-authorizationpolicy-bypass-http2-method-case-bug-3ii8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/istio-authorizationpolicy-http2-bypass/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Istio AuthorizationPolicy rules that block or allow traffic by HTTP method just got a real asterisk. &lt;a href="https://istio.io/latest/news/security/istio-security-2026-007/" rel="noopener noreferrer"&gt;ISTIO-SECURITY-2026-007&lt;/a&gt; fixes a case bug in that matcher. Published October 15, 2026, it covers one specific issue: an HTTP/2 request with a lowercased method name could slip past a method-based rule entirely. If you run DENY rules with &lt;code&gt;methods&lt;/code&gt;, or ALLOW rules with &lt;code&gt;notMethods&lt;/code&gt;, check this today. Don't wait for your next patch window.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ISTIO-SECURITY-2026-007 Fixes in Istio AuthorizationPolicy
&lt;/h2&gt;

&lt;p&gt;The advisory bundles five fixes. The loudest is a critical Envoy Brotli decompressor buffer overflow, CVE-2026-111497 (CVSS 9.2). Two Envoy crash bugs on CONNECT requests without a :path header (GHSA-8vc2-jrm4-835w, CVSS 7.5, and GHSA-47vj-9r25-wv5j, CVSS 6.5) and an ambient-mode istiod denial-of-service bug (GHSA-7p2q-4xvg-vxc6, CVSS 7.7, IP-addressed waypoints only) round it out.&lt;/p&gt;

&lt;p&gt;The other is the one this piece covers: &lt;a href="https://github.com/istio/istio/security/advisories/GHSA-w44g-8m76-8gc5" rel="noopener noreferrer"&gt;GHSA-w44g-8m76-8gc5&lt;/a&gt;, CVSS 7.4 (High). Istio titled it "AuthorizationPolicy method rules are bypassed over HTTP/2 by changing the method case."&lt;/p&gt;

&lt;p&gt;There's no CVE number attached to this one yet. Only the GHSA ID exists, so don't expect it in a CVE search anytime soon. The affected range is 1.31.0–1.31.1, 1.30.0–1.30.5, and 1.29.0–1.29.8.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the HTTP/2 Method-Case Bypass Works
&lt;/h2&gt;

&lt;p&gt;An Istio AuthorizationPolicy can use a &lt;code&gt;DENY&lt;/code&gt; rule with &lt;code&gt;methods&lt;/code&gt;, or an &lt;code&gt;ALLOW&lt;/code&gt; rule with &lt;code&gt;notMethods&lt;/code&gt;. Both compile down to an Envoy &lt;code&gt;:method&lt;/code&gt; matcher. That matcher is case-sensitive. So a rule meant to block &lt;code&gt;POST&lt;/code&gt; only checks for the literal uppercase string &lt;code&gt;POST&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's where the codecs diverge. Envoy's HTTP/1.1 codec normalizes and rejects non-uppercase methods before the request reaches the authorization filter. A lowercase &lt;code&gt;post&lt;/code&gt; never even gets to test the policy. HTTP/2 doesn't enforce that same restriction at the codec layer. A request arriving as &lt;code&gt;post&lt;/code&gt; instead of &lt;code&gt;POST&lt;/code&gt; passes straight through the case-sensitive matcher. The policy that was supposed to block it does nothing.&lt;/p&gt;

&lt;p&gt;That's the whole bug. One protocol enforces method casing before policy evaluation; the other doesn't. The policy logic assumed both would behave the same way. Policies that only use &lt;code&gt;ALLOW&lt;/code&gt; rules with an explicit &lt;code&gt;methods&lt;/code&gt; list aren't affected. The request still has to match an allowed value to get through. The negative-match cases — &lt;code&gt;DENY&lt;/code&gt; + &lt;code&gt;methods&lt;/code&gt;, &lt;code&gt;ALLOW&lt;/code&gt; + &lt;code&gt;notMethods&lt;/code&gt; — are different: there, a non-match means "let it through."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Istio AuthorizationPolicy Bypasses Keep Happening
&lt;/h2&gt;

&lt;p&gt;This is at least the fifth time a matching-logic gap has let traffic slip past an Istio AuthorizationPolicy rule. Every time, the rule was supposed to catch it. Each one has been a different field. But the shape of the bug is the same: the policy compiler assumes a normalized value. Somewhere downstream, that assumption breaks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2020-16844&lt;/strong&gt; (ISTIO-SECURITY-2020-009, CVSS 6.8) — &lt;code&gt;DENY&lt;/code&gt; rules with wildcard suffixes on source principals or namespaces never denied TCP callers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2021-39155&lt;/strong&gt; (&lt;a href="https://istio.io/latest/news/security/istio-security-2021-008/" rel="noopener noreferrer"&gt;ISTIO-SECURITY-2021-008&lt;/a&gt;) — the &lt;code&gt;Host&lt;/code&gt; header comparison was case-sensitive, even though RFC 4343 says hostnames should compare case-insensitively. That gap let &lt;code&gt;Httpbin.Foo&lt;/code&gt; bypass a rule written for &lt;code&gt;httpbin.foo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2021-39156&lt;/strong&gt; (same ISTIO-SECURITY-2021-008 advisory) — a &lt;code&gt;#fragment&lt;/code&gt; appended to a request path could bypass path-based authorization checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2022-21679&lt;/strong&gt; (ISTIO-SECURITY-2022-001, CVSS 6.8) — mixing a 1.12 control plane with 1.11 data plane proxies broke &lt;code&gt;hosts&lt;/code&gt; and &lt;code&gt;notHosts&lt;/code&gt; matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are the same code path. But they're all the same category: a string comparison in an AuthorizationPolicy field that quietly assumed normalized input. If you've treated method, host, or path matchers as airtight, this history says otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Is Affected: Versions and Deployment Modes
&lt;/h2&gt;

&lt;p&gt;You're exposed if two things are both true. Your Istio AuthorizationPolicy rules use &lt;code&gt;DENY&lt;/code&gt; with &lt;code&gt;methods&lt;/code&gt;, or &lt;code&gt;ALLOW&lt;/code&gt; with &lt;code&gt;notMethods&lt;/code&gt;. And clients can reach the workload over HTTP/2.&lt;/p&gt;

&lt;p&gt;That covers both sidecar mesh deployments and ambient mode. Both enforce L7 authorization through Envoy proxies that speak HTTP/2 to upstream services and to each other.&lt;/p&gt;

&lt;p&gt;It doesn't matter whether the attacker is external or an already-compromised pod elsewhere in the cluster. The bypass works the same way either direction. That's exactly why relying on a single enforcement layer is risky. Our guide on &lt;a href="https://devtoolhub.com/kubernetes-node-pools-networking/" rel="noopener noreferrer"&gt;Kubernetes node pools and networking&lt;/a&gt; is useful here too. It maps where traffic actually flows before you decide where policy should live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patched Versions and the Upgrade Path
&lt;/h2&gt;

&lt;p&gt;Istio shipped fixes in 1.31.2, 1.30.6, and 1.29.9. The 1.29 branch's fix is also its final release. That line is done taking patches. Treat 1.29.9 as a stopgap, and plan your move to 1.30 or 1.31 regardless.&lt;/p&gt;

&lt;p&gt;Roll the whole mesh, not just the control plane. The same release carries Envoy fixes, so restart your sidecars — or ztunnels and waypoints, on ambient — to pick up the patched proxy build, and don't stop at &lt;code&gt;istiod&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Audit Your Istio AuthorizationPolicy Rules Right Now
&lt;/h2&gt;

&lt;p&gt;You don't need to wait for a scanner to tell you if you're exposed. Pull every policy in the cluster and grep for the two fields that matter:&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 authorizationpolicies.security.istio.io &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-B5&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"methods:|notMethods:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each hit, check the &lt;code&gt;action&lt;/code&gt; field on that policy. If it's &lt;code&gt;DENY&lt;/code&gt; and lists &lt;code&gt;methods&lt;/code&gt;, or it's &lt;code&gt;ALLOW&lt;/code&gt; and lists &lt;code&gt;notMethods&lt;/code&gt;, that rule relied on a case-sensitive match. HTTP/2 traffic could dodge it until you upgraded. If every method-based rule you find is an &lt;code&gt;ALLOW&lt;/code&gt; with an explicit &lt;code&gt;methods&lt;/code&gt; list, you were never affected. Patch anyway — the next matcher bug in this list might also hit a different field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mitigations If You Can't Upgrade Yet
&lt;/h2&gt;

&lt;p&gt;The cleanest workaround doesn't need a code change to Istio at all. Rewrite &lt;code&gt;DENY&lt;/code&gt; + &lt;code&gt;methods&lt;/code&gt; rules as &lt;code&gt;ALLOW&lt;/code&gt; rules with an explicit, uppercase &lt;code&gt;methods&lt;/code&gt; list instead. Per the advisory itself, policies that only use &lt;code&gt;ALLOW&lt;/code&gt; with an explicit methods list were never affected. A request still has to match a listed value to get through. There's no negative-match gap to exploit.&lt;/p&gt;

&lt;p&gt;If you can't restructure the policy quickly, you can also restrict which workloads accept HTTP/2 from untrusted sources. That's a narrower fix than it sounds. gRPC and most modern service-to-service traffic run on HTTP/2 by default. Upgrading is still the actual fix; the policy rewrite is a bridge, not a replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway: Don't Rely on Istio AuthorizationPolicy Alone
&lt;/h2&gt;

&lt;p&gt;Five bypasses across six years in the same subsystem is a pattern, not bad luck. That's a reasonable argument for not putting all your access control eggs in one AuthorizationPolicy basket. Layer a &lt;a href="https://devtoolhub.com/kubernetes-network-policies/" rel="noopener noreferrer"&gt;Kubernetes NetworkPolicy&lt;/a&gt; underneath your mesh's L7 rules. That way, a matcher bug at the application layer doesn't also mean the connection itself was never restricted.&lt;/p&gt;

&lt;p&gt;Runtime visibility helps too. A tool like &lt;a href="https://devtoolhub.com/falco-kubernetes-runtime-security/" rel="noopener noreferrer"&gt;Falco&lt;/a&gt; can flag unexpected traffic at the kernel level, no matter what Envoy's policy layer missed. If you're studying for a Kubernetes security certification, our &lt;a href="https://devtoolhub.com/cka-vs-ckad-vs-cks/" rel="noopener noreferrer"&gt;CKA vs CKAD vs CKS&lt;/a&gt; breakdown covers the access-control picture. It shows where authorization and network segmentation fit into the certification track.&lt;/p&gt;

&lt;p&gt;For now: patch to 1.31.2, 1.30.6, or 1.29.9. Audit your existing Istio AuthorizationPolicy rules for &lt;code&gt;DENY&lt;/code&gt;+&lt;code&gt;methods&lt;/code&gt; or &lt;code&gt;ALLOW&lt;/code&gt;+&lt;code&gt;notMethods&lt;/code&gt;. And don't treat method-based policy as your only line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Does this bug have a CVE number?&lt;/strong&gt;&lt;br&gt;
A: Not as of this advisory's publication. It's tracked only under the GitHub Security Advisory ID GHSA-w44g-8m76-8gc5, CVSS 7.4 (High). A CVE may get assigned later, but don't rely on a CVE-only vulnerability feed catching it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is my cluster affected if I only use ALLOW rules with an explicit methods list?&lt;/strong&gt;&lt;br&gt;
A: No. The advisory is specific: &lt;code&gt;ALLOW&lt;/code&gt; rules with an explicit &lt;code&gt;methods&lt;/code&gt; list were never affected. It still has to match a value in that list. The bug only hits the negative-match cases: &lt;code&gt;DENY&lt;/code&gt; with &lt;code&gt;methods&lt;/code&gt;, or &lt;code&gt;ALLOW&lt;/code&gt; with &lt;code&gt;notMethods&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does HTTP/1.1 traffic need the same audit?&lt;/strong&gt;&lt;br&gt;
A: No, not for this specific bug. Envoy's HTTP/1.1 codec rejects non-uppercase methods before the request reaches the authorization filter. So the case-mismatch bypass doesn't apply here. HTTP/2 is the exposed path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Are the other fixes in the same advisory related to this bypass?&lt;/strong&gt;&lt;br&gt;
A: No. ISTIO-SECURITY-2026-007 bundles five separate fixes: this method-case bypass (GHSA-w44g-8m76-8gc5), a critical Envoy Brotli overflow (CVE-2026-111497), two Envoy CONNECT crash bugs, and an ambient-mode istiod denial-of-service bug (GHSA-7p2q-4xvg-vxc6) that needs an IP-addressed waypoint. They're unrelated to each other, and patching to the fixed versions covers all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ISTIO-SECURITY-2026-007 (Oct 15, 2026) fixes GHSA-w44g-8m76-8gc5, CVSS 7.4 — no CVE assigned yet&lt;/li&gt;
&lt;li&gt;The bug: Istio AuthorizationPolicy's &lt;code&gt;:method&lt;/code&gt; matcher is case-sensitive, so HTTP/2's lowercase methods slip past DENY/notMethods rules&lt;/li&gt;
&lt;li&gt;Affected: &lt;code&gt;DENY&lt;/code&gt; rules with &lt;code&gt;methods&lt;/code&gt;, or &lt;code&gt;ALLOW&lt;/code&gt; rules with &lt;code&gt;notMethods&lt;/code&gt;, on 1.31.0–1.31.1, 1.30.0–1.30.5, 1.29.0–1.29.8&lt;/li&gt;
&lt;li&gt;Not affected: &lt;code&gt;ALLOW&lt;/code&gt; rules with an explicit &lt;code&gt;methods&lt;/code&gt; list&lt;/li&gt;
&lt;li&gt;Patched in 1.31.2, 1.30.6, and 1.29.9 (1.29's final release — plan your move off that branch)&lt;/li&gt;
&lt;li&gt;Same advisory also fixes a critical Envoy Brotli overflow (CVE-2026-111497, CVSS 9.2) — the upgrade covers it&lt;/li&gt;
&lt;li&gt;This is the fifth AuthorizationPolicy matching bypass in Istio's history (CVE-2020-16844, CVE-2021-39155, CVE-2021-39156, CVE-2022-21679) — treat method/host/path matchers as a layer, not the whole wall, and back them with NetworkPolicy and runtime detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real fix is upgrading. But if this is the fifth time a string comparison inside Istio AuthorizationPolicy has let traffic through it shouldn't have, the bigger lesson is to stop treating that one policy object as your only gate.&lt;/p&gt;

</description>
      <category>istio</category>
      <category>kubernetes</category>
      <category>security</category>
      <category>servicemesh</category>
    </item>
    <item>
      <title>MCP vs A2A vs AG-UI: The Protocol Stack, Fact-Checked</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Fri, 23 Oct 2026 12:30:14 +0000</pubDate>
      <link>https://dev.to/amareswer/mcp-vs-a2a-vs-ag-ui-the-protocol-stack-fact-checked-5990</link>
      <guid>https://dev.to/amareswer/mcp-vs-a2a-vs-ag-ui-the-protocol-stack-fact-checked-5990</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/mcp-vs-a2a-vs-ag-ui/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MCP vs A2A vs AG-UI is the comparison chart making the rounds right now, and the framing is right — these are three different, complementary layers, not competing standards. But a couple of its headline numbers are either stale or don't hold up against the protocols' own sources, and it skips the actual news: MCP's July 2026 spec update is a real breaking-change event, not a one-word "stateless" bullet point. Here's what each protocol actually does, fact-checked against their own docs and GitHub repos, plus what the chart got right and what's worth correcting. For the agent concepts underneath all three, see our &lt;a href="https://devtoolhub.com/ai-agents-explained/" rel="noopener noreferrer"&gt;AI agents explained&lt;/a&gt; guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Protocols, What Each One Actually Connects
&lt;/h2&gt;

&lt;p&gt;The "vs" in every comparison title is misleading — you don't pick one. Each protocol solves a different connection problem, and a real agentic system typically uses all three at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; — built by Anthropic — connects an agent to &lt;strong&gt;tools and data&lt;/strong&gt;. Anthropic's own docs describe it as a standardized way to connect AI applications to external systems, "like a USB-C port for AI applications." If your agent needs to read a file, query a database, or call an API, this is the layer — see our &lt;a href="https://devtoolhub.com/mcp-registry-by-the-numbers/" rel="noopener noreferrer"&gt;MCP registry breakdown&lt;/a&gt; for how big that ecosystem already is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A2A (Agent2Agent Protocol)&lt;/strong&gt; — built by Google, now governed by the Linux Foundation — connects &lt;strong&gt;one agent to another&lt;/strong&gt;. It's how two independent agents, possibly built by different teams on different frameworks, discover each other and hand off work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AG-UI (Agent-User Interaction Protocol)&lt;/strong&gt; — built by CopilotKit and its open-source community, not Google (a detail the viral chart gets right, worth double-checking since AG-UI's name invites confusion with Google's A2A) — connects &lt;strong&gt;an agent to the human watching it work&lt;/strong&gt;. It's an event-based protocol for streaming an agent's state and output into a real UI, not a chat log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these three compete for the same job. A production agent that reads your CRM (MCP), delegates a sub-task to a specialist agent (A2A), and shows a human the live progress bar while it works (AG-UI) is using all three simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP's July 2026 Spec Update Was Bigger Than "Stateless"
&lt;/h2&gt;

&lt;p&gt;The chart's MCP row says "Stateless spec, July 2026" and leaves it there. That's the right headline, but it's doing a lot of work in one line — per &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" rel="noopener noreferrer"&gt;MCP's own 2026-07-28 changelog&lt;/a&gt;, this is the largest rewrite since launch: protocol-level sessions and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header are gone, the &lt;code&gt;initialize&lt;/code&gt; handshake is gone, and Roots, Sampling, and Logging are now formally deprecated with a twelve-month removal window. We already covered the full breaking-change list and a real server-migration checklist in &lt;a href="https://devtoolhub.com/mcp-server-authentication-spec-update/" rel="noopener noreferrer"&gt;MCP Server Authentication: What the 2026-07-28 Spec Broke&lt;/a&gt; — if you're migrating an MCP server, that's the piece to work from.&lt;/p&gt;

&lt;p&gt;What's actually new to check, and what the chart doesn't carry at all: per &lt;a href="https://claude.com/blog/bringing-mcp-2026-07-28-to-claude" rel="noopener noreferrer"&gt;Anthropic's own announcement&lt;/a&gt;, MCP is at &lt;strong&gt;400M monthly SDK downloads&lt;/strong&gt; as of that July 28 update — a real, sourced number that's meaningfully below the chart's "close to 500M" claim, and the real reason for the stateless move is architectural: "MCP moves from a bidirectional stateful protocol to a request/response model. Servers can now deploy on serverless and edge infrastructure" — something the old session-pinned design couldn't do cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A2A's Real Status — v1.0, Signed Agent Cards, and a Number Worth Correcting
&lt;/h2&gt;

&lt;p&gt;The chart says A2A hit "v1.0 with signed Agent Cards, March 2026." The version and date both check out — &lt;a href="https://github.com/a2aproject/A2A/releases" rel="noopener noreferrer"&gt;A2A's GitHub releases&lt;/a&gt; show v1.0.0 published March 12, 2026, and the live spec's section 8.4 ("Agent Card Signing") does define canonicalization, signature format, and signature verification requirements so agents can cryptographically sign their Agent Cards for authenticity. Worth knowing if you're building against it today: v1.0 isn't the current patch anymore — v1.0.1 shipped May 28, 2026 with HTTP binding and &lt;code&gt;TaskStatus&lt;/code&gt; fixes.&lt;/p&gt;

&lt;p&gt;Governance checks out too: Google originally built A2A and donated it to the Linux Foundation, and as of August 20, 2026 it formally joined the &lt;strong&gt;Agentic AI Foundation (AAIF)&lt;/strong&gt; — the same body that already hosts MCP — putting both protocols under one neutral governance structure instead of two separate vendor-led ones. A2A's Technical Steering Committee includes representatives from AWS, Cisco, Google, IBM Research, Microsoft, Salesforce, SAP, and ServiceNow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one number worth double-checking:&lt;/strong&gt; the chart lists A2A at "150+ organizations behind it." I couldn't find a primary source attributing that specific figure to A2A alone. What I could verify, directly from the Linux Foundation's own press release, is AAIF's total membership — the body that now governs &lt;em&gt;both&lt;/em&gt; A2A and MCP — which states it "grows to 247 member organizations with the addition of 57 new members over the past three months." That's a materially different, larger number than "150+," just measuring the whole foundation rather than one protocol's backers specifically. If the chart's figure is from an earlier AAIF member count, it's stale now.&lt;/p&gt;

&lt;h2&gt;
  
  
  AG-UI's Real Adoption — 16.7k Stars and a Fourth Integration the Chart Skips
&lt;/h2&gt;

&lt;p&gt;AG-UI's chart row says "15K+ GitHub stars." Checking the &lt;a href="https://github.com/ag-ui-protocol/ag-ui" rel="noopener noreferrer"&gt;ag-ui-protocol/ag-ui repo&lt;/a&gt; directly, it's actually at &lt;strong&gt;16.7k stars&lt;/strong&gt; — the chart's number is accurate but already a bit behind. More interesting: the chart lists three 1st-party framework integrations (Google ADK, Microsoft Agent Framework, AWS AgentCore). The repo documents a fourth — &lt;strong&gt;AWS Strands Agents&lt;/strong&gt; is also officially supported, alongside AWS Bedrock AgentCore, not instead of it. If you're picking an agent framework specifically because it has AG-UI support baked in, Strands is a real option the chart doesn't mention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where ACP Went
&lt;/h2&gt;

&lt;p&gt;The chart's footer note — "Where's ACP? IBM merged it into A2A in August 2025" — checks out exactly. IBM Research's Agent Communication Protocol, built to power its BeeAI platform, overlapped heavily with A2A (both covered agent-to-agent discovery and messaging). The &lt;a href="https://github.com/i-am-bee/acp" rel="noopener noreferrer"&gt;original ACP repository&lt;/a&gt; itself confirms it: archived August 27, 2025, with a banner reading "ACP is now part of A2A under the Linux Foundation!" and a link to the migration guide. If you see ACP referenced in older material, it's a dead end now — the real destination is A2A.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do You Need All Three? A Practical Decision Guide
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your agent needs to read or write something external&lt;/strong&gt; — a CRM, your docs, a GitHub repo, a database: that's MCP. One server, any MCP-compatible client can use it — our &lt;a href="https://devtoolhub.com/best-mcp-servers-by-category/" rel="noopener noreferrer"&gt;best MCP servers by category&lt;/a&gt; guide is a starting point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your agent needs to hand off a task to a different, independently-built agent&lt;/strong&gt; — possibly on a different framework, possibly a different vendor: that's A2A. The Agent Card is how the other side learns what your agent can do before the handoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A person needs to watch your agent work and approve or steer its steps in real time&lt;/strong&gt;, not just read a final answer: that's AG-UI. It's the layer between the agent's internal state and whatever UI framework renders it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're building a single-tool integration with no multi-agent handoff and no live UI&lt;/strong&gt; — you may genuinely only need MCP. Don't add A2A or AG-UI to a system that doesn't have another agent or a human-in-the-loop UI to connect to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Are MCP, A2A, and AG-UI competing standards?&lt;/strong&gt;&lt;br&gt;
A: No. They connect different things — MCP connects an agent to tools and data, A2A connects one agent to another, AG-UI connects an agent to the person watching it. A single system can use all three at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What actually changed when MCP went stateless in July 2026?&lt;/strong&gt;&lt;br&gt;
A: Sessions, the &lt;code&gt;initialize&lt;/code&gt; handshake, and three features (Roots, Sampling, Logging) all changed at once — see our full &lt;a href="https://devtoolhub.com/mcp-server-authentication-spec-update/" rel="noopener noreferrer"&gt;MCP server authentication breaking-change checklist&lt;/a&gt; for the complete list. The number worth knowing that isn't in that piece: Anthropic's own blog puts MCP at 400M monthly SDK downloads as of this update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Who governs MCP and A2A now?&lt;/strong&gt;&lt;br&gt;
A: Both are hosted projects under the Agentic AI Foundation (AAIF), part of the Linux Foundation. A2A joined AAIF on August 20, 2026, after Google donated it; MCP was already a founding AAIF project. AAIF's own press release puts total membership at 247 organizations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does AG-UI only work with Google, Microsoft, and AWS AgentCore?&lt;/strong&gt;&lt;br&gt;
A: Those three are 1st-party supported, but so is AWS Strands Agents — a fourth framework with official AG-UI support that's easy to miss if you're only going by popular comparison charts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What happened to ACP (IBM's Agent Communication Protocol)?&lt;/strong&gt;&lt;br&gt;
A: It merged into A2A. The original repository was archived August 27, 2025, with a notice directing developers to A2A's migration guide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick Summary:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP, A2A, and AG-UI are complementary layers (tools, agent-to-agent, agent-to-human), not competing alternatives&lt;/li&gt;
&lt;li&gt;MCP's July 28, 2026 spec update (sessions, the &lt;code&gt;initialize&lt;/code&gt; handshake, Roots/Sampling/Logging all changed) is covered in full in our &lt;a href="https://devtoolhub.com/mcp-server-authentication-spec-update/" rel="noopener noreferrer"&gt;MCP server authentication piece&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Per Anthropic's own blog, MCP is at 400M monthly SDK downloads as of that update — not "close to 500M"&lt;/li&gt;
&lt;li&gt;A2A hit v1.0.0 in March 2026 (now on patch v1.0.1) with real signed Agent Cards; AAIF's own total membership is 247 organizations, a different number than "150+"&lt;/li&gt;
&lt;li&gt;AG-UI is at 16.7k GitHub stars with four 1st-party framework integrations (Google ADK, Microsoft Agent Framework, AWS Bedrock AgentCore, and AWS Strands Agents)&lt;/li&gt;
&lt;li&gt;ACP (IBM) merged into A2A in August 2025 and is a dead end if you see it referenced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real answer to MCP vs A2A vs AG-UI is: pick protocols by the connection you actually need, not by which chart you saw first — and if you're touching an MCP integration built before July 2026, check it against the real breaking-change list above before assuming "stateless" just means a minor version bump.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
      <category>devops</category>
    </item>
    <item>
      <title>macOS 27 fm CLI: What Apple's Built-In Model Can Do</title>
      <dc:creator>Amaresh Pelleti</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:48:08 +0000</pubDate>
      <link>https://dev.to/amareswer/macos-27-fm-cli-what-apples-built-in-model-can-do-1kk1</link>
      <guid>https://dev.to/amareswer/macos-27-fm-cli-what-apples-built-in-model-can-do-1kk1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://devtoolhub.com/macos-27-fm-cli-apple-on-device-model/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;strong&gt;macOS 27 fm CLI&lt;/strong&gt; gives every Mac a free language model in the terminal. It needs no account, no API key and no download, and you can point any OpenAI-style client at it. It also has a hard 4,096-token limit and a few rough edges, and several articles describing it get the details wrong.&lt;/p&gt;

&lt;p&gt;I ran it on macOS 27.0.1 and measured what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the macOS 27 fm CLI?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;fm&lt;/code&gt; command is a binary at &lt;code&gt;/usr/bin/fm&lt;/code&gt; that talks to Apple's on-device foundation model, the same model behind Apple Intelligence. Apple announced it at WWDC26, so it isn't hidden. Run &lt;code&gt;fm available&lt;/code&gt; first. On my Mac it printed &lt;code&gt;System model available&lt;/code&gt;, and that was the whole setup.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fm respond&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One prompt in, one answer out. Reads stdin too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fm chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Interactive session, saved under &lt;code&gt;~/.fm/sessions/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fm count-tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counts tokens in a prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fm schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Generates a JSON schema for structured output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fm serve&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starts a local Chat Completions API server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Is the Private Cloud Compute model available?
&lt;/h2&gt;

&lt;p&gt;Not on this build. Apple's session shows &lt;code&gt;fm respond --model pcc&lt;/code&gt;, but on macOS 27.0.1 it fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fm respond &lt;span class="nt"&gt;--model&lt;/span&gt; pcc &lt;span class="s1"&gt;'hi'&lt;/span&gt;
&lt;span class="c"&gt;# Error: The value 'pcc' is invalid for '--model &amp;lt;model&amp;gt;'. Please provide one of 'system'.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is the context window of the fm CLI?
&lt;/h2&gt;

&lt;p&gt;About 4,096 tokens, shared by prompt and reply. Inputs up to 4,001 counted tokens worked; 4,031 failed with &lt;code&gt;The session's transcript exceeded the model's context size.&lt;/code&gt; The 8,192 figure in some articles doesn't match this test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run fm serve as a local API?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fm serve &lt;span class="nt"&gt;--port&lt;/span&gt; 1976
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:1976/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"system","stream":false,"messages":[{"role":"user","content":"Say hi in 3 words."}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model id is always &lt;code&gt;system&lt;/code&gt;. The server streams by default, so set &lt;code&gt;"stream": false&lt;/code&gt; explicitly. I found no auth option, so keep it on localhost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can the fm CLI return structured JSON?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fm schema object &lt;span class="nt"&gt;--name&lt;/span&gt; Person &lt;span class="nt"&gt;--string&lt;/span&gt; name &lt;span class="nt"&gt;--int&lt;/span&gt; age &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; person.json
fm respond &lt;span class="nt"&gt;--schema&lt;/span&gt; person.json &lt;span class="s1"&gt;'Extract: Maria is 34 years old and lives in Lisbon.'&lt;/span&gt; &lt;span class="nt"&gt;--no-stream&lt;/span&gt;
&lt;span class="c"&gt;# {"name": "Maria", "age": 34}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How does it compare to Ollama?
&lt;/h2&gt;

&lt;p&gt;About 21 tokens per second through &lt;code&gt;fm serve&lt;/code&gt;, versus 20.9 for &lt;code&gt;llama3.2&lt;/code&gt; (3B) on Ollama, one run each. Use &lt;code&gt;fm&lt;/code&gt; for zero-install scripting and Ollama when you need a bigger model or long context.&lt;/p&gt;

&lt;p&gt;It also answered "today's date" with October 27, 2024, and wrote a &lt;code&gt;find -exec wc -l&lt;/code&gt; command that prints per-file counts instead of a total. Check anything it produces.&lt;/p&gt;

&lt;p&gt;Read the full version with the comparison table and FAQ on &lt;a href="https://devtoolhub.com/macos-27-fm-cli-apple-on-device-model/" rel="noopener noreferrer"&gt;DevToolHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>m</category>
      <category>a</category>
      <category>c</category>
      <category>o</category>
    </item>
  </channel>
</rss>
