<?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: James Joyner</title>
    <description>The latest articles on DEV Community by James Joyner (@jjoyneriv).</description>
    <link>https://dev.to/jjoyneriv</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%2F3973259%2F0becdb8e-e292-4cd3-b2dd-c55ab65df4c1.jpg</url>
      <title>DEV Community: James Joyner</title>
      <link>https://dev.to/jjoyneriv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jjoyneriv"/>
    <language>en</language>
    <item>
      <title>Keep Docker Engine as Your Kubernetes Runtime on Ubuntu with cri-dockerd</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Tue, 04 Aug 2026 23:35:35 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/keep-docker-engine-as-your-kubernetes-runtime-on-ubuntu-with-cri-dockerd-18de</link>
      <guid>https://dev.to/jjoyneriv/keep-docker-engine-as-your-kubernetes-runtime-on-ubuntu-with-cri-dockerd-18de</guid>
      <description>&lt;p&gt;Sometimes you genuinely want &lt;strong&gt;Docker Engine&lt;/strong&gt; as the Kubernetes node runtime — a team standardized on the Docker CLI/API for tooling, an image-build box that doubles as a node, or a legacy playbook you can't rewrite yet. Since &lt;code&gt;dockershim&lt;/code&gt; was removed in Kubernetes 1.24, that path now runs through &lt;strong&gt;&lt;code&gt;cri-dockerd&lt;/code&gt;&lt;/strong&gt;, an open-source CRI adapter maintained by Mirantis. Here's how to set it up on Ubuntu, and an honest note on whether you should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you? A quick reality check
&lt;/h2&gt;

&lt;p&gt;For most people the answer is &lt;strong&gt;no&lt;/strong&gt; — use containerd (see the previous post). Reach for &lt;code&gt;cri-dockerd&lt;/code&gt; only when you have a concrete reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing automation that talks to the Docker socket on the node.&lt;/li&gt;
&lt;li&gt;You want &lt;code&gt;docker build&lt;/code&gt; and the kubelet on the &lt;em&gt;same&lt;/em&gt; host sharing one image store.&lt;/li&gt;
&lt;li&gt;A vendor or product that still assumes Docker Engine as the runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those apply, containerd is less to install and less to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install Docker Engine on Ubuntu
&lt;/h2&gt;

&lt;p&gt;Use Docker's official repo, not the &lt;code&gt;docker.io&lt;/code&gt; package, so you get current Engine:&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;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; ca-certificates curl
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; stable"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Set Docker's cgroup driver to systemd&lt;/strong&gt; — on Ubuntu's cgroup v2 this must match the kubelet or the node will not stay Ready:&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;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/docker
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' | sudo tee /etc/docker/daemon.json
{ "exec-opts": ["native.cgroupdriver=systemd"], "log-driver": "json-file", "log-opts": {"max-size": "100m"} }
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Install cri-dockerd
&lt;/h2&gt;

&lt;p&gt;Grab the latest release for your architecture:&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;VER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.3.15   &lt;span class="c"&gt;# check github.com/Mirantis/cri-dockerd/releases for current&lt;/span&gt;
&lt;span class="nv"&gt;ARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; cri-dockerd.deb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://github.com/Mirantis/cri-dockerd/releases/download/v&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/cri-dockerd_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.3-0.ubuntu-noble_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARCH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.deb"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg &lt;span class="nt"&gt;-i&lt;/span&gt; cri-dockerd.deb

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cri-docker.socket
systemctl status cri-docker.socket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CRI socket it exposes is the value you'll hand to kubeadm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unix:///var/run/cri-dockerd.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Point kubeadm at the cri-dockerd socket
&lt;/h2&gt;

&lt;p&gt;Everything else in the node setup (kernel modules, sysctl, swap off, the &lt;code&gt;pkgs.k8s.io&lt;/code&gt; repo) is identical to the containerd post — only the CRI endpoint changes:&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;sudo &lt;/span&gt;kubeadm init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cri-socket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unix:///var/run/cri-dockerd.sock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.0/16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worker joins must carry the same flag:&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;sudo &lt;/span&gt;kubeadm &lt;span class="nb"&gt;join&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;cp&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;:6443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;token&amp;gt; &lt;span class="nt"&gt;--discovery-token-ca-cert-hash&lt;/span&gt; sha256:&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cri-socket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unix:///var/run/cri-dockerd.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;span class="c"&gt;# CONTAINER-RUNTIME now reads docker://&amp;lt;engine-version&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The gotcha nobody warns you about
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;cri-dockerd&lt;/code&gt;, images pulled by the kubelet and images you &lt;code&gt;docker pull&lt;/code&gt; live in the &lt;strong&gt;same store&lt;/strong&gt;, which is convenient — but &lt;code&gt;docker ps&lt;/code&gt; will show a lot of Kubernetes-managed containers you didn't start. Use &lt;code&gt;crictl&lt;/code&gt; (CRI-aware) for cluster containers and reserve &lt;code&gt;docker&lt;/code&gt; for images you manage by hand, so you don't accidentally &lt;code&gt;docker rm&lt;/code&gt; something the kubelet owns.&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;sudo &lt;/span&gt;crictl &lt;span class="nt"&gt;--runtime-endpoint&lt;/span&gt; unix:///var/run/cri-dockerd.sock ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the node comes up NotReady or pods can't pull, it's nearly always the cgroup-driver mismatch or the socket path — both covered in the &lt;a href="https://devopsaitoolkit.com/guides/kubernetes-pod-startup-errors/" rel="noopener noreferrer"&gt;Kubernetes pod-startup error hub&lt;/a&gt; and the &lt;a href="https://devopsaitoolkit.com/guides/docker-container-and-runtime-errors/" rel="noopener noreferrer"&gt;Docker runtime error guides&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next in the series: dropping the whole node dance and running Kubernetes &lt;strong&gt;inside&lt;/strong&gt; Docker with &lt;code&gt;kind&lt;/code&gt; for local development.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>ubuntu</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Set Up a Kubernetes Node on Ubuntu 24.04 with containerd (the Docker-Compatible Runtime)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:28:05 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/set-up-a-kubernetes-node-on-ubuntu-2404-with-containerd-the-docker-compatible-runtime-3l31</link>
      <guid>https://dev.to/jjoyneriv/set-up-a-kubernetes-node-on-ubuntu-2404-with-containerd-the-docker-compatible-runtime-3l31</guid>
      <description>&lt;p&gt;This is the mainstream way to run Kubernetes "with Docker" on Ubuntu in 2026: &lt;strong&gt;containerd&lt;/strong&gt; as the runtime (the same engine Docker uses under the hood), your &lt;strong&gt;Docker-built images&lt;/strong&gt; running unchanged. Here's a clean &lt;code&gt;kubeadm&lt;/code&gt; node bring-up on &lt;strong&gt;Ubuntu 24.04 (Noble)&lt;/strong&gt; with the sharp edges called out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Run everything below as root or with &lt;code&gt;sudo&lt;/code&gt;. This sets up a single control-plane node; join workers with the &lt;code&gt;kubeadm join&lt;/code&gt; command printed at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Kernel prerequisites
&lt;/h2&gt;

&lt;p&gt;Kubernetes needs bridged traffic to hit iptables and IP forwarding on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disable swap — the kubelet refuses to start with swap on by default:&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;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;.bak &lt;span class="s1"&gt;'/\bswap\b/s/^/#/'&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Install containerd
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; containerd
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/containerd
containerd config default | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/containerd/config.toml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The one edit that matters on Ubuntu.&lt;/strong&gt; Ubuntu 22.04+ uses cgroup v2, so containerd and the kubelet must both use the &lt;code&gt;systemd&lt;/code&gt; cgroup driver or the node flaps between Ready/NotReady:&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;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/SystemdCgroup = false/SystemdCgroup = true/'&lt;/span&gt; /etc/containerd/config.toml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart containerd
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;containerd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(This mismatch is common enough that I gave it its own post later in the series.)&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Install kubeadm, kubelet, kubectl
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;apt.kubernetes.io&lt;/code&gt; repo was retired — use &lt;code&gt;pkgs.k8s.io&lt;/code&gt;. Pin the minor version you want (1.30 shown):&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;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https ca-certificates curl gpg
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/kubernetes-apt-keyring.gpg
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/kubernetes.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; kubelet kubeadm kubectl
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-mark hold kubelet kubeadm kubectl   &lt;span class="c"&gt;# don't let an unattended upgrade skip a minor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Initialize the control plane
&lt;/h2&gt;

&lt;p&gt;Pick a pod CIDR that matches your CNI. This uses Flannel's default:&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;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.0/16

&lt;span class="c"&gt;# set up kubectl for your user&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /etc/kubernetes/admin.conf &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Install a CNI, then verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml

kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;span class="c"&gt;# STATUS should go Ready within a minute; CONTAINER-RUNTIME shows containerd://1.7.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a single-node lab, let workloads schedule on the control plane:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl taint nodes &lt;span class="nt"&gt;--all&lt;/span&gt; node-role.kubernetes.io/control-plane-
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Prove Docker images run
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create deployment web &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx:1.27
kubectl expose deployment web &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80
kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide     &lt;span class="c"&gt;# Running, on your containerd node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;nginx&lt;/code&gt; image was built by Docker. It runs on containerd without translation because it's an OCI image — which is the whole point of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it doesn't come up
&lt;/h2&gt;

&lt;p&gt;The usual first-node failures are the kubelet not starting (almost always swap or the cgroup driver), or pods stuck pending (no CNI yet). I keep the specific fixes here: &lt;a href="https://devopsaitoolkit.com/guides/kubernetes-pod-startup-errors/" rel="noopener noreferrer"&gt;Kubernetes pod-startup errors&lt;/a&gt; and the &lt;a href="https://devopsaitoolkit.com/stacks/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes troubleshooting stack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next: if you specifically need &lt;strong&gt;Docker Engine&lt;/strong&gt; as the runtime rather than bare containerd, that's &lt;code&gt;cri-dockerd&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>containerd</category>
      <category>ubuntu</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Run Kubernetes in Docker on Ubuntu for Local Development</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 26 Jul 2026 18:26:50 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/run-kubernetes-in-docker-on-ubuntu-for-local-development-a8c</link>
      <guid>https://dev.to/jjoyneriv/run-kubernetes-in-docker-on-ubuntu-for-local-development-a8c</guid>
      <description>&lt;p&gt;There's a delightfully literal answer to "Kubernetes with Docker": &lt;strong&gt;kind&lt;/strong&gt; — Kubernetes IN Docker. Each node is a Docker container running a full Kubernetes node image. On an Ubuntu workstation it gives you a real, throwaway, multi-node cluster in about 30 seconds. It's my default for local dev and for CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites on Ubuntu
