<?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>DNS Troubleshooting with dig: The Commands DevOps Engineers Actually Need</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Wed, 19 Aug 2026 12:41:16 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/dns-troubleshooting-with-dig-the-commands-devops-engineers-actually-need-19b0</link>
      <guid>https://dev.to/jjoyneriv/dns-troubleshooting-with-dig-the-commands-devops-engineers-actually-need-19b0</guid>
      <description>&lt;p&gt;A surprising share of "the app is down" pages resolve to a name-resolution problem, not a broken service. The service is fine; the client can't turn a name into an address. &lt;code&gt;dig&lt;/code&gt; is the precision tool for proving that in seconds instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think about it as a resolution chain, not "is DNS broken"
&lt;/h2&gt;

&lt;p&gt;When a name fails, work the chain: which resolver did the client ask, what did that resolver return, and does it match what authoritative DNS actually says? Most incidents live in the gap between those three. The method is boring and reliable: &lt;strong&gt;observe the symptom, form a hypothesis about where in the chain it breaks, test with one query, read the evidence, fix, then validate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The single most important habit: &lt;strong&gt;query the name from the same host and the same resolver the app uses.&lt;/strong&gt; Running &lt;code&gt;dig&lt;/code&gt; from your laptop proves nothing about what the pod or VM sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The record types worth knowing
&lt;/h2&gt;

&lt;p&gt;You don't need all of them, but you need to recognize them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A / AAAA&lt;/strong&gt; — name to IPv4 / IPv6 address. The usual suspect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNAME&lt;/strong&gt; — an alias pointing at another name. A stale or wrong CNAME sends traffic somewhere unexpected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX&lt;/strong&gt; — mail routing. &lt;strong&gt;TXT&lt;/strong&gt; — SPF, DKIM, domain verification, and other metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NS&lt;/strong&gt; — which servers are authoritative for a zone. &lt;strong&gt;SOA&lt;/strong&gt; — the zone's serial and TTL defaults; the serial tells you whether a change has propagated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PTR&lt;/strong&gt; — reverse lookup, IP back to name.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The commands that actually earn their place
&lt;/h2&gt;

&lt;p&gt;Start with the quick answer, then get precise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short api.internal.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+short&lt;/code&gt; strips everything except the answer. If it prints an IP, resolution works from this host. If it prints nothing, you have a real failure to chase. Empty output is a signal, not an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig api.internal.example.com A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full form. Read the &lt;strong&gt;status&lt;/strong&gt; in the header: &lt;code&gt;NOERROR&lt;/code&gt; with an ANSWER section is good; &lt;code&gt;NXDOMAIN&lt;/code&gt; means the name genuinely doesn't exist; &lt;code&gt;SERVFAIL&lt;/code&gt; points at a broken upstream or DNSSEC issue. Also note which &lt;strong&gt;SERVER&lt;/strong&gt; answered at the bottom — that's the resolver you're actually testing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig &lt;span class="nt"&gt;-x&lt;/span&gt; 10.20.30.40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reverse lookup (PTR). Handy when logs show an IP and you need the name, or when validating that forward and reverse records agree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +trace api.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+trace&lt;/code&gt; walks delegation from the root servers down, showing each handoff. Use it when a name works from one resolver but not another — it reveals whether the authoritative servers themselves disagree with your cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On systemd hosts, this shows the &lt;strong&gt;actual resolver and search domains in effect&lt;/strong&gt; per interface. This is the ground truth that &lt;code&gt;/etc/resolv.conf&lt;/code&gt; often only hints at, especially when &lt;code&gt;systemd-resolved&lt;/code&gt; owns the stub at &lt;code&gt;127.0.0.53&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real scenarios, and how to read them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wrong A record.&lt;/strong&gt; &lt;code&gt;dig +short&lt;/code&gt; returns an IP, but it's the old one. Confirm authoritative truth with &lt;code&gt;dig @&amp;lt;authoritative-ns&amp;gt; name A&lt;/code&gt; and compare to the zone's SOA serial. If authoritative is correct but your resolver isn't, you're looking at caching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale cache / TTL.&lt;/strong&gt; A record changed an hour ago but clients still hit the old address. Check the &lt;strong&gt;TTL&lt;/strong&gt; counting down in the ANSWER section — a long TTL means old answers linger. Query the authoritative server directly to confirm the new value, then wait out or flush the cache rather than "restarting things."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong resolver in /etc/resolv.conf.&lt;/strong&gt; The app queries a resolver that can't see internal zones. &lt;code&gt;dig&lt;/code&gt; shows the wrong SERVER at the bottom of its output. Cross-check with &lt;code&gt;resolvectl status&lt;/code&gt;. This is common on cloud VMs where DHCP overwrites resolver config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search-domain surprises.&lt;/strong&gt; &lt;code&gt;dig +short api&lt;/code&gt; returns nothing but &lt;code&gt;dig +short api.internal.example.com&lt;/code&gt; works. A bare name gets &lt;strong&gt;search domains&lt;/strong&gt; appended by the resolver, and &lt;code&gt;dig&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; apply them the way your app's resolver library does. Always test both the short name and the FQDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container DNS.&lt;/strong&gt; Inside Docker, &lt;code&gt;/etc/resolv.conf&lt;/code&gt; points at the embedded resolver &lt;strong&gt;127.0.0.11&lt;/strong&gt;, which forwards to the host and resolves other containers by name. In Kubernetes, pods use cluster DNS (CoreDNS) with search domains like &lt;code&gt;svc.cluster.local&lt;/code&gt;. The rule holds: &lt;code&gt;dig&lt;/code&gt; &lt;em&gt;from inside the container or pod&lt;/em&gt;, not from the host. A name that resolves on the node but not in the pod is a cluster-DNS or search-domain issue, not a broken app.&lt;/p&gt;

&lt;p&gt;I wrote this up as a full, reproducible walkthrough — resolver chain, capture, and fixes — in the &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/dns-troubleshooting/" rel="noopener noreferrer"&gt;DNS troubleshooting lesson&lt;/a&gt;, part of a &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/" rel="noopener noreferrer"&gt;15-lesson networking series&lt;/a&gt; aimed at DevOps and DevSecOps troubleshooting. There's also a &lt;a href="https://devopsaitoolkit.com/kali-linux/docker/dns-troubleshooting/" rel="noopener noreferrer"&gt;container-focused DNS lesson&lt;/a&gt; that goes deeper on the 127.0.0.11 resolver and cluster DNS.&lt;/p&gt;

&lt;p&gt;One note on ethics: only inspect, query, or capture DNS traffic on systems you own or are explicitly authorized to assess. This is defensive infrastructure validation, not reconnaissance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;DNS failures feel mysterious because people guess. &lt;code&gt;dig&lt;/code&gt;, run from the right host against the right resolver, turns guessing into evidence: you can see the status code, the resolver, the TTL, and the delegation path in one command each.&lt;/p&gt;

&lt;p&gt;If you want the hands-on version, the &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/dns-troubleshooting/" rel="noopener noreferrer"&gt;DNS troubleshooting walkthrough&lt;/a&gt; steps through each scenario end to end — and the site has more free, hands-on Kali learning paths built specifically for DevOps engineers.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>tcpdump for DevOps: Read a TCP Handshake and Stop Guessing</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Tue, 18 Aug 2026 17:07:07 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/tcpdump-for-devops-read-a-tcp-handshake-and-stop-guessing-3a2f</link>
      <guid>https://dev.to/jjoyneriv/tcpdump-for-devops-read-a-tcp-handshake-and-stop-guessing-3a2f</guid>
      <description>&lt;p&gt;When a service "can't connect," the app logs and the network rarely tell the same story. &lt;code&gt;tcpdump&lt;/code&gt; settles the argument: it shows what actually went over the wire, not what the client library claims happened. Learning to read three packet signatures is usually enough to split a network problem from an application problem in under a minute.&lt;/p&gt;

&lt;p&gt;One ground rule first: &lt;strong&gt;only capture on systems you own or are explicitly authorized to assess.&lt;/strong&gt; A pcap can hold credentials, tokens, and personal data, so treat capture files as sensitive and delete them when you're done. This is defensive infrastructure validation, not snooping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture just enough, and nothing else
&lt;/h2&gt;