&lt;/h2&gt;

&lt;p&gt;You need Docker Engine and &lt;code&gt;kubectl&lt;/code&gt;. If you don't have Docker yet:&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;sudo &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; newgrp docker   &lt;span class="c"&gt;# run docker without sudo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install &lt;code&gt;kind&lt;/code&gt; (single static binary):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ./kind &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo mv&lt;/span&gt; ./kind /usr/local/bin/kind
kind version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A one-command cluster
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; dev
kubectl cluster-info &lt;span class="nt"&gt;--context&lt;/span&gt; kind-dev
docker ps    &lt;span class="c"&gt;# you'll see a dev-control-plane container — that's your node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind&lt;/code&gt; wrote a kubeconfig context for you. Tear the whole thing down just as fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind delete cluster &lt;span class="nt"&gt;--name&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A realistic multi-node cluster
&lt;/h2&gt;

&lt;p&gt;Most bugs only show up with more than one node (scheduling, affinity, PodDisruptionBudgets). Define it in a config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kind-cluster.yaml&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
    &lt;span class="na"&gt;kubeadmConfigPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;kind: InitConfiguration&lt;/span&gt;
        &lt;span class="s"&gt;nodeRegistration:&lt;/span&gt;
          &lt;span class="s"&gt;kubeletExtraArgs:&lt;/span&gt;
            &lt;span class="s"&gt;node-labels: "ingress-ready=true"&lt;/span&gt;
    &lt;span class="na"&gt;extraPortMappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; dev &lt;span class="nt"&gt;--config&lt;/span&gt; kind-cluster.yaml
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;extraPortMappings&lt;/code&gt; bit is the trick people miss: it forwards a port from your Ubuntu host into the control-plane container, so an ingress controller inside the cluster is reachable at &lt;code&gt;http://localhost:8080&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading a locally-built image (no registry needed)
&lt;/h2&gt;

&lt;p&gt;This is &lt;code&gt;kind&lt;/code&gt;'s best feature for the Docker workflow. Build with Docker, push straight into the cluster's nodes — no registry round-trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
kind load docker-image myapp:dev &lt;span class="nt"&gt;--name&lt;/span&gt; dev

kubectl create deployment myapp &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp:dev
kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/myapp &lt;span class="nv"&gt;myapp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp:dev   &lt;span class="c"&gt;# after a rebuild + reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Set &lt;code&gt;imagePullPolicy: IfNotPresent&lt;/code&gt; (or &lt;code&gt;Never&lt;/code&gt;) in your manifest for locally-loaded images, or the kubelet will try to pull &lt;code&gt;myapp:dev&lt;/code&gt; from a registry and fail with &lt;code&gt;ImagePullBackOff&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Ubuntu-specific gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cgroup / inotify limits.&lt;/strong&gt; Big clusters on &lt;code&gt;kind&lt;/code&gt; can exhaust inotify watches. If pods crashloop with "too many open files," raise them:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_watches&lt;span class="o"&gt;=&lt;/span&gt;524288
  &lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_instances&lt;span class="o"&gt;=&lt;/span&gt;512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rootless Docker&lt;/strong&gt; works but needs cgroup v2 delegation configured; if &lt;code&gt;kind create&lt;/code&gt; hangs, test with rootful Docker first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's ephemeral by design.&lt;/strong&gt; A &lt;code&gt;kind&lt;/code&gt; node is a container — restart Docker and the cluster state is gone unless you use &lt;code&gt;extraMounts&lt;/code&gt; for persistence. That's a feature for testing, a footgun if you treated it like a server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a &lt;code&gt;kind&lt;/code&gt; pod won't start, it's the same Kubernetes failures as anywhere — &lt;code&gt;ImagePullBackOff&lt;/code&gt; from the pull-policy trap above, or &lt;code&gt;CrashLoopBackOff&lt;/code&gt; from the app itself. Fixes here: &lt;a href="https://devopsaitoolkit.com/blog/kubernetes-error-imagepullbackoff/" rel="noopener noreferrer"&gt;ImagePullBackOff&lt;/a&gt; and &lt;a href="https://devopsaitoolkit.com/blog/kubernetes-error-crashloopbackoff/" rel="noopener noreferrer"&gt;CrashLoopBackOff&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next: &lt;code&gt;minikube&lt;/code&gt; with the Docker driver — a heavier but more full-featured local option.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>ubuntu</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>minikube with the Docker Driver on Ubuntu: A Practical Local Cluster</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 25 Jul 2026 12:34:10 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/minikube-with-the-docker-driver-on-ubuntu-a-practical-local-cluster-4fn9</link>
      <guid>https://dev.to/jjoyneriv/minikube-with-the-docker-driver-on-ubuntu-a-practical-local-cluster-4fn9</guid>
      <description>&lt;p&gt;&lt;code&gt;minikube&lt;/code&gt; is the other "Kubernetes in Docker" option on Ubuntu, and with &lt;code&gt;--driver=docker&lt;/code&gt; it runs the cluster inside a Docker container just like &lt;code&gt;kind&lt;/code&gt; — but ships with addons (ingress, metrics-server, dashboard, a built-in registry) that make it feel more like a real cluster. Here's a practical setup and how it differs from &lt;code&gt;kind&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install on Ubuntu
&lt;/h2&gt;

&lt;p&gt;You need Docker first (&lt;code&gt;sudo apt-get install -y docker.io&lt;/code&gt;, then add yourself to the &lt;code&gt;docker&lt;/code&gt; group). Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
&lt;span class="nb"&gt;sudo install &lt;/span&gt;minikube /usr/local/bin/minikube
minikube version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Start with the Docker driver
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube start &lt;span class="nt"&gt;--driver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker
&lt;span class="c"&gt;# make it the default so you don't repeat the flag:&lt;/span&gt;
minikube config &lt;span class="nb"&gt;set &lt;/span&gt;driver docker

kubectl get nodes
docker ps    &lt;span class="c"&gt;# a 'minikube' container is your node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Size it for real work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube start &lt;span class="nt"&gt;--driver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8g &lt;span class="nt"&gt;--disk-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;40g
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The addons are the reason to pick minikube
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube addons list
minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;ingress
minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;metrics-server
minikube dashboard        &lt;span class="c"&gt;# opens the web UI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ingress&lt;/code&gt; gives you a working NGINX ingress controller with no manifest wrangling — genuinely useful when you want to test ingress routing locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker image workflow
&lt;/h2&gt;

&lt;p&gt;minikube runs its &lt;strong&gt;own&lt;/strong&gt; Docker daemon inside the node container. The neat trick is pointing your shell's Docker CLI at &lt;em&gt;that&lt;/em&gt; daemon, so images you build are immediately visible to the cluster with no push:&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;eval&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;minikube docker-env&lt;span class="si"&gt;)&lt;/span&gt;     &lt;span class="c"&gt;# your `docker` now talks to minikube's daemon&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
kubectl create deployment myapp &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp:dev
&lt;span class="c"&gt;# remember: imagePullPolicy: IfNotPresent so it doesn't try a registry pull&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Undo it when you're done so &lt;code&gt;docker&lt;/code&gt; points back at your host daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;minikube docker-env &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a built-in registry if you prefer the push model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;registry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Accessing services from Ubuntu
&lt;/h2&gt;

&lt;p&gt;Two common patterns:&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;# quick tunnel to a single service (prints a URL)&lt;/span&gt;
minikube service myapp &lt;span class="nt"&gt;--url&lt;/span&gt;

&lt;span class="c"&gt;# LoadBalancer support on your host (needs sudo; keep it running in a terminal)&lt;/span&gt;
minikube tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  minikube vs kind — how I choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;kind&lt;/strong&gt;: faster start, lighter, multi-node config is trivial, ideal for CI and quick throwaways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;minikube&lt;/strong&gt;: heavier, single-node by default, but addons (ingress, metrics, registry, dashboard) and the &lt;code&gt;docker-env&lt;/code&gt; trick make it nicer for interactive local development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both run "Kubernetes with Docker" in the most literal sense — the cluster &lt;em&gt;is&lt;/em&gt; a Docker container. Neither reflects how production nodes run (that's the containerd/cri-dockerd posts earlier in this series), so don't debug production runtime issues on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube stop        &lt;span class="c"&gt;# keep the cluster, free the resources&lt;/span&gt;
minikube delete      &lt;span class="c"&gt;# nuke it entirely&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a workload misbehaves on minikube it's ordinary Kubernetes troubleshooting — start with &lt;code&gt;kubectl describe pod&lt;/code&gt; and &lt;code&gt;kubectl logs&lt;/code&gt;, and the &lt;a href="https://devopsaitoolkit.com/guides/kubernetes-pod-startup-errors/" rel="noopener noreferrer"&gt;pod-startup error hub&lt;/a&gt; for the specific messages.&lt;/p&gt;

&lt;p&gt;Next: the failure mode that quietly breaks more Ubuntu nodes than anything else — the systemd cgroup driver mismatch.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>ubuntu</category>
      <category>minikube</category>
    </item>
    <item>
      <title>From Docker Build to Kubernetes Deploy on Ubuntu: The Image Workflow That Never Changed</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Wed, 22 Jul 2026 21:31:12 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/from-docker-build-to-kubernetes-deploy-on-ubuntu-the-image-workflow-that-never-changed-d4f</link>
      <guid>https://dev.to/jjoyneriv/from-docker-build-to-kubernetes-deploy-on-ubuntu-the-image-workflow-that-never-changed-d4f</guid>
      <description>&lt;p&gt;Amid all the noise about dockershim, one thing got lost: &lt;strong&gt;the everyday workflow of building an image with Docker and running it on Kubernetes never changed.&lt;/strong&gt; Docker is still an excellent build tool, Kubernetes still runs OCI images, and on Ubuntu the loop is clean. Here it is end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A build-friendly Dockerfile
&lt;/h2&gt;

&lt;p&gt;Multi-stage keeps the runtime image small and the attack surface low — this matters more on Kubernetes, where you pull the image onto every node that schedules the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# build stage&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.* ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 go build &lt;span class="nt"&gt;-o&lt;/span&gt; /out/api ./cmd/api

&lt;span class="c"&gt;# runtime stage — distroless, no shell, tiny&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/static:nonroot&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /out/api /api&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nonroot:nonroot&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/api"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Build and push with Docker on Ubuntu
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;buildx&lt;/code&gt; (bundled with modern Docker) so you can build multi-arch — worth it if any nodes are arm64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64,linux/arm64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; registry.example.com/api:1.4.2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tag with an immutable version, never rely on &lt;code&gt;:latest&lt;/code&gt;.&lt;/strong&gt; Kubernetes caches images per node; &lt;code&gt;:latest&lt;/code&gt; makes "which build is actually running?" unanswerable and breaks rollbacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A deployment that behaves in production
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&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;api&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/api:1.4.2&lt;/span&gt;   &lt;span class="c1"&gt;# the exact tag you pushed&lt;/span&gt;
          &lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;128Mi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/healthz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
          &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/healthz&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;readinessProbe&lt;/code&gt; is the piece people skip and regret: without it, Kubernetes sends traffic to a pod before your app is listening, and you get intermittent 502s during every rollout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
kubectl rollout status deployment/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Pulling from a private registry
&lt;/h2&gt;

&lt;p&gt;If your registry needs auth, the cluster needs a pull secret — this is the same regardless of whether nodes run containerd or cri-dockerd:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret docker-registry regcred &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ci &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--docker-password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REGISTRY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;imagePullSecrets&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;regcred&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss this and pods sit in &lt;code&gt;ImagePullBackOff&lt;/code&gt; with &lt;code&gt;pull access denied&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Ship a new build
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="nt"&gt;-t&lt;/span&gt; registry.example.com/api:1.4.3 &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/api &lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;registry.example.com/api:1.4.3
kubectl rollout status deployment/api
&lt;span class="c"&gt;# rollback is one command because you used immutable tags:&lt;/span&gt;
kubectl rollout undo deployment/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker&lt;/strong&gt; = how you &lt;em&gt;build&lt;/em&gt; and &lt;em&gt;push&lt;/em&gt; images. Unchanged, still great.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;containerd / cri-dockerd&lt;/strong&gt; = how the &lt;em&gt;node&lt;/em&gt; runs them. This is what dockershim's removal was about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your manifests&lt;/strong&gt; = don't care which runtime is underneath.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you keep those three separate in your head, none of the 2022 runtime drama affects your daily loop.&lt;/p&gt;

&lt;p&gt;When a rollout goes wrong it's usually the image pull or the probe: &lt;a href="https://devopsaitoolkit.com/blog/kubernetes-error-imagepullbackoff/" rel="noopener noreferrer"&gt;ImagePullBackOff&lt;/a&gt;, and the wider &lt;a href="https://devopsaitoolkit.com/guides/docker-container-and-runtime-errors/" rel="noopener noreferrer"&gt;Docker runtime error guides&lt;/a&gt; for build-side failures.&lt;/p&gt;

&lt;p&gt;Last in the series: when the node or the runtime itself is the problem — troubleshooting kubelet and containerd on Ubuntu.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>ubuntu</category>
      <category>devops</category>
    </item>
    <item>
      <title>State Encryption in OpenTofu: How It Works and How to Roll It Out</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:54:27 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/state-encryption-in-opentofu-how-it-works-and-how-to-roll-it-out-2fj2</link>
      <guid>https://dev.to/jjoyneriv/state-encryption-in-opentofu-how-it-works-and-how-to-roll-it-out-2fj2</guid>
      <description>&lt;p&gt;If you've ever &lt;code&gt;cat&lt;/code&gt;-ed a Terraform or OpenTofu state file, you already know the uncomfortable truth: it's a plaintext JSON dump of everything your infrastructure knows, including secrets. Database passwords, generated private keys, API tokens injected through providers — they all land in state, in the clear. OpenTofu is the one place where you can fix this at the source, because native state and plan encryption is a first-class OpenTofu feature that upstream Terraform does not have. Here's how it actually works and how I roll it out on existing projects without breaking them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plaintext state is a real risk
&lt;/h2&gt;

&lt;p&gt;State is not a cache you can regenerate. It's the authoritative map between your HCL and the real resources, and OpenTofu has to store the &lt;em&gt;values&lt;/em&gt; of attributes to compute diffs. That includes sensitive ones. Marking an output &lt;code&gt;sensitive = true&lt;/code&gt; only hides it from the CLI output — it's still written verbatim to state.&lt;/p&gt;

&lt;p&gt;On real infra I've seen state end up in three places it shouldn't: an S3 bucket without SSE and with overly broad read IAM, a CI artifact that got uploaded to a build cache, and a developer laptop with &lt;code&gt;terraform.tfstate&lt;/code&gt; committed to a feature branch by accident. Backend encryption (like S3 SSE) helps for one of those. It does nothing for the other two, because the moment state leaves the backend it's plaintext again.&lt;/p&gt;

&lt;p&gt;OpenTofu's encryption operates at the &lt;em&gt;data&lt;/em&gt; layer, before the bytes ever hit the backend or a local file. The state is encrypted at rest everywhere: in the backend, in local copies, in CI artifacts. That's the property I want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of the &lt;code&gt;encryption&lt;/code&gt; block
&lt;/h2&gt;

&lt;p&gt;Encryption lives in a &lt;code&gt;terraform { encryption { ... } }&lt;/code&gt; block. It has three moving parts: a &lt;strong&gt;key provider&lt;/strong&gt; (where the encryption key comes from), a &lt;strong&gt;method&lt;/strong&gt; (the actual cipher), and &lt;strong&gt;targets&lt;/strong&gt; (&lt;code&gt;state&lt;/code&gt; and/or &lt;code&gt;plan&lt;/code&gt;) that bind a method to what you want encrypted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;encryption&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt; &lt;span class="s2"&gt;"pbkdf2"&lt;/span&gt; &lt;span class="s2"&gt;"passphrase"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;passphrase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tofu_encryption_passphrase&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"aes_gcm"&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pbkdf2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passphrase&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole minimal setup. &lt;code&gt;pbkdf2&lt;/code&gt; derives a key from a passphrase, &lt;code&gt;aes_gcm&lt;/code&gt; is AES-GCM authenticated encryption, and both the &lt;code&gt;state&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; targets use it. Note the passphrase comes from a variable — never hardcode it.&lt;/p&gt;

&lt;p&gt;One caveat worth knowing (as of 2026 — check current docs): you generally can't pull the passphrase from a normal input variable defined elsewhere, because the &lt;code&gt;encryption&lt;/code&gt; block is evaluated very early, before most of the graph. In practice I feed it from the environment instead, which I'll cover below. Treat the &lt;code&gt;var.&lt;/code&gt; reference above as illustrative and prefer the env-var approach for the passphrase itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key providers: passphrase vs KMS
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;pbkdf2&lt;/code&gt; passphrase provider is the easiest to start with and the easiest to get wrong operationally. If you lose the passphrase, the state is gone — there is no recovery. It's great for a solo project or a quick proof of concept, but the passphrase becomes a secret you now have to manage carefully.&lt;/p&gt;

&lt;p&gt;For anything shared or production, I use a cloud KMS provider so the key material lives in a managed HSM-backed service and access is controlled by IAM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;encryption&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt; &lt;span class="s2"&gt;"aws_kms"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;kms_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:kms:us-east-1:111122223333:key/abcd-1234"&lt;/span&gt;
      &lt;span class="nx"&gt;region&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
      &lt;span class="nx"&gt;key_spec&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AES_256"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"aes_gcm"&lt;/span&gt; &lt;span class="s2"&gt;"kms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kms&lt;/span&gt;
      &lt;span class="nx"&gt;enforced&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kms&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are equivalent &lt;code&gt;gcp_kms&lt;/code&gt; and &lt;code&gt;openbao&lt;/code&gt;/Vault-style providers too. The pattern is identical: the &lt;code&gt;key_provider&lt;/code&gt; block changes, the &lt;code&gt;method&lt;/code&gt; and targets stay the same. With KMS, access control and audit logging come for free — I can see in CloudTrail exactly who decrypted state and when, and I can revoke a role without rotating the underlying data key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypt the plan file too
&lt;/h2&gt;

&lt;p&gt;People forget plan files. A &lt;code&gt;tofu plan -out=tfplan&lt;/code&gt; binary contains the same resource values as state, plus the proposed changes. If your CI pipeline runs &lt;code&gt;plan&lt;/code&gt; in one job and &lt;code&gt;apply&lt;/code&gt; in another, that plan artifact is passed between jobs — and it's just as sensitive as state. The &lt;code&gt;plan {}&lt;/code&gt; target above encrypts it with the same method. Do not skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out on an existing project with &lt;code&gt;fallback&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The scary part is turning encryption on when you already have unencrypted state. If you just add the &lt;code&gt;encryption&lt;/code&gt; block, the next &lt;code&gt;tofu plan&lt;/code&gt; will fail trying to decrypt state that was never encrypted. The &lt;code&gt;fallback&lt;/code&gt; block is the migration escape hatch: it tells OpenTofu "if you can't decrypt with the primary method, treat the data as unencrypted."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;encryption&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt; &lt;span class="s2"&gt;"aws_kms"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;kms_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:kms:us-east-1:111122223333:key/abcd-1234"&lt;/span&gt;
      &lt;span class="nx"&gt;region&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"aes_gcm"&lt;/span&gt; &lt;span class="s2"&gt;"kms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kms&lt;/span&gt;

      &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# no method = read plaintext state, write encrypted&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The migration is a one-time apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu plan &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tfplan
tofu apply tfplan
&lt;span class="c"&gt;# state is now written back ENCRYPTED with the primary method&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;fallback&lt;/code&gt; with no method means "read as plaintext," OpenTofu can read your old state, and because the primary method is set, it writes the new state encrypted. Run one apply, confirm the state in the backend is now ciphertext, then &lt;strong&gt;remove the &lt;code&gt;fallback&lt;/code&gt; block&lt;/strong&gt; so plaintext state can no longer be silently accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock it down with &lt;code&gt;enforced&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Once you've migrated, add &lt;code&gt;enforced = true&lt;/code&gt; on the target. This is the setting that turns encryption from optional to mandatory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;method&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kms&lt;/span&gt;
  &lt;span class="nx"&gt;enforced&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;enforced&lt;/code&gt;, OpenTofu refuses to read or write unencrypted state at all. No accidental fallback, no misconfiguration silently dropping to plaintext. On a team, this is the line that guarantees nobody's local run produces a plaintext &lt;code&gt;terraform.tfstate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key rotation
&lt;/h2&gt;

&lt;p&gt;Rotation doesn't require re-encrypting everything in one shot. A &lt;code&gt;method&lt;/code&gt; can hold a &lt;em&gt;list&lt;/em&gt; of keys; the first is used to encrypt, and all of them are tried for decryption. To rotate, add a new key provider and put it &lt;strong&gt;ahead of&lt;/strong&gt; the old one in the method's &lt;code&gt;keys&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"aes_gcm"&lt;/span&gt; &lt;span class="s2"&gt;"kms"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# new key: used for encryption&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# old key: still valid for decryption&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next apply writes state encrypted with the new key while still being able to read anything encrypted with the old one. After you've applied and confirmed all state is on the new key, drop the old provider. Same mechanism works for migrating from passphrase to KMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI and secrets handling
&lt;/h2&gt;

&lt;p&gt;For the passphrase provider, feed the secret through the environment, not a &lt;code&gt;.tfvars&lt;/code&gt; file. OpenTofu reads encryption config from env vars prefixed appropriately, and in CI I inject it as a masked secret:&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;export &lt;/span&gt;&lt;span class="nv"&gt;TF_ENCRYPTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'key_provider "pbkdf2" "passphrase" {
  passphrase = "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOFU_PASSPHRASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"
}'&lt;/span&gt;
tofu apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For KMS, there's no passphrase to leak — the CI runner just needs an IAM role that can call &lt;code&gt;kms:Decrypt&lt;/code&gt;/&lt;code&gt;kms:GenerateDataKey&lt;/code&gt; on that key, which is exactly the least-privilege boundary you want. That's the strongest argument for KMS over passphrase in a pipeline: the secret never exists as a string anywhere.&lt;/p&gt;