&lt;p&gt;The mistake most people make is capturing everything and drowning in it. Filter at the source instead.&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;tcpdump &lt;span class="nt"&gt;-i&lt;/span&gt; eth0 &lt;span class="nt"&gt;-n&lt;/span&gt; host 10.0.4.20 and port 443 &lt;span class="nt"&gt;-c&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking that down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-i eth0&lt;/code&gt; — the interface to listen on (&lt;code&gt;any&lt;/code&gt; works if you're unsure).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-n&lt;/code&gt; — don't resolve IPs or ports to names, so DNS lag never distorts the output.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;host 10.0.4.20&lt;/code&gt; and &lt;code&gt;port 443&lt;/code&gt; — a &lt;strong&gt;BPF filter&lt;/strong&gt;: only packets to/from that host on that port.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-c 20&lt;/code&gt; — stop after 20 packets so the terminal stays readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add &lt;code&gt;tcp&lt;/code&gt; to drop everything else, or write the raw capture to disk for later analysis:&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;tcpdump &lt;span class="nt"&gt;-i&lt;/span&gt; eth0 &lt;span class="nt"&gt;-n&lt;/span&gt; host 10.0.4.20 and tcp &lt;span class="nt"&gt;-w&lt;/span&gt; handshake.pcap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-w handshake.pcap&lt;/code&gt; writes packets untouched — open it later in the same tool or in Wireshark. The point is to reproduce the failing request while this runs, then read back exactly what the kernel saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signature 1: a healthy connection
&lt;/h2&gt;

&lt;p&gt;A working TCP connection always opens with the &lt;strong&gt;three-way handshake&lt;/strong&gt; — SYN, SYN-ACK, ACK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.1.5.51420 &amp;gt; 10.0.4.20.443: Flags [S], seq 12345
10.0.4.20.443 &amp;gt; 10.0.1.5.51420: Flags [S.], seq 98765, ack 12346
10.0.1.5.51420 &amp;gt; 10.0.4.20.443: Flags [.], ack 98766
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[S]&lt;/code&gt; is SYN, &lt;code&gt;[S.]&lt;/code&gt; is SYN-ACK, &lt;code&gt;[.]&lt;/code&gt; is a bare ACK. Three lines, both directions, done. &lt;strong&gt;If you see this, the network path and the listening service are both fine&lt;/strong&gt; — your problem is above the transport layer: TLS, auth, a slow query, a 500. Stop blaming the firewall and go read the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signature 2: connection refused
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.1.5.51422 &amp;gt; 10.0.4.20.443: Flags [S], seq 22222
10.0.4.20.443 &amp;gt; 10.0.1.5.51422: Flags [R.], seq 0, ack 22223
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SYN gets an immediate &lt;strong&gt;RST&lt;/strong&gt; (&lt;code&gt;[R.]&lt;/code&gt;). That's a fast, deliberate "no." The packet reached the host, but &lt;strong&gt;nothing is listening on that port&lt;/strong&gt; — the process is down, bound to the wrong interface (127.0.0.1 instead of 0.0.0.0), or a proxy is rejecting it. Notice the speed: refusals come back in milliseconds. Check that the service is up and bound correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signature 3: timeout / silence
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.1.5.51424 &amp;gt; 10.0.4.20.443: Flags [S], seq 33333
10.0.1.5.51424 &amp;gt; 10.0.4.20.443: Flags [S], seq 33333
10.0.1.5.51424 &amp;gt; 10.0.4.20.443: Flags [S], seq 33333
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your SYN goes out, gets retransmitted after ~1s, ~2s, ~4s — and &lt;strong&gt;nothing ever comes back&lt;/strong&gt;. No RST, no SYN-ACK, just your own side talking to a wall. That silence is the signature of a &lt;strong&gt;dropped packet&lt;/strong&gt;: a security group, &lt;code&gt;iptables&lt;/code&gt; rule, missing route, or a host that's simply gone. A refusal answers; a firewall drop stays quiet. That distinction alone routes the ticket to the right team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget DNS
&lt;/h2&gt;

&lt;p&gt;Half of "network" outages are name resolution. DNS rides UDP/53, and a healthy lookup is one query, one response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.1.5.40311 &amp;gt; 10.0.0.2.53: A? api.internal.example. (37)
10.0.0.2.53 &amp;gt; 10.0.1.5.40311: A 10.0.4.20 (53)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Query out, answer back with an address. If the query leaves and no response returns, you have a resolver problem, not a connectivity problem — and every downstream connect() will fail for the wrong-looking reason. I walk through this end to end in &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/tcpdump-packet-analysis/" rel="noopener noreferrer"&gt;the full tcpdump packet-analysis lesson&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing inside a container
&lt;/h2&gt;

&lt;p&gt;Containers don't get raw-socket access by default. The wrong fix is &lt;code&gt;--privileged&lt;/code&gt;, which hands the container broad host-level capabilities — and never mount the Docker socket to work around it. Grant exactly the one capability packet capture needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;debug&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;nicolaka/netshoot&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_RAW&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service:app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NET_RAW&lt;/code&gt; is enough for &lt;code&gt;tcpdump&lt;/code&gt;; sharing the target's network namespace lets you watch its traffic without touching the host. There's a container-specific walkthrough in &lt;a href="https://devopsaitoolkit.com/kali-linux/docker/tcpdump-packet-capture/" rel="noopener noreferrer"&gt;the Docker packet-capture lesson&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit
&lt;/h2&gt;

&lt;p&gt;Observe the packets, form a hypothesis (handshake completes → look higher; RST → nothing listening; silence → something dropping), then test and validate the same way. The capture is evidence, not a guess.&lt;/p&gt;

&lt;p&gt;If you want the guided version, start with &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/tcpdump-packet-analysis/" rel="noopener noreferrer"&gt;the tcpdump lesson in the networking series&lt;/a&gt;. The site has free, hands-on &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/" rel="noopener noreferrer"&gt;Kali learning paths built for DevOps engineers&lt;/a&gt; — Kali as a portable troubleshooting toolbox, not a hacking course.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Network Troubleshooting as a Stack: Find Which Layer Is Broken First</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 15 Aug 2026 18:34:37 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/network-troubleshooting-as-a-stack-find-which-layer-is-broken-first-2nm6</link>
      <guid>https://dev.to/jjoyneriv/network-troubleshooting-as-a-stack-find-which-layer-is-broken-first-2nm6</guid>
      <description>&lt;p&gt;The difference between a good infrastructure troubleshooter and someone who restarts services and hopes is a mental model. When "HTTPS times out" lands in your inbox, you don't guess — you know exactly which layer to interrogate first, and in what order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network is a stack, so treat it like one
&lt;/h2&gt;

&lt;p&gt;Every request rides through the same layers, top to bottom:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application → TLS → Port → DNS → Gateway → Route → Interface&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the &lt;em&gt;dependency&lt;/em&gt; order — TLS can't work if the port is closed, the port is meaningless if DNS resolved to the wrong host, and none of it matters if your interface has no IP. So you verify in the &lt;strong&gt;inverse&lt;/strong&gt; order, from the ground up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interface → IP → Route → Gateway → DNS → Port → TLS → Application&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Start at the bottom because a broken lower layer produces confusing symptoms higher up. Confirm each layer is healthy before you climb. The moment a layer fails, you've found your problem — everything above it is a red herring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk it: "HTTPS to api.example.com times out"
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Interface — do we have a link and an address?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for your primary interface (say &lt;code&gt;eth0&lt;/code&gt;) in state &lt;code&gt;UP&lt;/code&gt; with an &lt;code&gt;inet&lt;/code&gt; line like &lt;code&gt;192.168.1.20/24&lt;/code&gt;. &lt;strong&gt;No &lt;code&gt;inet&lt;/code&gt;?&lt;/strong&gt; DHCP failed or the link is down — stop here, nothing above will work. If the address is present and sane, climb.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Route — is there a path to the destination?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 93.184.216.34
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows the exact route the kernel &lt;em&gt;would&lt;/em&gt; pick, including the source IP and gateway (&lt;code&gt;via 192.168.1.1 dev eth0 src 192.168.1.20&lt;/code&gt;). If you get &lt;strong&gt;"Network is unreachable"&lt;/strong&gt; or no default route, you've found it. This is also the signature behind the classic &lt;code&gt;curl&lt;/code&gt; error &lt;strong&gt;"No route to host."&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Gateway — can we reach the first hop?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping &lt;span class="nt"&gt;-c3&lt;/span&gt; 192.168.1.1
ip neigh show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ping&lt;/code&gt; tests reachability; &lt;code&gt;ip neigh&lt;/code&gt; shows the ARP table. A gateway entry in state &lt;code&gt;REACHABLE&lt;/code&gt; with a MAC address means L2 is fine. &lt;strong&gt;&lt;code&gt;FAILED&lt;/code&gt; or &lt;code&gt;INCOMPLETE&lt;/code&gt;&lt;/strong&gt; means the gateway isn't answering ARP — a VLAN, cabling, or firewall problem. Note that many hosts drop ICMP, so treat a failed &lt;code&gt;ping&lt;/code&gt; as a hint, not a verdict — trust the neighbor state.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. DNS — does the name resolve, and to the right thing?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short api.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty answer or &lt;strong&gt;"could not resolve host"&lt;/strong&gt; from &lt;code&gt;curl&lt;/code&gt; means DNS is your layer. Compare the returned IP against what you expect — resolving successfully to a &lt;em&gt;stale&lt;/em&gt; address is a subtle failure that looks like an application bug. Add &lt;code&gt;dig api.example.com&lt;/code&gt; (no &lt;code&gt;+short&lt;/code&gt;) to see the &lt;code&gt;SERVER:&lt;/code&gt; line and confirm &lt;em&gt;which&lt;/em&gt; resolver answered.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Port — is the service actually listening/reachable?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nc &lt;span class="nt"&gt;-vz&lt;/span&gt; api.example.com 443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes, three different meanings — this is the most diagnostic step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"succeeded" / "open"&lt;/strong&gt; — TCP handshake completed, move up to TLS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Connection refused"&lt;/strong&gt; — you reached the host but nothing is listening on 443 (service down, or wrong port). The host answered &lt;em&gt;fast&lt;/em&gt; with a RST.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeout / hangs&lt;/strong&gt; — a firewall or security group is silently dropping packets. The signature is the &lt;em&gt;wait&lt;/em&gt;, not an immediate error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Refused vs. timeout is the single most valuable distinction in network debugging: refused = reached the host, timeout = something ate the packet. On the server, &lt;code&gt;ss -tlnp | grep :443&lt;/code&gt; confirms the process is bound to the right address (a service on &lt;code&gt;127.0.0.1&lt;/code&gt; won't accept external traffic).&lt;/p&gt;