&lt;p&gt;When I get stuck on the migration edge cases — mixed fallback states, rotation ordering, backend quirks — I keep notes and error write-ups in my &lt;a href="https://devopsaitoolkit.com/categories/opentofu/" rel="noopener noreferrer"&gt;OpenTofu troubleshooting guides&lt;/a&gt;, because the failure messages during a half-migrated encryption rollout are not always obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Turn on encryption with a &lt;code&gt;fallback&lt;/code&gt; block, run one &lt;code&gt;tofu apply&lt;/code&gt; to migrate existing state, remove &lt;code&gt;fallback&lt;/code&gt;, then set &lt;code&gt;enforced = true&lt;/code&gt; and encrypt the &lt;code&gt;plan&lt;/code&gt; target too. Use &lt;code&gt;pbkdf2&lt;/code&gt; only for throwaway projects and reach for &lt;code&gt;aws_kms&lt;/code&gt;/&lt;code&gt;gcp_kms&lt;/code&gt; for anything real, so the key lives in a managed service with IAM and audit logging. Rotate by prepending a new key provider ahead of the old one and applying once. Plaintext state is a solved problem in OpenTofu — you just have to opt in.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>terraform</category>
      <category>security</category>
    </item>
    <item>
      <title>Migrating from Terraform to OpenTofu: A Low-Risk Playbook</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 19 Jul 2026 19:30:09 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/migrating-from-terraform-to-opentofu-a-low-risk-playbook-1g</link>
      <guid>https://dev.to/jjoyneriv/migrating-from-terraform-to-opentofu-a-low-risk-playbook-1g</guid>
      <description>&lt;p&gt;I've migrated a few real environments from Terraform to OpenTofu now, and the good news is that a careful migration is almost boring. The state format is compatible, the CLI is a near drop-in, and the whole thing can be done with a rollback path at every step. The bad news is that "almost boring" still has a couple of sharp edges, and the teams that get hurt are the ones who skip the parity check and go straight to &lt;code&gt;apply&lt;/code&gt;. Here's the calm, low-risk playbook I actually follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Know what "low-risk" means here
&lt;/h2&gt;

&lt;p&gt;The core insight that makes this safe: OpenTofu reads the same HCL and the same state file that Terraform does. A migration is not a rewrite — it's swapping which binary talks to your existing state. That means at almost every step, your rollback is just "keep using the &lt;code&gt;terraform&lt;/code&gt; binary." As long as you don't trigger a one-way-door feature (more on those later), you can walk back.&lt;/p&gt;

&lt;p&gt;So the whole strategy is: prove parity before you change anything real, change one thing at a time, and keep the old binary installed until you're confident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Check and pin your Terraform version first
&lt;/h2&gt;

&lt;p&gt;Before you touch OpenTofu, get your current setup deterministic. OpenTofu forked from the last MPL-licensed Terraform, so very old or very new Terraform configs can have edges. Find out exactly what you're running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin it. If you're not already using a version manager or a pinned CI image, do that now — you want a fixed, known-good Terraform baseline to compare against and to fall back to. Also pin your provider versions in a lockfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform providers lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A migration where both the tool version and the provider versions are floating is a migration where you can't tell what caused a diff. Lock everything down first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install &lt;code&gt;tofu&lt;/code&gt; alongside, not instead
&lt;/h2&gt;

&lt;p&gt;Install OpenTofu without removing Terraform. On a workstation or a scratch CI runner:&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;# Verify the binary is there and note the version&lt;/span&gt;
tofu version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep both &lt;code&gt;terraform&lt;/code&gt; and &lt;code&gt;tofu&lt;/code&gt; on PATH during the migration. You'll be running them back to back to compare, and having both is what makes rollback trivial. As of 2026 the install methods and current versions are in the OpenTofu docs — check them rather than trusting a version number from a blog post (including this one).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The parity check — init and plan, no apply
&lt;/h2&gt;

&lt;p&gt;This is the heart of the whole exercise. Work on a copy or a non-production workspace first. Point OpenTofu at your existing configuration and existing state, initialize, and produce a plan — but do not apply.&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;# Fresh working dir state, same backend/state as before&lt;/span&gt;
tofu init

&lt;span class="c"&gt;# The critical test: does OpenTofu see zero changes?&lt;/span&gt;
tofu plan &lt;span class="nt"&gt;-out&lt;/span&gt; tofu.plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you want to see is a clean, &lt;strong&gt;no-changes&lt;/strong&gt; plan. If OpenTofu reads your Terraform-written state and reports that nothing needs to change, you have parity. That's the green light.&lt;/p&gt;

&lt;p&gt;If the plan shows drift, stop and read it carefully before doing anything. Common causes I've hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider version differences.&lt;/strong&gt; OpenTofu resolved a slightly different provider than your pinned Terraform lockfile. Reconcile the versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registry source differences.&lt;/strong&gt; OpenTofu uses its own registry; a provider or module might resolve from a different source. Verify the provider actually publishes where OpenTofu looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A genuinely different interpretation&lt;/strong&gt; of some config. Rare, but read the diff — do not &lt;code&gt;apply&lt;/code&gt; your way past it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do this parity check per module/workspace, not once globally. State lives per-workspace, and a clean plan in one doesn't guarantee a clean plan in another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Apply once, deliberately, in a safe place
&lt;/h2&gt;

&lt;p&gt;Once you've got a clean plan in a non-prod workspace, run the apply there so OpenTofu writes state at least once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu apply tofu.plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even a no-op apply may rewrite state metadata. That's fine and expected — but it's the moment worth noting, because after OpenTofu writes state, that workspace's state has been touched by &lt;code&gt;tofu&lt;/code&gt;. Terraform can generally still read it, but this is the point where you start treating that workspace as "OpenTofu-managed." Do it somewhere you can afford to be wrong before you do it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Swap CI, one pipeline at a time
&lt;/h2&gt;

&lt;p&gt;Now change the automation. In your CI config, this is usually as small as swapping the binary and the command name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before&lt;/span&gt;
terraform init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform plan &lt;span class="nt"&gt;-out&lt;/span&gt; plan.tfout

&lt;span class="c"&gt;# After&lt;/span&gt;
tofu init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; tofu plan &lt;span class="nt"&gt;-out&lt;/span&gt; plan.tfout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roll it out per-pipeline, lowest-stakes environment first. Keep the plan-review gate in your pipeline — a human or a required approval looking at the plan output — for the first few runs on each environment. The whole point of a slow rollout is that if OpenTofu ever produces a plan you didn't expect, you catch it at plan time, not after apply.&lt;/p&gt;

&lt;p&gt;I also recommend keeping a &lt;code&gt;terraform&lt;/code&gt;-based fallback job available (even if disabled) during the transition, so reverting CI is a one-line change rather than an archaeology project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Watch for the one-way doors
&lt;/h2&gt;

&lt;p&gt;Everything above is reversible &lt;em&gt;as long as your config stays compatible with both tools&lt;/em&gt;. The way you lose your rollback is by adopting an OpenTofu-only feature. The big ones to be aware of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native state/plan encryption.&lt;/strong&gt; Once OpenTofu encrypts your state, stock Terraform can't read it. This is a feature you may &lt;em&gt;want&lt;/em&gt; — but adopt it as a deliberate, post-migration decision, not mid-migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early variable evaluation&lt;/strong&gt; in backend blocks or module sources. Configs that rely on it won't parse under Terraform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.tofu&lt;/code&gt; / &lt;code&gt;.tofu.json&lt;/code&gt; override files&lt;/strong&gt; and &lt;strong&gt;provider-defined functions&lt;/strong&gt; via the &lt;code&gt;provider::&lt;/code&gt; namespace. Both are OpenTofu-specific surface area.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My rule during the migration window: change the tool, not the config. Keep your HCL dual-compatible until every environment is on OpenTofu and stable. Only then start adopting the divergent features — and when you do, understand you're closing the door behind you. If you want more detail on the specific compatibility gotchas and error messages these features throw, I keep a running set of &lt;a href="https://devopsaitoolkit.com/categories/opentofu/" rel="noopener noreferrer"&gt;OpenTofu troubleshooting notes&lt;/a&gt; from real migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: How to actually roll back
&lt;/h2&gt;

&lt;p&gt;If something goes wrong before you've crossed a one-way door, rollback is genuinely simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Switch the binary back.&lt;/strong&gt; In CI and locally, &lt;code&gt;tofu&lt;/code&gt; becomes &lt;code&gt;terraform&lt;/code&gt; again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-init with Terraform&lt;/strong&gt; so its lockfile and provider selections are in place: &lt;code&gt;terraform init&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a plan&lt;/strong&gt; and confirm a clean, no-change result: &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore state from backup&lt;/strong&gt; only if you actually corrupted or encrypted it. This is why you keep versioned state — an S3 bucket with versioning, or whatever your backend offers, so you can retrieve the pre-migration state object.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Back up your state before you start. A cheap &lt;code&gt;tofu state pull &amp;gt; backup.tfstate&lt;/code&gt; (or the Terraform equivalent) before the first apply gives you a plain escape hatch. I've never had to use it on a careful migration, but the whole reason the migration &lt;em&gt;feels&lt;/em&gt; calm is that the backup exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Migrating from Terraform to OpenTofu is mostly a swap, not a rewrite, and the parity check is what makes it safe: prove OpenTofu reads your existing state with a clean plan before you change anything real. Pin your versions, keep both binaries installed, roll CI out one environment at a time, and don't adopt one-way-door features until you're fully migrated and stable. Do it in that order and the scariest part of the whole thing will be how uneventful it is.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>terraform</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>OpenTofu vs Terraform in 2026: What Actually Changed</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 18 Jul 2026 14:30:45 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/opentofu-vs-terraform-in-2026-what-actually-changed-2do7</link>
      <guid>https://dev.to/jjoyneriv/opentofu-vs-terraform-in-2026-what-actually-changed-2do7</guid>
      <description>&lt;p&gt;I've been running both Terraform and OpenTofu across production infra for a while now, and the number one question I still get is some version of "wait, aren't they the same thing?" The honest answer in 2026 is: they share a common ancestor and a lot of DNA, but they are no longer the same tool. Here's what actually changed, from someone who has to keep both green in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  A very short history of the fork
&lt;/h2&gt;

&lt;p&gt;If you missed the drama: HashiCorp relicensed Terraform from the MPL open-source license to the Business Source License (BSL) in 2023. The BSL is source-available but not OSI-approved open source, and it carries a use restriction aimed at competitors. A chunk of the community, backed by a group of vendors and users, forked the last MPL-licensed Terraform codebase. That fork landed under the Linux Foundation as OpenTofu.&lt;/p&gt;

&lt;p&gt;So the core distinction is governance, not features: OpenTofu is a Linux Foundation project with open governance and an MPL-2.0 license, and Terraform is a HashiCorp product under the BSL. That licensing split is the reason a lot of teams looked at OpenTofu at all — if your legal team is nervous about the BSL's use restriction, an actual open-source license is the whole ballgame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CLI and state are (mostly) drop-in compatible
&lt;/h2&gt;

&lt;p&gt;The thing that makes OpenTofu practical to adopt is that it started as a literal fork. The binary is &lt;code&gt;tofu&lt;/code&gt; instead of &lt;code&gt;terraform&lt;/code&gt;, and for a lot of everyday work it behaves identically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init
tofu plan &lt;span class="nt"&gt;-out&lt;/span&gt; plan.tfout
tofu apply plan.tfout
tofu state list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads your existing &lt;code&gt;.tf&lt;/code&gt; files, understands the same HCL, and uses the same state file format. On real infra I've pointed &lt;code&gt;tofu&lt;/code&gt; at a state file that was last touched by Terraform and had it produce a clean, no-change plan. That parity is not an accident — keeping the state format compatible is what makes migration a low-drama exercise rather than a rewrite.&lt;/p&gt;

&lt;p&gt;But "mostly compatible" is doing real work in that sentence. The two projects have been diverging since the fork, and the gap widens with every release. Treating them as interchangeable is where teams get burned, so let's talk about the divergences that actually matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real divergences in 2026
&lt;/h2&gt;

&lt;h3&gt;
  
  
  State and plan encryption, natively
&lt;/h3&gt;

&lt;p&gt;This is the feature I care about most. OpenTofu ships native state and plan encryption. You configure it directly in your OpenTofu configuration, pick a key provider (PBKDF2 with a passphrase, a cloud KMS, and so on) and a method, and OpenTofu encrypts state at rest — including the plan file, which can leak secrets just as badly as state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;encryption&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key_provider&lt;/span&gt; &lt;span class="s2"&gt;"pbkdf2"&lt;/span&gt; &lt;span class="s2"&gt;"mykey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;passphrase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;encryption_passphrase&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"aes_gcm"&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pbkdf2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mykey&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aes_gcm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Terraform world you traditionally solved this at the backend level — a KMS-encrypted S3 bucket, restrictive IAM, and hoping nobody &lt;code&gt;terraform show&lt;/code&gt;s a plan file into a CI log. OpenTofu moves encryption into the tool itself. If you handle regulated data, this alone can justify the switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Early variable evaluation
&lt;/h3&gt;

&lt;p&gt;For years the answer to "why can't I use a variable in my backend block?" was a shrug. OpenTofu added early variable evaluation, which lets you use variables (and locals) in places that used to demand static literals — most notably backend configuration and module sources. That means you can drive your backend bucket or key by variable instead of maintaining a wall of &lt;code&gt;-backend-config&lt;/code&gt; flags or partial-backend hacks.&lt;/p&gt;

&lt;p&gt;It's genuinely useful, and it's also a one-way door: a config that relies on early eval in a backend block won't parse cleanly under stock Terraform. Keep that in mind before you sprinkle it everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provider-defined functions
&lt;/h3&gt;

&lt;p&gt;Both ecosystems moved toward letting providers ship their own functions rather than waiting for the core team to add every string-munging helper. In OpenTofu you call them through the &lt;code&gt;provider::&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="err"&gt;::&lt;/span&gt;&lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="err"&gt;::&lt;/span&gt;&lt;span class="nx"&gt;arn_parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role_arn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact set of available functions depends on the provider version you're pinning, so I won't quote a catalog — check the current provider docs. The point is that the language surface is no longer frozen to whatever core ships.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;.tofu&lt;/code&gt; and &lt;code&gt;.tofu.json&lt;/code&gt; override files
&lt;/h3&gt;

&lt;p&gt;This is a small feature with big ergonomic payoff. OpenTofu recognizes &lt;code&gt;.tofu&lt;/code&gt; and &lt;code&gt;.tofu.json&lt;/code&gt; files, and it prefers them over the equivalent &lt;code&gt;.tf&lt;/code&gt;/&lt;code&gt;.tf.json&lt;/code&gt; when both exist. That gives you a clean way to keep a shared codebase that runs under both tools: keep the common config in &lt;code&gt;.tf&lt;/code&gt;, and drop OpenTofu-specific overrides in &lt;code&gt;.tofu&lt;/code&gt; files that Terraform simply ignores.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;main&lt;/span&gt;.&lt;span class="n"&gt;tf&lt;/span&gt;          &lt;span class="c"&gt;# shared, runs under both
&lt;/span&gt;&lt;span class="n"&gt;backend&lt;/span&gt;.&lt;span class="n"&gt;tofu&lt;/span&gt;     &lt;span class="c"&gt;# OpenTofu-only overrides, invisible to terraform
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're maintaining a module that needs to support both tools during a transition, this is the mechanism that keeps you sane.&lt;/p&gt;

&lt;h3&gt;
  
  
  The registry
&lt;/h3&gt;

&lt;p&gt;OpenTofu runs its own provider and module registry rather than depending on HashiCorp's. In practice most of the popular providers are mirrored and resolve fine, but the source of truth is different, and provider/module availability is something to actually verify rather than assume. If you have a niche or internal provider, confirm it publishes where OpenTofu looks for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on version numbers
&lt;/h2&gt;

&lt;p&gt;I'm deliberately not quoting exact version numbers or "OpenTofu is X% faster" benchmarks, because those age badly and half the ones you'll see online are made up. As of 2026 both projects are shipping regularly and the feature sets keep moving — treat any specific version claim (including mine) as something to verify against the current docs before you build a decision on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which do you pick, and should you switch?
&lt;/h2&gt;

&lt;p&gt;Here's my honest take.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're starting green today&lt;/strong&gt;, I'd default to OpenTofu. You get a real open-source license, native state/plan encryption, and the divergent features are mostly additive quality-of-life wins. The compatibility story means you lose almost nothing by choosing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're an existing Terraform shop&lt;/strong&gt;, the calculus is about your actual pain. Switch if the BSL license is a genuine legal or procurement problem, or if native state encryption solves a compliance requirement you're currently duct-taping. Don't switch just to be on the trendy side of a fork — a migration is still real work and real risk, even when it's low-risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you depend on Terraform Cloud / HCP-specific features&lt;/strong&gt; or a paid workflow tightly coupled to HashiCorp's platform, weigh that integration honestly. OpenTofu is the engine, not the whole platform, and you'll be assembling backend, state, and workflow pieces yourself or via third-party platforms.&lt;/p&gt;