&lt;h3&gt;
  
  
  6. TLS — does the handshake complete and the cert validate?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; api.example.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; api.example.com &amp;lt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;Verify return code&lt;/code&gt; at the bottom. &lt;strong&gt;&lt;code&gt;0 (ok)&lt;/code&gt;&lt;/strong&gt; means the chain is valid. An expired cert, wrong hostname, or missing intermediate shows up here as a nonzero code (e.g. &lt;code&gt;21&lt;/code&gt; unable to verify the first certificate) — long before your application logs blame something vague. The &lt;code&gt;-servername&lt;/code&gt; flag sends SNI, which matters on shared hosts serving multiple certs.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Application — now, finally, the app
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; https://api.example.com/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only when everything below is green does an app-layer error (HTTP 500, 401, a slow backend) actually mean the app. The &lt;code&gt;-v&lt;/code&gt; output replays the whole climb — DNS, connect, TLS handshake, request, response headers — so it's also a great one-shot sanity check to confirm your layer-by-layer conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats guessing
&lt;/h2&gt;

&lt;p&gt;Each command targets exactly one layer and each failure has a distinct &lt;strong&gt;signature&lt;/strong&gt;: &lt;em&gt;no route to host&lt;/em&gt; (route), &lt;em&gt;could not resolve&lt;/em&gt; (DNS), &lt;em&gt;connection refused&lt;/em&gt; (port, service down), &lt;em&gt;timeout&lt;/em&gt; (port, firewall drop), &lt;em&gt;verify return code != 0&lt;/em&gt; (TLS). Learn the signatures and you skip straight to the broken layer instead of restarting things at random.&lt;/p&gt;