&lt;p&gt;Whatever you choose, the one thing I'd avoid is drifting into using divergent features by accident and then being surprised you can't go back. If you want a cross-referenced dive into the specific errors and edge cases I hit while running OpenTofu on real clusters, I keep &lt;a href="https://devopsaitoolkit.com/categories/opentofu/" rel="noopener noreferrer"&gt;my OpenTofu troubleshooting guides&lt;/a&gt; updated as I trip over new ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;OpenTofu and Terraform are close cousins, not twins. The compatibility is real enough that adoption is cheap, but the divergences — state/plan encryption, early evaluation, provider-defined functions, &lt;code&gt;.tofu&lt;/code&gt; overrides, and a separate registry — are real enough that you should choose deliberately and know which one-way doors you're walking through. Pick based on your license posture and your compliance needs, pin your versions, and read the current docs before betting on any specific feature.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>terraform</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The OpenTofu Errors You'll Actually Hit (and How to Fix Them Fast)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Fri, 17 Jul 2026 23:14:11 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/the-opentofu-errors-youll-actually-hit-and-how-to-fix-them-fast-3f9e</link>
      <guid>https://dev.to/jjoyneriv/the-opentofu-errors-youll-actually-hit-and-how-to-fix-them-fast-3f9e</guid>
      <description>&lt;p&gt;Every OpenTofu user hits the same wall of errors eventually, usually at the worst possible moment — mid-deploy, in CI, with a teammate waiting. After enough &lt;code&gt;tofu apply&lt;/code&gt; runs on real infra I've learned that most of these have a fast, deterministic fix once you recognize the message. This is the field guide I wish I'd had: the errors you'll actually see and the shortest path out of each.&lt;/p&gt;

&lt;p&gt;The CLI is &lt;code&gt;tofu&lt;/code&gt; (OpenTofu is the Linux Foundation fork of Terraform), but almost all of these apply identically to &lt;code&gt;terraform&lt;/code&gt; if you're still on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. State lock: "Error acquiring the state lock"
&lt;/h2&gt;

&lt;p&gt;You'll see a &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; or a lock ID dump. It means a previous run died without releasing the lock, or someone is genuinely running apply right now.&lt;/p&gt;

&lt;p&gt;First, make sure nobody is actually applying. Then force-unlock with the ID from the error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu force-unlock 1a2b3c4d-5e6f-7890-abcd-ef1234567890
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not reach for &lt;code&gt;-force&lt;/code&gt; flags or delete the DynamoDB lock item by hand unless &lt;code&gt;force-unlock&lt;/code&gt; refuses. Ninety percent of the time a stale lock from a crashed CI job is the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Provider checksum mismatch / dependency lock
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: registered checksum for provider ... does not match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; was generated on one platform (say, macOS arm64) and CI runs on linux amd64, so the recorded hashes don't cover the platform being used. The fix is to record hashes for all the platforms your team and CI use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu providers lock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux_amd64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;darwin_arm64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux_arm64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit the updated &lt;code&gt;.terraform.lock.hcl&lt;/code&gt;. If you legitimately upgraded a provider and want to accept the new hash, &lt;code&gt;tofu init -upgrade&lt;/code&gt; regenerates it. Never delete the lock file to "fix" this — that just moves the problem to the next person.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "Backend initialization required, please run tofu init"
&lt;/h2&gt;

&lt;p&gt;The backend config changed, a new module was added, or you're in a fresh checkout. This is not a real error, just an uninitialized working directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you &lt;em&gt;changed&lt;/em&gt; backends (e.g. local to S3), you'll need to migrate state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init &lt;span class="nt"&gt;-migrate-state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if init complains the backend config differs from what's cached and you want to blow away the cached backend, &lt;code&gt;tofu init -reconfigure&lt;/code&gt;. Use &lt;code&gt;-migrate-state&lt;/code&gt; when you want to keep state, &lt;code&gt;-reconfigure&lt;/code&gt; when you want to point at a fresh one.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. "Invalid for_each argument" — unknown values
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Error&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Invalid&lt;/span&gt; &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="nx"&gt;argument&lt;/span&gt;
&lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="s2"&gt;"for_each"&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="nx"&gt;depends&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="nx"&gt;attributes&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;cannot&lt;/span&gt; &lt;span class="nx"&gt;be&lt;/span&gt;
&lt;span class="nx"&gt;determined&lt;/span&gt; &lt;span class="nx"&gt;until&lt;/span&gt; &lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most common structural error I see. &lt;code&gt;for_each&lt;/code&gt; needs to know its &lt;em&gt;keys&lt;/em&gt; at plan time, but you fed it a value that only exists after another resource is created — an ARN, a generated ID, a computed name.&lt;/p&gt;

&lt;p&gt;The fix is to key the map on something static. Use the input you control, not the computed output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD: keys depend on a created resource's attribute&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"r"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# GOOD: key on the static input you already know&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"r"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subnet_defs&lt;/span&gt;   &lt;span class="c1"&gt;# a map you defined&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you truly can't avoid it, a &lt;code&gt;-target&lt;/code&gt;ed apply to create the upstream resource first, then a normal apply, is the escape hatch — but restructuring the keys is the real fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Unsupported argument" / "Unsupported attribute"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Error&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Unsupported&lt;/span&gt; &lt;span class="nx"&gt;argument&lt;/span&gt;
&lt;span class="nx"&gt;An&lt;/span&gt; &lt;span class="nx"&gt;argument&lt;/span&gt; &lt;span class="nx"&gt;named&lt;/span&gt; &lt;span class="s2"&gt;"foo"&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="nx"&gt;here&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two usual causes. Either the provider version changed and renamed/removed the argument, or you're referencing an attribute that doesn't exist on that resource. Check the exact version you have and its docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu version
tofu providers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a provider upgrade renamed things, pin the version you were on while you migrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 5.40"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For "Unsupported attribute," run &lt;code&gt;tofu state show &amp;lt;address&amp;gt;&lt;/code&gt; on the resource to see exactly which attributes it actually exposes — I've wasted real time guessing at attribute names that the provider simply renamed between versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Dependency cycle
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Error&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cycle&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_x&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;foo&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_y&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two resources (or modules) reference each other, directly or through a chain, so OpenTofu can't order them. Visualize it instead of squinting at HCL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu graph | dot &lt;span class="nt"&gt;-Tsvg&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; graph.svg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to break the loop. Usually one of the two references can be replaced with a static value, moved into a separate resource (like an &lt;code&gt;aws_security_group_rule&lt;/code&gt; broken out of the group), or resolved by passing a value in as a variable rather than reading it back. Self-referential security groups are the classic offender — split the ingress rule into its own resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. "Provider produced inconsistent final plan"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Error&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="nx"&gt;produced&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;inconsistent&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;
&lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="nx"&gt;produced&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;invalid&lt;/span&gt; &lt;span class="nx"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;some_attr&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a provider bug, not your HCL — the value the provider promised at plan time didn't match apply time. It's rarely something you can fix in config directly. Fastest mitigations, in order:&lt;/p&gt;

&lt;p&gt;First, upgrade the provider — these are frequently patched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init &lt;span class="nt"&gt;-upgrade&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that doesn't help, tell OpenTofu to stop tracking the flapping computed attribute with &lt;code&gt;ignore_changes&lt;/code&gt;, or drop &lt;code&gt;lifecycle { ignore_changes = [some_attr] }&lt;/code&gt; on the resource. As a last resort, &lt;code&gt;tofu apply -replace=&amp;lt;address&amp;gt;&lt;/code&gt; forces a clean recreate so the provider computes the attribute fresh. If it persists, it's worth an upstream issue with the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Registry service discovery failure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Failed to query available provider packages
could not connect to registry.opentofu.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Network, proxy, or registry outage. First confirm it's reachable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://registry.opentofu.org/.well-known/terraform.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're behind a corporate proxy, set &lt;code&gt;HTTPS_PROXY&lt;/code&gt; and &lt;code&gt;NO_PROXY&lt;/code&gt; before init. If the public registry is flaky or you need reproducible CI, configure a provider mirror and point OpenTofu at local packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu providers mirror ./tofu-mirror
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference that directory with a &lt;code&gt;provider_installation { filesystem_mirror { ... } }&lt;/code&gt; block in your CLI config. A mirror also inoculates you against the next registry hiccup, which is why every serious CI pipeline I run has one.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the message isn't in this list
&lt;/h2&gt;

&lt;p&gt;Some errors are stack- or provider-specific and don't have a one-line fix. When I hit one that isn't obvious, I check my &lt;a href="https://devopsaitoolkit.com/categories/opentofu/" rel="noopener noreferrer"&gt;OpenTofu error library&lt;/a&gt; for the exact message before I start guessing, because reading the actual failure text carefully almost always beats trial-and-error re-applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Most &lt;code&gt;tofu&lt;/code&gt; errors fall into a handful of buckets: locking (&lt;code&gt;force-unlock&lt;/code&gt;), lock-file/checksum drift (&lt;code&gt;providers lock&lt;/code&gt; with all platforms), uninitialized dirs (&lt;code&gt;init&lt;/code&gt; / &lt;code&gt;-migrate-state&lt;/code&gt;), unknown-value &lt;code&gt;for_each&lt;/code&gt; (key on static inputs), version-renamed arguments (pin and read &lt;code&gt;state show&lt;/code&gt;), cycles (&lt;code&gt;tofu graph&lt;/code&gt;, then break the loop), provider plan bugs (&lt;code&gt;init -upgrade&lt;/code&gt; / &lt;code&gt;-replace&lt;/code&gt;), and registry outages (mirror). Learn to recognize the message and the fix is usually one command away. Keep a mirror and a pinned lock file and you'll pre-empt half of these before they ever fire.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>terraform</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 12 DevOps Errors That Page Teams Most (And the First Thing to Check)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:58:06 +0000</pubDate>
      <link>https://dev.to/devopsaitoolkit/the-12-devops-errors-that-page-teams-most-and-the-first-thing-to-check-hll</link>
      <guid>https://dev.to/devopsaitoolkit/the-12-devops-errors-that-page-teams-most-and-the-first-thing-to-check-hll</guid>
      <description>&lt;p&gt;Over the last while I've been cataloguing production DevOps errors — the exact strings that show up in logs at 2 a.m. — and writing a fix for each one. A pattern jumps out fast: a small number of errors account for a huge share of the pages. Here are the twelve that come up most, with the one-thing-to-check-first for each.&lt;/p&gt;

&lt;p&gt;None of these are exotic. That's the point. The stuff that actually pages you is rarely exotic — it's the same dozen failure modes wearing different hats.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;CrashLoopBackOff&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The pod started, died, and Kubernetes is now backing off between restarts. &lt;code&gt;CrashLoopBackOff&lt;/code&gt; is a &lt;em&gt;symptom&lt;/em&gt;, never a cause. Go straight to &lt;code&gt;kubectl logs &amp;lt;pod&amp;gt; --previous&lt;/code&gt; — the logs from the crashed container are where the real error lives. Nine times out of ten it's a bad config value, a missing env var, or a failed migration on startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;ImagePullBackOff&lt;/code&gt; / &lt;code&gt;ErrImagePull&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Kubernetes can't pull the image. Don't guess — &lt;code&gt;kubectl describe pod&lt;/code&gt; spells it out in Events. It's almost always a typo in the tag, a missing &lt;code&gt;imagePullSecret&lt;/code&gt;, or a registry rate limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;OOMKilled&lt;/code&gt; (exit code 137)
&lt;/h2&gt;

&lt;p&gt;This is &lt;em&gt;not&lt;/em&gt; "the node ran out of memory." It's "this container hit &lt;strong&gt;its own&lt;/strong&gt; cgroup memory limit and the kernel killed it." Different problem, different fix. Compare the pod's &lt;code&gt;resources.limits.memory&lt;/code&gt; against what it actually uses (&lt;code&gt;kubectl top pod&lt;/code&gt;) before you touch anything at the node level.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;No space left on device&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The classic — and the trap is when &lt;code&gt;df -h&lt;/code&gt; shows free space anyway. Then it's one of two things: you're out of &lt;strong&gt;inodes&lt;/strong&gt; (&lt;code&gt;df -i&lt;/code&gt;), or a process is holding a &lt;strong&gt;deleted-but-still-open&lt;/strong&gt; file (&lt;code&gt;lsof +L1&lt;/code&gt;). &lt;code&gt;rm&lt;/code&gt; won't reclaim that space until you restart the process holding the file descriptor.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. DNS timeouts inside pods
&lt;/h2&gt;

&lt;p&gt;An external lookup that works from the node but intermittently times out inside a pod is almost always the &lt;code&gt;ndots:5&lt;/code&gt; search-domain cascade colliding with a conntrack UDP race — you get a flat 5-second stall that blows your client timeout. Overriding &lt;code&gt;ndots&lt;/code&gt; on the pod spec and running NodeLocal DNSCache is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; (Postgres)
&lt;/h2&gt;

&lt;p&gt;Bumping &lt;code&gt;max_connections&lt;/code&gt; is the trap, not the fix — each connection costs real memory. You need a pooler (PgBouncer), not 500 backend processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. &lt;code&gt;Connection refused&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Something reached the host and nothing was listening on that port. It's rarely DNS or the network — it's the service being down, bound to &lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;0.0.0.0&lt;/code&gt;, or a firewall. &lt;code&gt;ss -tlnp&lt;/code&gt; on the target tells you in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. &lt;code&gt;TLS handshake timeout&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Usually not a cert problem at all — it's a network path problem (MTU, a proxy, or a firewall silently dropping the handshake) masquerading as TLS. Test raw connectivity first with &lt;code&gt;openssl s_client -connect host:443&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. &lt;code&gt;Read-only file system&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A filesystem that was mounted read-write and is suddenly read-only almost always means the kernel remounted it &lt;code&gt;ro&lt;/code&gt; after detecting I/O errors. Check &lt;code&gt;dmesg&lt;/code&gt; — you may be looking at a failing disk, not a permissions issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. &lt;code&gt;Multi-Attach error for volume&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;ReadWriteOnce&lt;/code&gt; volume can attach to exactly one &lt;strong&gt;node&lt;/strong&gt; at a time — not one pod, one node. If a node goes &lt;code&gt;NotReady&lt;/code&gt; with the volume still attached, a pod rescheduled elsewhere gets this error. Kubernetes waits ~6 minutes before force-detaching &lt;em&gt;on purpose&lt;/em&gt; — to protect your data from being written by two hosts at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. &lt;code&gt;502 Bad Gateway&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;502 means your proxy reached the upstream and the upstream said no (or died). It's rarely the proxy. &lt;code&gt;connect() failed (111: Connection refused)&lt;/code&gt; in the NGINX error log → your app isn't listening where the proxy thinks it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. &lt;code&gt;exec format error&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You built an image for one CPU architecture and ran it on another (hello, Apple Silicon → x86 clusters). Build multi-arch, or match your &lt;code&gt;--platform&lt;/code&gt;.&lt;/p&gt;




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

&lt;p&gt;Every one of these has the same shape: the error message describes the &lt;em&gt;symptom&lt;/em&gt; the system noticed, not the &lt;em&gt;cause&lt;/em&gt; you need to fix. &lt;code&gt;CrashLoopBackOff&lt;/code&gt; isn't why your pod is dying. &lt;code&gt;OOMKilled&lt;/code&gt; isn't the node. The skill isn't memorizing fixes — it's knowing which single command turns the symptom back into a cause.&lt;/p&gt;

&lt;p&gt;I keep a full, searchable library of these — every error above has a complete guide with the diagnostic workflow, an example root-cause analysis, and the prevention checklist. If you want the deeper version of any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Kubernetes ones live in the &lt;a href="https://devopsaitoolkit.com/stacks/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes troubleshooting toolkit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Everything else is in the &lt;a href="https://devopsaitoolkit.com/blog/" rel="noopener noreferrer"&gt;full error-guide library&lt;/a&gt; (Linux, Postgres, Docker, NGINX, and more)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the error that pages &lt;em&gt;your&lt;/em&gt; team most? Curious whether it's on this list or something I should go write up next.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>sre</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built Free Browser-Based Validators for YAML, Kubernetes and Terraform (No Upload, No Signup)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:32:55 +0000</pubDate>
      <link>https://dev.to/devopsaitoolkit/i-built-free-browser-based-validators-for-yaml-kubernetes-and-terraform-no-upload-no-signup-57ka</link>
      <guid>https://dev.to/devopsaitoolkit/i-built-free-browser-based-validators-for-yaml-kubernetes-and-terraform-no-upload-no-signup-57ka</guid>
      <description>&lt;p&gt;Every DevOps engineer has done this dance: you've got a chunk of YAML or a Terraform file that &lt;em&gt;looks&lt;/em&gt; right, something's rejecting it, and you want a fast sanity check. So you paste it into some random online validator — and a small voice asks, &lt;em&gt;wait, where did that config just go?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That config often has structure, comments, sometimes internal hostnames or resource names in it. Pasting infrastructure definitions into an unknown server is a habit worth breaking. So I built a set of validators that never send your config anywhere — they run entirely in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they are
&lt;/h2&gt;

&lt;p&gt;Free, browser-based validators for the formats DevOps folks paste-and-pray most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;YAML&lt;/strong&gt; — catches the indentation and structure errors that make Kubernetes and CI configs fail with cryptic messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes manifests&lt;/strong&gt; — schema-aware checks beyond "is it valid YAML," so you catch the wrong &lt;code&gt;apiVersion&lt;/code&gt; or a misplaced field before &lt;code&gt;kubectl apply&lt;/code&gt; does&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform / HCL&lt;/strong&gt; — structural validation for the syntax slips that &lt;code&gt;terraform validate&lt;/code&gt; flags only after you've context-switched away&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one design decision that matters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;100% client-side.&lt;/strong&gt; No upload, no signup, no server round-trip. Your config is parsed by JavaScript running in your own tab — it never leaves your machine. You can literally open dev-tools, watch the network panel, and see nothing go out. Turn off your wifi and they still work.&lt;/p&gt;

&lt;p&gt;This isn't a privacy gimmick — it's the correct architecture for a tool that handles infrastructure definitions. A validator has no business seeing your config on a server it doesn't need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I bother
&lt;/h2&gt;

&lt;p&gt;Two reasons, honestly.&lt;/p&gt;

&lt;p&gt;One: I kept wanting this exact thing and kept not trusting the options. The nth time I hesitated before pasting a manifest into a stranger's website, I decided to just build the version I'd trust.&lt;/p&gt;

&lt;p&gt;Two: fast feedback loops are the whole game in this job. The gap between "save the file" and "find out it's malformed" is pure friction — and the tighter that loop, the less of your working memory it burns. A validator that's one tab away and gives an answer in milliseconds is a small thing that compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://devopsaitoolkit.com/validators/" rel="noopener noreferrer"&gt;validator workbench&lt;/a&gt; — YAML, Kubernetes, and Terraform, all client-side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're the kind of person who'd rather script it, a lot of the underlying tooling is open source — CLIs and a small read-only API for the prompt and error-guide data — over on the &lt;a href="https://devopsaitoolkit.com/developers/" rel="noopener noreferrer"&gt;developer page&lt;/a&gt; and the &lt;a href="https://github.com/devopsaitoolkit" rel="noopener noreferrer"&gt;GitHub org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Client-side tools have real limits — they can't know your cluster's live state, and schema validation isn't the same as a policy check. But for the "did I just fat-finger the indentation" question, having the answer without a network request is exactly the trade I want.&lt;/p&gt;

&lt;p&gt;What config format do you most wish had a trustworthy, offline, no-signup validator? That's genuinely how I decide what to build next.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>yaml</category>
      <category>opensource</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Fix Docker Exit Code 137 (OOMKilled): Why It Happens and How to Stop It</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Tue, 14 Jul 2026 03:21:09 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/fix-docker-exit-code-137-oomkilled-why-it-happens-and-how-to-stop-it-4ipf</link>
      <guid>https://dev.to/jjoyneriv/fix-docker-exit-code-137-oomkilled-why-it-happens-and-how-to-stop-it-4ipf</guid>
      <description>&lt;p&gt;Your container died and &lt;code&gt;docker ps -a&lt;/code&gt; shows something like &lt;code&gt;Exited (137) 4 minutes ago&lt;/code&gt;. Nine times out of ten that's the kernel's OOM killer, not your app crashing on its own. Here's what exit code 137 actually means and how I go about stopping it from happening again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exit code 137 actually means
&lt;/h2&gt;

&lt;p&gt;Exit code 137 is &lt;code&gt;128 + 9&lt;/code&gt;. The &lt;code&gt;128 +&lt;/code&gt; part is the shell convention for "terminated by a signal," and &lt;code&gt;9&lt;/code&gt; is &lt;code&gt;SIGKILL&lt;/code&gt;. So 137 means your process was hard-killed — no chance to clean up, no graceful shutdown.&lt;/p&gt;