&lt;p&gt;I put the full layer-by-layer walkthrough, with these tools built into a portable toolbox, in &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/" rel="noopener noreferrer"&gt;the Kali Linux Networking for DevOps series&lt;/a&gt; — a 15-lesson path that frames Kali as a disposable, throwaway diagnostics box, not a "learn to hack" course. The &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/linux-routing/" rel="noopener noreferrer"&gt;Linux routing lesson&lt;/a&gt; and the &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/dns-troubleshooting/" rel="noopener noreferrer"&gt;DNS troubleshooting lesson&lt;/a&gt; go deep on the two layers people misdiagnose most.&lt;/p&gt;

&lt;p&gt;One ground rule: only probe, scan, or capture on systems you own or are explicitly authorized to assess — this is defensive infrastructure validation, not an excuse to poke at other people's networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it next time
&lt;/h2&gt;

&lt;p&gt;Next incident, resist the restart reflex — climb the stack from &lt;code&gt;ip addr&lt;/code&gt; upward and let the failure signature name the layer. If you want the guided version, work through &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/" rel="noopener noreferrer"&gt;the full 15-lesson networking series&lt;/a&gt;; the site has free hands-on Kali learning paths built specifically for DevOps engineers.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>kali</category>
    </item>
    <item>
      <title>Nmap for Authorized Infrastructure Validation (Not Hacking)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Thu, 13 Aug 2026 21:39:05 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/nmap-for-authorized-infrastructure-validation-not-hacking-3ne6</link>
      <guid>https://dev.to/jjoyneriv/nmap-for-authorized-infrastructure-validation-not-hacking-3ne6</guid>
      <description>&lt;p&gt;Every deploy makes a promise about the network: "this box only exposes SSH and HTTPS," "the database is never reachable from outside the app tier." Nmap is how you turn that promise into a test that either passes or fails. Nobody has to take the security group's word for it.&lt;/p&gt;