&lt;p&gt;Two things commonly send that &lt;code&gt;SIGKILL&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;kernel OOM killer&lt;/strong&gt;. Your container hit its memory cgroup limit, or the whole host ran out of RAM, and the kernel picked a process to kill to stay alive.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;code&gt;docker stop&lt;/code&gt; that timed out&lt;/strong&gt;. Docker sends &lt;code&gt;SIGTERM&lt;/code&gt;, waits (10s by default), and if the process is still alive it escalates to &lt;code&gt;SIGKILL&lt;/code&gt;. That also produces 137.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both look identical in &lt;code&gt;docker ps -a&lt;/code&gt;. The rest of this is about the first case — OOMKilled — because that's the one that quietly recurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm it was OOM, not something else
&lt;/h2&gt;

&lt;p&gt;Before changing anything, confirm the cause. Docker records whether the OOM killer was involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect my-service &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.OOMKilled}} {{.State.ExitCode}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints &lt;code&gt;true 137&lt;/code&gt;, you're done guessing — it was OOM. If it prints &lt;code&gt;false 137&lt;/code&gt;, the &lt;code&gt;SIGKILL&lt;/code&gt; came from somewhere else (most often a &lt;code&gt;docker stop&lt;/code&gt; timeout).&lt;/p&gt;

&lt;p&gt;For the fuller picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect my-service &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .State}}'&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also confirm from the kernel side. The OOM killer logs every kill:&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;sudo &lt;/span&gt;dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'out of memory|oom-kill|killed process'&lt;/span&gt;
&lt;span class="c"&gt;# or, on a systemd host:&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'out of memory|oom-kill'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're looking for a line like &lt;code&gt;Out of memory: Killed process 12345 (java)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One distinction that changes your fix: did you hit &lt;strong&gt;the container's own &lt;code&gt;--memory&lt;/code&gt; limit&lt;/strong&gt;, or did &lt;strong&gt;the whole host run out of RAM&lt;/strong&gt;? If &lt;code&gt;OOMKilled&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; but the host has plenty of free memory, the container hit its own cgroup limit. If the host itself was starved, the kernel may kill the biggest process regardless of which container it's in — sometimes an innocent bystander. &lt;code&gt;dmesg&lt;/code&gt; shows the cgroup and total-vm in the kill line, which tells you which case you're in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;A few root causes cover most of what I see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;--memory&lt;/code&gt; limit at all.&lt;/strong&gt; The container can grow until the host is exhausted. This is the one that takes down neighbours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A limit that's just too low&lt;/strong&gt; for the real working set. The app was always going to need ~400 MB and you capped it at 256 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real leak.&lt;/strong&gt; Memory climbs steadily under load and never comes back down. A limit only changes &lt;em&gt;when&lt;/em&gt; it dies, not &lt;em&gt;whether&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A runtime that ignores the cgroup limit.&lt;/strong&gt; This is the classic. An old JVM sees the &lt;em&gt;host's&lt;/em&gt; total RAM, sizes its heap for that, and blows past the container limit. Node has a similar story — its old-space heap defaults to roughly 1.5–2 GB regardless of the container limit unless you tell it otherwise with &lt;code&gt;--max-old-space-size&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A batch job that spikes.&lt;/strong&gt; Steady-state memory is fine, but one large request or a big file load briefly doubles it and trips the limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The runtime-unaware case is worth dwelling on because it surprises people: the container limit and the runtime's idea of "how much memory exists" are two different numbers, and if the runtime's number is bigger, it will happily allocate its way into an OOM kill.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd diagnose it (in order)
&lt;/h2&gt;

&lt;p&gt;Cheapest, least invasive first. I don't reach for a profiler until the simple checks rule things out.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch live usage against the limit.&lt;/strong&gt; &lt;code&gt;docker stats&lt;/code&gt; shows current memory and the limit side by side:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt; my-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;MEM USAGE / LIMIT&lt;/code&gt; reads &lt;code&gt;254MiB / 256MiB&lt;/code&gt; right before it dies, you're pegged at the limit — that's your answer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce and watch it climb.&lt;/strong&gt; Leave &lt;code&gt;docker stats&lt;/code&gt; streaming (drop &lt;code&gt;--no-stream&lt;/code&gt;) while you drive load. Steady climb that never recedes points at a leak; a sharp spike on one operation points at a batch/request problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the configured limit.&lt;/strong&gt; Confirm what the container was actually given:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   docker inspect my-service &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'mem={{.HostConfig.Memory}} memswap={{.HostConfig.MemorySwap}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0&lt;/code&gt; means no limit. Otherwise it's bytes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the app-level heap settings.&lt;/strong&gt; Look at how the runtime was told to size itself — JVM flags, &lt;code&gt;NODE_OPTIONS&lt;/code&gt;, whatever applies. Mismatch between this and the container limit is a common culprit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only now, a profiler.&lt;/strong&gt; If usage is legitimately high and you need to know &lt;em&gt;what's&lt;/em&gt; holding memory, attach the language's heap profiler. This is the expensive step, so I earn my way to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Say I've got a JVM service running with a tight limit:&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-service &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256m my-registry/my-service:1.4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It OOMs under load. &lt;code&gt;docker inspect&lt;/code&gt; confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect my-service &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.OOMKilled}} {{.State.ExitCode}}'&lt;/span&gt;
&lt;span class="c"&gt;# true 137&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; shows it pinned at the limit before each death. The problem is twofold: the limit is a bit low for the real working set, &lt;em&gt;and&lt;/em&gt; the JVM isn't sizing its heap to the container.&lt;/p&gt;

&lt;p&gt;First, give it a limit that reflects reality. I measured the steady-state working set at around 350 MB, so I'll allow headroom for the JVM's non-heap overhead (metaspace, thread stacks, off-heap buffers) on top of the heap:&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-XX:MaxRAMPercentage=70.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  my-registry/my-service:1.4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-XX:MaxRAMPercentage=70.0&lt;/code&gt; tells the JVM to cap its heap at 70% of the &lt;em&gt;container&lt;/em&gt; limit — leaving the other 30% for non-heap memory so the process total stays under 512 MB. On JDK 10+ container support (&lt;code&gt;-XX:+UseContainerSupport&lt;/code&gt;) is on by default, so the JVM reads the cgroup limit rather than the host's RAM. On older JVMs you'd set an explicit &lt;code&gt;-Xmx&lt;/code&gt; instead, but percentage-based is more robust across environments.&lt;/p&gt;

&lt;p&gt;For a Node service the equivalent is bounding the old-space heap under the limit:&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-worker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--max-old-space-size=384"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  my-registry/my-worker:2.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;384&lt;/code&gt; (MB) sits comfortably under the 512 MB container limit, leaving room for Node's other allocations.&lt;/p&gt;

&lt;p&gt;The pattern in both cases: pick the container limit from measured usage plus headroom, then tell the runtime to keep its heap &lt;em&gt;under&lt;/em&gt; that limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to watch out for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Setting the limit too low just to "cap" it.&lt;/strong&gt; If the app has a leak, a tight limit doesn't fix the leak — it converts a slow degradation into a fast crash loop. You've made it more visible, not healthier. Fine as a deliberate blast-radius guard; not fine as a substitute for fixing the leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No limit at all.&lt;/strong&gt; One unbounded container can consume the host and get &lt;em&gt;other&lt;/em&gt; containers OOM-killed. The victim in &lt;code&gt;dmesg&lt;/code&gt; may be a service that did nothing wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap accounting.&lt;/strong&gt; &lt;code&gt;--memory&lt;/code&gt; and &lt;code&gt;--memory-swap&lt;/code&gt; are different knobs. If you set &lt;code&gt;--memory=512m&lt;/code&gt; and leave swap unset, Docker may allow up to twice the memory in swap, which masks the real usage — the container limps along swapping instead of failing cleanly. Set &lt;code&gt;--memory-swap&lt;/code&gt; equal to &lt;code&gt;--memory&lt;/code&gt; to disable swap for that container when you want hard, predictable behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measuring the wrong number.&lt;/strong&gt; &lt;code&gt;MEM USAGE&lt;/code&gt; in &lt;code&gt;docker stats&lt;/code&gt; includes page cache, which can make usage look scarier than the actual anonymous (unreclaimable) memory that drives OOM decisions. Watch the trend and the kill line in &lt;code&gt;dmesg&lt;/code&gt; rather than a single snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Making it repeatable
&lt;/h2&gt;

&lt;p&gt;To stop this being a recurring surprise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Right-size from data.&lt;/strong&gt; Use &lt;code&gt;docker stats&lt;/code&gt; or your metrics stack to find the real working set under load, then set &lt;code&gt;--memory&lt;/code&gt; to that plus honest headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the limit and the runtime heap together.&lt;/strong&gt; Set &lt;code&gt;--memory&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; a matching runtime flag (&lt;code&gt;-XX:MaxRAMPercentage&lt;/code&gt;, &lt;code&gt;--max-old-space-size&lt;/code&gt;, etc.) so the two numbers can't drift apart.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;my-service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-registry/my-service:1.4.2&lt;/span&gt;
      &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512m&lt;/span&gt;
      &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;on-failure&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-XX:MaxRAMPercentage=70.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a restart policy and an alert.&lt;/strong&gt; &lt;code&gt;restart: on-failure&lt;/code&gt; keeps you online through a transient spike, and an alert on &lt;code&gt;OOMKilled&lt;/code&gt; events or restart count means you hear about it before your users do. Be honest about the restart policy though: it buys time, it does not fix a leak. A container that restarts every ten minutes is telling you something you shouldn't silence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather not reassemble all of this under pressure the next time a container flaps, I keep the reusable Docker patterns — limits, healthchecks, restart policies — as a &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;reference set of Docker runbook patterns&lt;/a&gt; so it's a lookup, not an investigation.&lt;/p&gt;

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

&lt;p&gt;Exit code 137 is almost always a memory conversation, not a crash. The durable fix isn't a bigger number — it's making the container limit and the runtime's own idea of "available memory" agree, sized from what the app actually uses. Get those two to match and 137 stops being a mystery.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>sre</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