&lt;p&gt;One rule before anything else: &lt;strong&gt;only scan systems you own or are explicitly authorized to assess.&lt;/strong&gt; Point Nmap at a lab, a VM you control, or your own infrastructure. This is authorized infrastructure validation — a defensive check on exposure you're responsible for, not "hacking."&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with what's actually listening
&lt;/h2&gt;

&lt;p&gt;The most basic useful run is a host scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap 192.168.56.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does host discovery and a default TCP scan of the common ports. The output lists each port as &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;closed&lt;/code&gt;, or &lt;code&gt;filtered&lt;/code&gt;. &lt;strong&gt;&lt;code&gt;open&lt;/code&gt;&lt;/strong&gt; means something accepted the connection. &lt;strong&gt;&lt;code&gt;filtered&lt;/code&gt;&lt;/strong&gt; usually means a firewall or security group silently dropped the packet — which is exactly the signal you want when validating that a rule is doing its job. If you expected a wall of &lt;code&gt;filtered&lt;/code&gt; and instead see &lt;code&gt;open&lt;/code&gt;, that's your finding.&lt;/p&gt;

&lt;p&gt;When you already know what &lt;em&gt;should&lt;/em&gt; be exposed, scan for exactly that and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-p&lt;/span&gt; 22,80,443 host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Narrowing to the declared ports keeps the scan fast and the output readable. The question you're answering isn't "what's out there" — it's "does observed reality match what I declared?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm what's really on the port
&lt;/h2&gt;

&lt;p&gt;An open port tells you a socket is listening. It does not tell you &lt;em&gt;what&lt;/em&gt;. For that, add version detection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-sV&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 22,80,443 host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-sV&lt;/code&gt; probes each open port and reports the service and, when it can, the version banner. This matters because ports lie. A service you assumed was nginx on 443 might be something a teammate stood up last week. Read the &lt;code&gt;SERVICE&lt;/code&gt; and &lt;code&gt;VERSION&lt;/code&gt; columns and ask: is this the thing I expected, at the version I expected? A mismatch here is often the first sign of drift or a forgotten container.&lt;/p&gt;

&lt;h2&gt;
  
  
  A methodology, not just commands
&lt;/h2&gt;

&lt;p&gt;Running Nmap ad hoc gives you trivia. Running it as a pipeline gives you a regression test for your network posture. The loop I use after every deploy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declared Configuration → Expected Exposure → Observed Exposure → Compare → Correct → Retest.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declared Configuration&lt;/strong&gt; — the source of truth: the security group, firewall rules, or Terraform that says what's allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected Exposure&lt;/strong&gt; — translate that into a concrete list of ports that &lt;em&gt;should&lt;/em&gt; answer. For a web tier: 22 (from bastion only) and 443.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observed Exposure&lt;/strong&gt; — what Nmap actually sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare&lt;/strong&gt; — diff the two. Every open port that isn't in the expected list is a finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correct&lt;/strong&gt; — fix the rule, the container publish, or the config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retest&lt;/strong&gt; — scan again. The finding must be gone. No retest, no fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A concrete finding
&lt;/h3&gt;

&lt;p&gt;Say the declared config for a web host is SSH and HTTPS only. Expected exposure: &lt;code&gt;22, 443&lt;/code&gt;. You run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-sV&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 22,443,3306 web-host.internal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And 3306 comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PORT     STATE  SERVICE  VERSION
22/tcp   open   ssh      OpenSSH ...
443/tcp  open   https    ...
3306/tcp open   mysql    MySQL ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3306 is MySQL, and it was never in the declared config.&lt;/strong&gt; Observed doesn't match expected — that's the finding. The hypothesis writes itself: a container published &lt;code&gt;3306:3306&lt;/code&gt; to the host instead of binding to an internal network, or a security group rule is broader than intended. Correct it (bind the DB to the app network, tighten the rule), then rerun the exact same scan. When 3306 comes back &lt;code&gt;filtered&lt;/code&gt; or absent, the loop closes.&lt;/p&gt;

&lt;p&gt;That Declared → Observed → Retest discipline is the core of &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/nmap-network-validation/" rel="noopener noreferrer"&gt;the full Nmap network validation walkthrough&lt;/a&gt;, which builds this into a repeatable post-deploy check.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on scan types and privileges
&lt;/h2&gt;

&lt;p&gt;Some scan types need elevated privileges. A &lt;strong&gt;SYN scan&lt;/strong&gt; (&lt;code&gt;-sS&lt;/code&gt;), for example, crafts raw packets and requires &lt;code&gt;CAP_NET_RAW&lt;/code&gt; — run unprivileged, Nmap quietly falls back to a slower connect scan. In a container, grant &lt;em&gt;only&lt;/em&gt; that capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_RAW instrumentisto/nmap &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 22,443 host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Never reach for &lt;code&gt;--privileged&lt;/code&gt;.&lt;/strong&gt; It hands the container the full capability set and effectively removes the kernel's isolation between it and the host — a wildly oversized trade for one raw-socket permission. &lt;code&gt;--cap-add NET_RAW&lt;/code&gt; grants exactly what the scan needs and nothing more. If you're wiring Nmap into container-based checks, the &lt;a href="https://devopsaitoolkit.com/kali-linux/docker/nmap-service-discovery/" rel="noopener noreferrer"&gt;Docker-based service discovery lesson&lt;/a&gt; covers the disposable-toolbox pattern in depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;Nmap earns its place in a DevOps toolkit not as an offensive tool but as an assertion engine: it makes "the firewall should only allow X" something you can prove after every change. Work through &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/nmap-network-validation/" rel="noopener noreferrer"&gt;the Nmap network validation lesson&lt;/a&gt; to turn this loop into a habit — and the site has free, hands-on &lt;a href="https://devopsaitoolkit.com/kali-linux/networking/" rel="noopener noreferrer"&gt;Kali networking learning paths&lt;/a&gt; aimed squarely at DevOps engineers who'd rather test their infrastructure than trust it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>devops</category>
      <category>kali</category>
    </item>
    <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>
  </channel>
</rss>
