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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;p&gt;Install OpenTofu without removing Terraform. On a workstation or a scratch CI runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify the binary is there and note the version&lt;/span&gt;
tofu version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;This is the heart of the whole exercise. Work on a copy or a non-production workspace first. Point OpenTofu at your existing configuration and existing state, initialize, and produce a plan — but do not apply.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fresh working dir state, same backend/state as before&lt;/span&gt;
tofu init

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



&lt;p&gt;You can also confirm from the kernel side. The OOM killer logs every kill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'out of memory|oom-kill|killed process'&lt;/span&gt;
&lt;span class="c"&gt;# or, on a systemd host:&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'out of memory|oom-kill'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;Say I've got a JVM service running with a tight limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-service &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256m my-registry/my-service:1.4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

&lt;p&gt;First, give it a limit that reflects reality. I measured the steady-state working set at around 350 MB, so I'll allow headroom for the JVM's non-heap overhead (metaspace, thread stacks, off-heap buffers) on top of the heap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-XX:MaxRAMPercentage=70.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  my-registry/my-service:1.4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;For a Node service the equivalent is bounding the old-space heap under the limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; my-worker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--max-old-space-size=384"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  my-registry/my-worker:2.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

</description>
      <category>docker</category>
      <category>devops</category>
      <category>sre</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Hardening Docker Containers: The Security Habits That Actually Matter</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Mon, 13 Jul 2026 14:38:47 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/hardening-docker-containers-the-security-habits-that-actually-matter-3d88</link>
      <guid>https://dev.to/jjoyneriv/hardening-docker-containers-the-security-habits-that-actually-matter-3d88</guid>
      <description>&lt;p&gt;Most of what people call "container security" is a short list of defaults you can set in an afternoon. The exotic tooling — admission controllers, runtime sensors, policy engines — matters at scale, but it's the polish, not the foundation. What actually moves the needle is a handful of flags and one or two Dockerfile lines, ranked here by leverage: highest-impact, lowest-effort first.&lt;/p&gt;

&lt;p&gt;I'm going to skip the compliance framing. None of this is about passing an audit. It's about making the difference between "an attacker got code execution in the container" and "an attacker got code execution on the host" as wide as I can with defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't run as root
&lt;/h2&gt;

&lt;p&gt;This is the single highest-leverage change, so it goes first. By default a process in a container runs as UID 0. It's namespaced root, not host root — but it's still root inside, and the day someone chains a container escape (a kernel bug, a misconfigured mount, a runc CVE) with root-in-container, that's the bad day. Root inside plus an escape is root on the box. A non-root UID turns the same escape into a much smaller problem.&lt;/p&gt;

&lt;p&gt;Set it in the Dockerfile. Create a real user, don't just &lt;code&gt;USER 1000&lt;/code&gt; on top of a root-owned filesystem:&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-slim&lt;/span&gt;

&lt;span class="c"&gt;# Create an unprivileged user and group&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;--gid&lt;/span&gt; 10001 app &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;--uid&lt;/span&gt; 10001 &lt;span class="nt"&gt;--gid&lt;/span&gt; app &lt;span class="nt"&gt;--home-dir&lt;/span&gt; /app &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; app

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=app:app . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&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;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things people trip on. First, order matters: anything that needs to write to the image (installing packages, chowning files) has to happen before the &lt;code&gt;USER&lt;/code&gt; line, because after it you're unprivileged. Second, listening on a port below 1024 as non-root fails unless you grant a capability — more on that in a second.&lt;/p&gt;

&lt;p&gt;If you can't rebuild the image (third-party base you don't control), force it at runtime:&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;--user&lt;/span&gt; 10001:10001 REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That won't fix files inside the image owned by root that the app expects to write, which is exactly the kind of thing you find by testing. But a numeric UID with no matching entry in the container's &lt;code&gt;/etc/passwd&lt;/code&gt; is fine for most apps and is a clean default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drop capabilities, add back only what you need
&lt;/h2&gt;

&lt;p&gt;Even non-root containers get a default set of Linux capabilities — roughly fourteen of them, including things like &lt;code&gt;CHOWN&lt;/code&gt;, &lt;code&gt;SETUID&lt;/code&gt;, &lt;code&gt;MKNOD&lt;/code&gt;, &lt;code&gt;NET_RAW&lt;/code&gt;. Most applications use approximately none of these. The default set is far broader than a typical web service needs, and every capability you don't need is attack surface you're carrying for free.&lt;/p&gt;

&lt;p&gt;Drop everything, then add back the specific ones the app actually uses:&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&lt;/span&gt; ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_BIND_SERVICE &lt;span class="se"&gt;\&lt;/span&gt;
  REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NET_BIND_SERVICE&lt;/code&gt; is the common one — it lets a non-root process bind a port below 1024, so you can run as UID 10001 and still listen on 80 or 443. If your app does something lower-level (raw sockets, for instance) you'll discover the missing capability as a permission error and add it back deliberately.&lt;/p&gt;

&lt;p&gt;The honest tradeoff: &lt;code&gt;--cap-drop ALL&lt;/code&gt; needs testing. Some images shell out to tools that quietly expect a capability, and you won't know until an unusual code path runs. Test the real workload, not just startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-only root filesystem
&lt;/h2&gt;

&lt;p&gt;If an attacker gets code execution, a writable filesystem lets them drop tools, modify binaries, or persist. Make the root filesystem read-only and the whole class of "write a webshell to disk" goes away:&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tmpfs&lt;/span&gt; /tmp:rw,noexec,nosuid,size&lt;span class="o"&gt;=&lt;/span&gt;64m &lt;span class="se"&gt;\&lt;/span&gt;
  REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every app needs &lt;em&gt;some&lt;/em&gt; writable path — usually &lt;code&gt;/tmp&lt;/code&gt;, sometimes a cache or a PID directory. The &lt;code&gt;--tmpfs&lt;/code&gt; mount gives you a small, in-memory writable spot without making the whole filesystem writable, and &lt;code&gt;noexec&lt;/code&gt; means nothing dropped there can be run.&lt;/p&gt;

&lt;p&gt;How do I find what actually needs to be writable? Run it read-only and watch it fail. The app will throw &lt;code&gt;EROFS&lt;/code&gt; / "read-only file system" errors pointing at exact paths. Add a tmpfs or a named volume for each real one and re-test. It's iterative, and yes, some apps write all over the place and fight you — those you either fix or grant a narrow volume. But most well-behaved services need &lt;code&gt;/tmp&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  no-new-privileges
&lt;/h2&gt;

&lt;p&gt;Cheap, no downside I've ever hit, so it's always on:&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;--security-opt&lt;/span&gt; no-new-privileges REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets the kernel flag that stops a process from gaining privileges via setuid binaries. If there's a setuid-root binary lurking in the image, this prevents it from being used to escalate. Combined with running as non-root, it closes the "call sudo, become root" path. There's no reason not to set it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never &lt;code&gt;--privileged&lt;/code&gt; (and mind the Docker socket)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--privileged&lt;/code&gt; is not "a bit more access." It disables most of the isolation that makes a container a container: it grants all capabilities, drops the seccomp and AppArmor profiles, and gives access to host devices. A privileged container is, for practical purposes, running on the host. If you've reached for it to make one thing work, you almost certainly wanted a single &lt;code&gt;--cap-add&lt;/code&gt; or a specific &lt;code&gt;--device&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;The sharper edge of the same knife is mounting the Docker socket:&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;# Do not do this unless you fully mean it&lt;/span&gt;
&lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything that can talk to &lt;code&gt;docker.sock&lt;/code&gt; can start a new container mounting the host's root filesystem, and that is root on the host — full stop. CI runners and "container that manages containers" tooling do this constantly, and it's the footgun I see most often. If a workload needs it, treat that workload as if it already has host root, because effectively it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets: not in ENV, not in layers
&lt;/h2&gt;

&lt;p&gt;A sibling article covers image slimming in depth, so I'll keep this tight, but it belongs on the list. Two rules.&lt;/p&gt;

&lt;p&gt;Don't put secrets in &lt;code&gt;ENV&lt;/code&gt; — they're visible to anything that can run &lt;code&gt;docker inspect&lt;/code&gt; and to every child process. And don't &lt;code&gt;COPY&lt;/code&gt; a secret file into the image expecting a later &lt;code&gt;RUN rm&lt;/code&gt; to erase it; the secret persists in the earlier layer forever, and anyone with the image can pull it back out.&lt;/p&gt;

&lt;p&gt;For build-time secrets, use BuildKit's secret mount. The secret is available during that one &lt;code&gt;RUN&lt;/code&gt; and never lands in a layer:&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;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret,id&lt;span class="o"&gt;=&lt;/span&gt;npm_token &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nv"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /run/secrets/npm_token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; npm ci
&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;docker build &lt;span class="nt"&gt;--secret&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;npm_token,env&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For runtime secrets, inject them as environment or mounted files from your orchestrator or a secrets manager at run time — not baked into the image. The image should be safe to push to a registry with nothing sensitive inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin base images by digest, and scan them
&lt;/h2&gt;

&lt;p&gt;Here's the unglamorous truth: patching base images is most of your real CVE exposure. Your application code is a small target; the OS packages and language runtime underneath it are where the published, exploitable CVEs actually accumulate.&lt;/p&gt;

&lt;p&gt;Pin the base by digest so the build is reproducible and can't drift under you:&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-slim@sha256:REDACTEDdigest0000000000000000000000000000000000000000000000000000000000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tag like &lt;code&gt;node:20-slim&lt;/code&gt; moves; a digest doesn't. Then scan on a schedule and treat a bumped digest as routine maintenance, not an event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker scout cves REDACTED-image:tag
&lt;span class="c"&gt;# or&lt;/span&gt;
trivy image REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinning without scanning just means you've frozen your vulnerabilities in place, so the two go together: pin for reproducibility, scan to know when to move the pin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limit the blast radius
&lt;/h2&gt;

&lt;p&gt;Resource limits aren't confidentiality controls, but they cap what a compromised or buggy container can do to its neighbors — a fork bomb or memory balloon shouldn't take out the host:&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt; 256 &lt;span class="se"&gt;\&lt;/span&gt;
  REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--pids-limit&lt;/code&gt; in particular is a cheap defense against fork bombs, and &lt;code&gt;--memory&lt;/code&gt; stops one container from starving everything else on the node. Noisy-neighbor insurance, basically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Here's a single &lt;code&gt;docker run&lt;/code&gt; with the high-leverage flags stacked:&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; 10001:10001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&lt;/span&gt; ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_BIND_SERVICE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tmpfs&lt;/span&gt; /tmp:rw,noexec,nosuid,size&lt;span class="o"&gt;=&lt;/span&gt;64m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--security-opt&lt;/span&gt; no-new-privileges &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 512m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pids-limit&lt;/span&gt; 256 &lt;span class="se"&gt;\&lt;/span&gt;
  REDACTED-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the same thing in Compose, which is where most of this ends up living anyway:&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;api&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;REDACTED-image:tag&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001:10001"&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp:rw,noexec,nosuid,size=64m&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;cap_drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&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_BIND_SERVICE&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512m&lt;/span&gt;
    &lt;span class="na"&gt;pids_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;256&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with these on your least-critical service, watch it run, and fix what breaks before you roll it out wider. Read-only and cap-drop are the two that need testing; the rest are close to free.&lt;/p&gt;

&lt;p&gt;One habit worth building alongside this: before an image ships, run the Dockerfile through the &lt;a href="https://devopsaitoolkit.com/validators/dockerfile/" rel="noopener noreferrer"&gt;free Dockerfile validator&lt;/a&gt; — it flags secrets in &lt;code&gt;ENV&lt;/code&gt;, a missing &lt;code&gt;USER&lt;/code&gt;, and unpinned base images, which are exactly the mistakes that survive code review because they look normal.&lt;/p&gt;

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

&lt;p&gt;The durable takeaway, in priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run as non-root&lt;/strong&gt; — &lt;code&gt;USER&lt;/code&gt; in the Dockerfile, or &lt;code&gt;--user&lt;/code&gt; at runtime. Highest leverage, lowest cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--cap-drop ALL&lt;/code&gt;&lt;/strong&gt;, add back only what the app needs (usually just &lt;code&gt;NET_BIND_SERVICE&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--read-only&lt;/code&gt;&lt;/strong&gt; root filesystem plus a small &lt;code&gt;--tmpfs&lt;/code&gt; for the paths that genuinely need writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--security-opt no-new-privileges&lt;/code&gt;&lt;/strong&gt; — always on, no downside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;--privileged&lt;/code&gt;&lt;/strong&gt;, and never mount &lt;code&gt;docker.sock&lt;/code&gt; unless that workload already effectively owns the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of &lt;code&gt;ENV&lt;/code&gt; and out of layers&lt;/strong&gt; — BuildKit &lt;code&gt;--mount=type=secret&lt;/code&gt; at build, injected at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin base images by digest and scan them&lt;/strong&gt; — patching the base is most of your real CVE exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--memory&lt;/code&gt; and &lt;code&gt;--pids-limit&lt;/code&gt;&lt;/strong&gt; to cap the blast radius.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It's a short list of defaults, and most of the security you'll ever get from a container comes from just turning them on.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>containers</category>
    </item>
    <item>
      <title>Docker Networking, Demystified: Bridge, Host, and Container DNS</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sun, 12 Jul 2026 00:58:54 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/docker-networking-demystified-bridge-host-and-container-dns-5fac</link>
      <guid>https://dev.to/jjoyneriv/docker-networking-demystified-bridge-host-and-container-dns-5fac</guid>
      <description>&lt;p&gt;Most people meet Docker networking the same way I did: two containers that should be able to talk to each other, and one of them stubbornly refusing to connect. You did nothing wrong on the application side, the code works locally, and yet &lt;code&gt;curl&lt;/code&gt; inside one container gives you &lt;code&gt;connection refused&lt;/code&gt; reaching the other. The good news is that Docker networking is small once you understand three or four moving parts, and almost every real-world problem comes down to the same handful of causes.&lt;/p&gt;

&lt;p&gt;I want to walk through how containers actually find each other and the outside world, with commands you can paste and run. No magic, just the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pieces: bridges, DNS, and published ports
&lt;/h2&gt;

&lt;p&gt;Every container attaches to one or more networks. On Linux, the default network is a &lt;code&gt;bridge&lt;/code&gt; — a virtual switch (&lt;code&gt;docker0&lt;/code&gt;) that hands each container a private IP and NATs its outbound traffic to the host. Containers on the same bridge can reach each other by IP; the host reaches them only if you publish a port.&lt;/p&gt;

&lt;p&gt;There are three network modes worth knowing well: the default bridge, a &lt;strong&gt;user-defined bridge&lt;/strong&gt;, and &lt;code&gt;host&lt;/code&gt;. There's also &lt;code&gt;none&lt;/code&gt; for full isolation. The single most useful thing I can tell you is: don't use the default bridge for anything real. Create your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default bridge vs. user-defined bridge
&lt;/h2&gt;

&lt;p&gt;Here's the difference that trips everyone up. On the &lt;strong&gt;default&lt;/strong&gt; bridge, containers can talk by IP but there is no automatic name resolution. If you want &lt;code&gt;app&lt;/code&gt; to reach &lt;code&gt;db&lt;/code&gt; by name, you'd have to use the legacy &lt;code&gt;--link&lt;/code&gt; flag, which is deprecated and awkward.&lt;/p&gt;

&lt;p&gt;On a &lt;strong&gt;user-defined&lt;/strong&gt; bridge, Docker runs an embedded DNS server, and every container is resolvable by its name (and by &lt;code&gt;--network-alias&lt;/code&gt; if you set one). That one feature — automatic DNS by container name — is why you almost always want a user-defined network. You also get better isolation: only containers you explicitly attach can see each other.&lt;/p&gt;

&lt;p&gt;Create one and look at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create appnet
docker network &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NETWORK ID     NAME      DRIVER    SCOPE
0a1b2c3d4e5f   appnet    bridge    local
1122aabbccdd   bridge    bridge    local
99887766ffee   host      host      local
ab12cd34ef56   none      null      local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;appnet&lt;/code&gt; is a bridge just like the default one, but with DNS turned on for its members.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: an app talking to Redis by name
&lt;/h2&gt;

&lt;p&gt;Let me make this concrete with two containers on &lt;code&gt;appnet&lt;/code&gt; — a Redis instance and a small client — resolving each other by 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;# Redis, named so DNS has something to resolve&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; redis &lt;span class="nt"&gt;--network&lt;/span&gt; appnet redis:7.2-alpine

&lt;span class="c"&gt;# An app container on the same network&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; app &lt;span class="nt"&gt;--network&lt;/span&gt; appnet alpine:3.19 &lt;span class="nb"&gt;sleep &lt;/span&gt;infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now check that &lt;code&gt;app&lt;/code&gt; can resolve and reach &lt;code&gt;redis&lt;/code&gt; purely by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;app getent hosts redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



&lt;p&gt;That IP (&lt;code&gt;10.0.0.5&lt;/code&gt; here, yours will differ) came from Docker's embedded DNS, not from anything you configured. Test the actual TCP path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;app sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"apk add --no-cache redis &amp;gt;/dev/null &amp;amp;&amp;amp; redis-cli -h redis ping"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



&lt;p&gt;No IPs hardcoded, no &lt;code&gt;--link&lt;/code&gt;, no &lt;code&gt;/etc/hosts&lt;/code&gt; editing. The name &lt;code&gt;redis&lt;/code&gt; resolves because both containers share a user-defined network. Restart Redis and it may get a new IP, but the name keeps working — which is exactly why you reference services by name and never by IP.&lt;/p&gt;

&lt;p&gt;For contrast, run the same thing on the &lt;strong&gt;default&lt;/strong&gt; bridge and the name lookup fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; redis2 redis:7.2-alpine
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; alpine:3.19 getent hosts redis2   &lt;span class="c"&gt;# no --network, so default bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(no output — lookup fails)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same image, same host, no DNS. That's the whole argument for user-defined networks in one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing ports: EXPOSE is not publish
&lt;/h2&gt;

&lt;p&gt;This is the other big source of confusion. Two different concepts get used interchangeably and they are not the same thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXPOSE 80&lt;/code&gt; in a Dockerfile is &lt;strong&gt;documentation&lt;/strong&gt;. It records that the app listens on port 80 inside the container. It does not open anything to the host. You can &lt;code&gt;EXPOSE&lt;/code&gt; a port and still be unable to reach it from your laptop.&lt;/p&gt;

&lt;p&gt;Publishing with &lt;code&gt;-p&lt;/code&gt; is what actually maps a host port to a container port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx:1.27-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The syntax is &lt;code&gt;-p HOST:CONTAINER&lt;/code&gt;. So &lt;code&gt;8080:80&lt;/code&gt; means "traffic to the host's port 8080 goes to the container's port 80." Now you can hit it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A subtlety worth knowing: by default &lt;code&gt;-p 8080:80&lt;/code&gt; binds to all host interfaces. If you only want it reachable from the host itself, bind to loopback explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 127.0.0.1:8080:80 nginx:1.27-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that surprise people. First, containers on the same user-defined network reach each other on the container's &lt;em&gt;internal&lt;/em&gt; port (80 in these examples) with no &lt;code&gt;-p&lt;/code&gt; at all — publishing is only for host-to-container traffic. Second, "it works inside the container but I can't reach it from outside" almost always means you never published the port, or you published a different one than the app listens on.&lt;/p&gt;

&lt;h2&gt;
  
  
  host and none modes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--network host&lt;/code&gt; removes network isolation entirely: the container shares the host's network stack. No separate IP, no NAT, and &lt;code&gt;-p&lt;/code&gt; is ignored because there's nothing to map — the container's port 80 &lt;em&gt;is&lt;/em&gt; the host's port 80.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; host nginx:1.27-alpine
&lt;span class="c"&gt;# nginx is now on the host's :80 directly, no -p needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reach for &lt;code&gt;host&lt;/code&gt; mode in two situations: performance-sensitive workloads where NAT overhead matters, and tools that need to see the host's real interfaces (some monitoring agents, or anything doing raw/multicast networking). The tradeoffs are real, though — you lose port-mapping flexibility, you can collide with ports already in use on the host, and it's Linux-only in the way people expect (on Docker Desktop the semantics differ). Use it deliberately, not as a shortcut around a publish problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--network none&lt;/code&gt; gives the container a loopback interface and nothing else — no external connectivity at all. It's for batch jobs that process local data and should never touch the network, or as a security boundary. Handy, rarely needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting: the four usual suspects
&lt;/h2&gt;

&lt;p&gt;When a connection fails, I check these in order. Almost every case is one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Wrong network.&lt;/strong&gt; The two containers aren't actually on the same network, so DNS never resolves. Confirm who's attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network inspect appnet &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{range .Containers}}{{.Name}} {{end}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



&lt;p&gt;If a container you expected isn't listed, that's your bug. Attach a running one with &lt;code&gt;docker network connect appnet &amp;lt;container&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. App bound to 127.0.0.1 inside the container.&lt;/strong&gt; This is the sneakiest one. If your app listens on &lt;code&gt;127.0.0.1:5000&lt;/code&gt; instead of &lt;code&gt;0.0.0.0:5000&lt;/code&gt;, it only accepts connections from inside its own container — not from other containers, not from a published port. You'll get &lt;code&gt;connection refused&lt;/code&gt; even though the process is clearly running. Check what it's actually bound to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;app sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"netstat -tlnp 2&amp;gt;/dev/null || ss -tlnp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       128     127.0.0.1:5000       &amp;lt;-- the problem
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is in the app config, not Docker: bind to &lt;code&gt;0.0.0.0&lt;/code&gt;. Flask's &lt;code&gt;--host=0.0.0.0&lt;/code&gt;, a &lt;code&gt;HOST=0.0.0.0&lt;/code&gt; env var, &lt;code&gt;server.address=0.0.0.0&lt;/code&gt; — whatever your framework calls it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Port not published (or the wrong one).&lt;/strong&gt; You're trying to reach the container from the host but never mapped a port, or mapped one the app doesn't listen on. Check the mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker port web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;80/tcp -&amp;gt; 0.0.0.0:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means nothing is published. A mapping to a port your app isn't listening on means you'll connect to the host port and get refused at the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Right network, wrong port number.&lt;/strong&gt; Container-to-container traffic uses the internal port. If Redis listens on 6379, you connect to &lt;code&gt;redis:6379&lt;/code&gt; regardless of any &lt;code&gt;-p&lt;/code&gt; flag. Published ports are irrelevant between containers on the same network.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker network inspect&lt;/code&gt; and &lt;code&gt;docker exec ... getent hosts &amp;lt;name&amp;gt;&lt;/code&gt; resolve most of these in under a minute. If the name resolves and the port is right, the problem is inside the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose does the network for you
&lt;/h2&gt;

&lt;p&gt;Everything above is what Docker Compose automates. When you &lt;code&gt;docker compose up&lt;/code&gt;, Compose creates a user-defined bridge for the project and attaches every service to it, so services resolve each other by &lt;strong&gt;service name&lt;/strong&gt; out of the box.&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:5000"&lt;/span&gt;      &lt;span class="c1"&gt;# host:container, published to your machine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://redis:6379"&lt;/span&gt;   &lt;span class="c1"&gt;# "redis" is the service name&lt;/span&gt;
  &lt;span class="na"&gt;redis&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;redis:7.2-alpine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;app&lt;/code&gt; reaches Redis at the hostname &lt;code&gt;redis&lt;/code&gt; because Compose put both on the same network and named the DNS entry after the service. It's the exact same embedded-DNS mechanism from the manual example — you just don't type the &lt;code&gt;docker network create&lt;/code&gt;. Note the &lt;code&gt;ports&lt;/code&gt; entry is only needed for the service you want to reach from your host; &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; talk internally with no published port.&lt;/p&gt;

&lt;p&gt;If you want a set of worked reference setups to copy from — user-defined networks, published ports, and Compose files already wired up — I keep a collection of Docker stack patterns at &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;devopsaitoolkit.com/stacks/docker&lt;/a&gt; that mirror the examples here.&lt;/p&gt;

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

&lt;p&gt;If you remember one thing: put your containers on a user-defined network and reference them by name, never by IP. That single habit gives you working DNS, clean isolation, and containers that keep talking to each other even as IPs churn underneath them — and it turns most "connection refused" mysteries into a quick check of network membership, bind address, and published port.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Docker Volumes vs Bind Mounts: Where Your Data Actually Lives</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Sat, 11 Jul 2026 09:38:12 +0000</pubDate>
      <link>https://dev.to/jjoyneriv/docker-volumes-vs-bind-mounts-where-your-data-actually-lives-1ipl</link>
      <guid>https://dev.to/jjoyneriv/docker-volumes-vs-bind-mounts-where-your-data-actually-lives-1ipl</guid>
      <description>&lt;p&gt;A container's writable layer feels like a filesystem, and that's exactly the trap. Write a database into it, remove the container, and the data is gone — no warning, no recovery. If you want anything to survive &lt;code&gt;docker rm&lt;/code&gt;, it has to live outside the container, and Docker gives you three ways to do that: named volumes, bind mounts, and tmpfs. Knowing which one to reach for is most of the battle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the writable layer betrays you
&lt;/h2&gt;

&lt;p&gt;Every running container gets a thin read-write layer stacked on top of its image layers. It looks persistent because you can &lt;code&gt;docker exec&lt;/code&gt; in and see your files. But that layer is bound to the container's lifecycle.&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;--name&lt;/span&gt; scratch alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo hello &amp;gt; /data.txt; cat /data.txt'&lt;/span&gt;
&lt;span class="c"&gt;# hello&lt;/span&gt;
docker &lt;span class="nb"&gt;rm &lt;/span&gt;scratch
&lt;span class="c"&gt;# the layer — and /data.txt — no longer exists&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no "oops." The writable layer is discarded with the container. Persistence is not a default you get; it's a decision you make. That decision is a volume, a bind mount, or tmpfs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Named volumes: the default for state
&lt;/h2&gt;

&lt;p&gt;A named volume is storage that Docker creates and manages for you. You give it a name, Docker keeps the actual bytes under its own directory, and you never have to care where that is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create pgdata
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;pgdata,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/data &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container writes to &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;, but those bytes land in a Docker-managed location on the host. Remove and recreate the container against the same volume and the data is still there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; db
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;pgdata,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/data &lt;span class="se"&gt;\&lt;/span&gt;
  postgres:16
&lt;span class="c"&gt;# same data, new container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where do the bytes actually live? Under Docker's data root, typically &lt;code&gt;/var/lib/docker/volumes/&amp;lt;name&amp;gt;/_data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume inspect pgdata &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ .Mountpoint }}'&lt;/span&gt;
&lt;span class="c"&gt;# /var/lib/docker/volumes/pgdata/_data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is that you're &lt;em&gt;not&lt;/em&gt; supposed to reach into that path directly — Docker owns it. You address the data by volume name, not host path, which is why volumes are portable across hosts and the right default for databases and app state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind mounts: your host directory, mapped in
&lt;/h2&gt;

&lt;p&gt;A bind mount points a container path straight at a directory you control on the host. No Docker management, no abstraction — it's your filesystem, exposed inside the container.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;/srv/appdata,target&lt;span class="o"&gt;=&lt;/span&gt;/app &lt;span class="se"&gt;\&lt;/span&gt;
  node:20 npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shines for local development. Mount your source tree in and edits on the host show up live inside the container, so a file-watcher reloads without a rebuild. It's also the natural way to feed in a config file.&lt;/p&gt;

&lt;p&gt;The tradeoff is coupling. A bind mount hard-wires the container to your host's layout — &lt;code&gt;/srv/appdata&lt;/code&gt; has to exist, with the right contents and permissions, on every machine that runs this. That portability cost is the whole reason volumes exist. My rule: bind mounts for dev convenience and config, named volumes for anything that's real state.&lt;/p&gt;

&lt;h2&gt;
  
  
  tmpfs: in-memory and gone on stop
&lt;/h2&gt;

&lt;p&gt;Sometimes you want scratch space that never touches disk — a secret you don't want persisted, or a hot temp directory. A tmpfs mount lives in RAM and vanishes when the container stops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; cache &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tmpfs,target&lt;span class="o"&gt;=&lt;/span&gt;/tmp/scratch &lt;span class="se"&gt;\&lt;/span&gt;
  alpine &lt;span class="nb"&gt;sleep &lt;/span&gt;3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is written to the host filesystem. Use it for sensitive or throwaway data, not for anything you expect to find later.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;-v&lt;/code&gt; shorthand vs &lt;code&gt;--mount&lt;/code&gt; long form
&lt;/h2&gt;

&lt;p&gt;You'll see two syntaxes. The old &lt;code&gt;-v&lt;/code&gt; shorthand packs everything into one colon-separated string:&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;# named volume&lt;/span&gt;
docker run &lt;span class="nt"&gt;-v&lt;/span&gt; pgdata:/var/lib/postgresql/data postgres:16
&lt;span class="c"&gt;# bind mount&lt;/span&gt;
docker run &lt;span class="nt"&gt;-v&lt;/span&gt; /srv/appdata:/app node:20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newer &lt;code&gt;--mount&lt;/code&gt; form is explicit key=value:&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;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;pgdata,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/data postgres:16
docker run &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;/srv/appdata,target&lt;span class="o"&gt;=&lt;/span&gt;/app node:20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They do the same thing, but &lt;code&gt;-v&lt;/code&gt; has a sharp edge: with a bind mount, if the host path doesn't exist, &lt;code&gt;-v&lt;/code&gt; silently &lt;em&gt;creates it as an empty directory&lt;/em&gt; owned by root. &lt;code&gt;--mount&lt;/code&gt; errors out instead. I recommend &lt;code&gt;--mount&lt;/code&gt; for anything non-trivial — the verbosity buys you clarity and a loud failure when you get a path wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permissions gotcha everyone hits
&lt;/h2&gt;

&lt;p&gt;This is the one that eats an afternoon. The container process runs as some UID, and files on a bind mount are owned by whatever UID owns them on the host. Those two numbers don't have to agree, and when they don't, you get denied.&lt;/p&gt;

&lt;p&gt;Say the container runs as UID 1000 but &lt;code&gt;/srv/appdata&lt;/code&gt; is owned by root (UID 0):&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; 1000:1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;/srv/appdata,target&lt;span class="o"&gt;=&lt;/span&gt;/app &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo test &amp;gt; /app/out.txt'&lt;/span&gt;
&lt;span class="c"&gt;# sh: can't create /app/out.txt: Permission denied&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container isn't confused — the kernel is enforcing ownership by number. Docker doesn't translate UIDs across the boundary. The fix is to make the numbers line up. Either match the host directory's ownership to the container's UID:&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 chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the container as the UID that already owns the files:&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;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;        &lt;span class="c"&gt;# say this prints 1000&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&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;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;/srv/appdata,target&lt;span class="o"&gt;=&lt;/span&gt;/app &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo test &amp;gt; /app/out.txt &amp;amp;&amp;amp; echo ok'&lt;/span&gt;
&lt;span class="c"&gt;# ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mental model that saves you: a UID inside the container is the &lt;em&gt;same number&lt;/em&gt; as a UID on the host. There's no name mapping, only integers. Named volumes dodge much of this because Docker initializes their ownership from the image's expected user on first use — another reason they're the calmer default for stateful services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-only mounts for config
&lt;/h2&gt;

&lt;p&gt;If a mount only needs to be read — config files, certs, static assets — say so. Append &lt;code&gt;:ro&lt;/code&gt; (or &lt;code&gt;readonly&lt;/code&gt;) and the container can't modify it, which closes off a whole class of accidents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;/srv/appdata/config.yaml,target&lt;span class="o"&gt;=&lt;/span&gt;/app/config.yaml,readonly &lt;span class="se"&gt;\&lt;/span&gt;
  node:20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read-only by default for config is a good habit. If the app tries to write where it shouldn't, you find out immediately instead of silently corrupting a shared file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspecting and lifecycle
&lt;/h2&gt;

&lt;p&gt;List and inspect volumes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume &lt;span class="nb"&gt;ls
&lt;/span&gt;docker volume inspect pgdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see what a specific container has mounted, &lt;code&gt;docker inspect&lt;/code&gt; its Mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect db &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ json .Mounts }}'&lt;/span&gt;
&lt;span class="c"&gt;# [{"Type":"volume","Name":"pgdata","Source":"/var/lib/docker/volumes/pgdata/_data","Destination":"/var/lib/postgresql/data",...}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the danger. Every time you run a container that declares a volume without naming it, Docker creates an &lt;strong&gt;anonymous volume&lt;/strong&gt; — a random-hash name you'll never recognize. These pile up quietly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="nv"&gt;dangling&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# 8f3c...REDACTED&lt;/span&gt;
&lt;span class="c"&gt;# a91d...REDACTED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker volume prune&lt;/code&gt; cleans up unused volumes, and that's genuinely useful for the anonymous cruft. But it does not discriminate between "junk I forgot about" and "the volume holding data I care about but isn't attached right now." A stopped-and-removed database whose named volume is momentarily unreferenced can be swept away. Read the prompt, and never wire &lt;code&gt;prune&lt;/code&gt; into an unattended script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backing up a named volume
&lt;/h2&gt;

&lt;p&gt;Because a named volume is just a directory Docker manages, you back it up by mounting it into a throwaway container alongside a backup target and tarring it up. No special tooling.&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;# back up pgdata to ./backup/pgdata.tar.gz&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;pgdata,target&lt;span class="o"&gt;=&lt;/span&gt;/data,readonly &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/backup"&lt;/span&gt;,target&lt;span class="o"&gt;=&lt;/span&gt;/backup &lt;span class="se"&gt;\&lt;/span&gt;
  alpine &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/pgdata.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore is the mirror image — mount the (empty) target volume and unpack into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;pgdata,target&lt;span class="o"&gt;=&lt;/span&gt;/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;bind&lt;/span&gt;,source&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/backup"&lt;/span&gt;,target&lt;span class="o"&gt;=&lt;/span&gt;/backup &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'cd /data &amp;amp;&amp;amp; tar xzf /backup/pgdata.tar.gz'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a live database, quiesce or dump it first rather than tarring hot files — but as a pattern for volume data at rest, this is honest and dependency-free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same rules in Compose
&lt;/h2&gt;

&lt;p&gt;Nothing changes conceptually in Compose; the syntax just moves into YAML. A top-level &lt;code&gt;volumes:&lt;/code&gt; key declares named volumes, and a service can also bind-mount a host path — both resolve exactly as they do on the CLI.&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;db&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;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;          &lt;span class="c1"&gt;# named volume&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/config.yaml:/app/config.yaml:ro&lt;/span&gt;  &lt;span class="c1"&gt;# bind mount, read-only&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want more end-to-end setups like this — services wired to the right kind of storage, with the Compose files laid out — there are &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;worked Docker stack patterns to copy from&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;The one thing to carry with you: match the storage to the job. Named volumes for state you care about — databases, uploads, anything you'd cry over losing — because Docker manages them and they stay portable. Bind mounts for development and config, where being coupled to a host path is a feature, not a bug. tmpfs for secrets and scratch. And whatever holds your real data, prove you can restore it before you need to.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>It Works on My Machine: A Docker War Story About exec format error</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:45:41 +0000</pubDate>
      <link>https://dev.to/devopsaitoolkit/it-works-on-my-machine-a-docker-war-story-about-exec-format-error-oen</link>
      <guid>https://dev.to/devopsaitoolkit/it-works-on-my-machine-a-docker-war-story-about-exec-format-error-oen</guid>
      <description>&lt;p&gt;"It works on my machine" is the oldest joke in software, and containers were supposed to kill it. Same image everywhere, same behavior everywhere — that's the whole pitch. So there's a special kind of betrayal when a container that runs perfectly on your laptop lands in the cluster and dies instantly with four unhelpful words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exec /app/server: exec format error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the afternoon that error cost me, and the thing it turned out to be teaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Built the image locally on a shiny new laptop. Ran it locally — perfect. Pushed it, the deploy rolled out, and every pod went straight into &lt;code&gt;CrashLoopBackOff&lt;/code&gt;. &lt;code&gt;kubectl logs&lt;/code&gt; showed the line above and nothing else. No stack trace, no panic, no hint. The binary that ran fine thirty seconds ago on my machine refused to execute at all in prod.&lt;/p&gt;

&lt;p&gt;The maddening part, same as it always is: &lt;em&gt;the exact same image&lt;/em&gt;. That's the container promise. How can the same bytes run in one place and be unrunnable in another?&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell I walked right past
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;exec format error&lt;/code&gt; is the kernel's way of saying "I tried to execute this file and I don't recognize the format." Not "permission denied," not "not found" — &lt;em&gt;I literally cannot run this shape of binary.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And the shape of a binary that a kernel can or can't run is its &lt;strong&gt;CPU architecture&lt;/strong&gt;. My shiny new laptop was Apple Silicon — &lt;code&gt;arm64&lt;/code&gt;. The cluster nodes were &lt;code&gt;amd64&lt;/code&gt;. I'd built an &lt;code&gt;arm64&lt;/code&gt; binary, wrapped it in an image, and shipped it to machines that speak a different instruction set. Locally it ran because I was running it &lt;em&gt;on the architecture I built it for.&lt;/em&gt; The moment it hit an &lt;code&gt;amd64&lt;/code&gt; node, the kernel looked at my &lt;code&gt;arm64&lt;/code&gt; executable and said, correctly, "I don't know how to run this."&lt;/p&gt;

&lt;p&gt;Nothing was broken. Docker did exactly what I asked — it built an image for the platform I was on and faithfully shipped it. I just never told it that "the platform I'm on" and "the platform this runs on" were different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming it
&lt;/h2&gt;

&lt;p&gt;Two commands make it obvious:&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;# what architecture is this image built for?&lt;/span&gt;
docker image inspect myimage:tag &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Architecture}}'&lt;/span&gt;
&lt;span class="c"&gt;# arm64   ← there's the problem&lt;/span&gt;

&lt;span class="c"&gt;# what do the target nodes run?&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[*].status.nodeInfo.architecture}'&lt;/span&gt;
&lt;span class="c"&gt;# amd64 amd64 amd64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;arm64&lt;/code&gt; image, &lt;code&gt;amd64&lt;/code&gt; nodes. Mystery over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Stop building for "wherever I happen to be" and start building for where it runs. &lt;code&gt;docker buildx&lt;/code&gt; builds multi-arch images from a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &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/myimage:tag &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;Now the registry holds both architectures under one tag, and every node pulls the variant it can actually run. If you only ever deploy to &lt;code&gt;amd64&lt;/code&gt;, you can just pin that: &lt;code&gt;--platform linux/amd64&lt;/code&gt;. Either way, the key is that the build platform is now a &lt;em&gt;decision&lt;/em&gt;, not an accident of what laptop you bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was actually teaching
&lt;/h2&gt;

&lt;p&gt;The container promise isn't "the same image runs everywhere." It's "the same image runs everywhere &lt;em&gt;that shares the contract it was built against&lt;/em&gt;" — and CPU architecture is part of that contract, an invisible part that used to be uniform and quietly stopped being uniform the day ARM laptops got good.&lt;/p&gt;

&lt;p&gt;That's the pattern behind almost every "works on my machine" that survives containerization: some assumption from your environment rode along inside the image without you noticing — an architecture, a mounted file that only exists locally, an env var your shell sets and prod doesn't. The container didn't lie. It faithfully packaged your assumptions and carried them somewhere the assumptions weren't true.&lt;/p&gt;

&lt;p&gt;The fix is always the same discipline: make the invisible contract explicit. Build for the target, not the desk you're sitting at.&lt;/p&gt;




&lt;p&gt;I keep the full library of Docker gotchas like this one — the diagnostic commands, the root cause, the prevention — for the next time one of them eats an afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;Docker troubleshooting toolkit&lt;/a&gt;, and the &lt;a href="https://devopsaitoolkit.com/blog/docker-error-executable-file-not-found-in-path/" rel="noopener noreferrer"&gt;&lt;code&gt;executable file not found in $PATH&lt;/code&gt; guide&lt;/a&gt; for its close cousin (the &lt;em&gt;other&lt;/em&gt; "your binary won't run" error).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your favorite "same image, different result" story? The ARM-laptop-to-x86-cluster one has bitten a lot of people since about 2021 — I doubt I'm the last.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The 10 Docker Errors That Waste the Most Time (and the One-Line Fix)</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:54:11 +0000</pubDate>
      <link>https://dev.to/devopsaitoolkit/the-10-docker-errors-that-waste-the-most-time-and-the-one-line-fix-45mg</link>
      <guid>https://dev.to/devopsaitoolkit/the-10-docker-errors-that-waste-the-most-time-and-the-one-line-fix-45mg</guid>
      <description>&lt;p&gt;Docker is fantastic right up until it throws one of its greasy, context-free error messages at you and you lose twenty minutes to a thing that has a one-line fix. I've been collecting these — the exact strings, and the first thing to check for each. Here are the ten that eat the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The engine isn't reachable. In order of likelihood: the daemon isn't running (&lt;code&gt;systemctl status docker&lt;/code&gt;), you're not in the &lt;code&gt;docker&lt;/code&gt; group (&lt;code&gt;sudo usermod -aG docker $USER&lt;/code&gt;, then log out and back in), or you're pointing at the wrong &lt;code&gt;DOCKER_HOST&lt;/code&gt;. It's almost never Docker being broken — it's Docker not being &lt;em&gt;up&lt;/em&gt; or you not being &lt;em&gt;allowed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-cannot-connect-to-docker-daemon/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;no space left on device&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Docker hoards. Dangling images, stopped containers, unused volumes and build cache pile up on the Docker root disk. &lt;code&gt;docker system df&lt;/code&gt; shows you where it went; &lt;code&gt;docker system prune -a --volumes&lt;/code&gt; reclaims it (read what it'll delete first). If &lt;code&gt;df -h&lt;/code&gt; says you have space but Docker disagrees, you may be out of inodes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-no-space-left-on-device/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;Bind for 0.0.0.0:8080 failed: port is already allocated&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Something already owns that port — often a container you forgot was running. &lt;code&gt;docker ps&lt;/code&gt; to find it, or &lt;code&gt;ss -tlnp | grep 8080&lt;/code&gt; for a non-Docker process. Stop the holder or map to a different host port.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-port-is-already-allocated/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;pull access denied ... repository does not exist or may require 'docker login'&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Three flavors: the image name/tag is wrong, it's a private registry and you're not authenticated (&lt;code&gt;docker login&lt;/code&gt;), or you've hit Docker Hub's anonymous pull rate limit. The error says "does not exist OR requires login" for a reason — check both.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-pull-access-denied/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;You built the image for one CPU architecture and ran it on another — the classic Apple Silicon (&lt;code&gt;arm64&lt;/code&gt;) build landing on an &lt;code&gt;amd64&lt;/code&gt; server. Build multi-arch with &lt;code&gt;docker buildx&lt;/code&gt;, or pin &lt;code&gt;--platform&lt;/code&gt; to match your target.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;OCI runtime create failed&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A low-level container-start failure. The useful part is always &lt;em&gt;after&lt;/em&gt; the colon — a missing binary, a bad mount, a permissions problem. Read the full message; &lt;code&gt;OCI runtime create failed&lt;/code&gt; itself tells you nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-oci-runtime-create-failed/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. &lt;code&gt;executable file not found in $PATH&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Your &lt;code&gt;CMD&lt;/code&gt; or &lt;code&gt;ENTRYPOINT&lt;/code&gt; points at a binary the image doesn't have — often because a slim/distroless base doesn't ship a shell, or you assumed a tool was installed. Check exec-form vs shell-form and confirm the binary actually exists in the final layer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-executable-file-not-found-in-path/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Usually not a cert problem — it's a network path issue (a proxy, MTU, or firewall) between you and the registry, masquerading as TLS. Test raw connectivity before you touch certificates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopsaitoolkit.com/blog/docker-error-tls-handshake-timeout/" rel="noopener noreferrer"&gt;Full guide →&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. &lt;code&gt;failed to compute cache key: ... not found&lt;/code&gt; (COPY/ADD)
&lt;/h2&gt;

&lt;p&gt;Your Dockerfile is trying to &lt;code&gt;COPY&lt;/code&gt; a file that isn't in the build context — either the path is wrong, or &lt;code&gt;.dockerignore&lt;/code&gt; is excluding it. Remember paths are relative to the context root, not the Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. &lt;code&gt;Conflict. The container name "/x" is already in use&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A container with that name already exists (running or stopped). &lt;code&gt;docker rm x&lt;/code&gt; to remove the old one, or use &lt;code&gt;--rm&lt;/code&gt; / a fresh name. Common in CI where a previous run didn't clean up.&lt;/p&gt;




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

&lt;p&gt;Nearly every Docker error puts the &lt;em&gt;useful information after the colon&lt;/em&gt; and a generic category before it. &lt;code&gt;OCI runtime create failed&lt;/code&gt; is the category; the cause is the clause you skimmed past. Train yourself to read to the end of the line before you start googling.&lt;/p&gt;

&lt;p&gt;I keep complete guides for all of these — and about eighty more Docker errors — each with the diagnostic workflow, a worked root-cause example, and the prevention checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;Docker troubleshooting toolkit&lt;/a&gt; — the top errors, launcher, and runbooks in one place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which Docker error has personally cost you the most hours? Genuinely curious which of these tops the list for other people.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How I Cut a Docker Image From 1.2GB to 180MB</title>
      <dc:creator>James Joyner</dc:creator>
      <pubDate>Thu, 09 Jul 2026 02:32:56 +0000</pubDate>
      <link>https://dev.to/devopsaitoolkit/how-i-cut-a-docker-image-from-12gb-to-180mb-2ah8</link>
      <guid>https://dev.to/devopsaitoolkit/how-i-cut-a-docker-image-from-12gb-to-180mb-2ah8</guid>
      <description>&lt;p&gt;A while back I inherited a service whose Docker image was 1.2GB. Pulls were slow, the CI cache was useless, and the deploy step took long enough that people context-switched away and forgot about it. I got it down to about 180MB without changing a line of application code. Here's exactly what moved the needle, roughly in order of impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Multi-stage builds (the big one)
&lt;/h2&gt;

&lt;p&gt;The single biggest win. The original Dockerfile built the app and shipped the &lt;em&gt;entire build toolchain&lt;/em&gt; along with it — compilers, dev headers, the full package cache. None of that is needed at runtime.&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 — has all the heavy tooling&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;node:20&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; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&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;npm run build

&lt;span class="c"&gt;# runtime stage — starts clean, copies only the artifact&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime image never contains the build tools. That alone took roughly 500MB off.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pick a smaller base
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;node:20&lt;/code&gt; is Debian with everything. &lt;code&gt;node:20-slim&lt;/code&gt; drops a couple hundred MB. If your app is a static binary (Go, Rust) you can go all the way to &lt;code&gt;distroless&lt;/code&gt; or &lt;code&gt;scratch&lt;/code&gt; and ship &lt;em&gt;just the binary&lt;/em&gt; — no shell, no package manager, no OS to speak of. Smaller base = smaller image and a smaller attack surface, which your security team will also thank you for.&lt;/p&gt;

&lt;p&gt;The trade-off: distroless has no shell, so &lt;code&gt;docker exec ... sh&lt;/code&gt; won't work for debugging. Know that going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Order layers by how often they change
&lt;/h2&gt;

&lt;p&gt;Docker caches layers top-down and invalidates everything after the first change. If you &lt;code&gt;COPY . .&lt;/code&gt; before installing dependencies, &lt;em&gt;every code change busts your dependency cache&lt;/em&gt; and reinstalls everything.&lt;/p&gt;

&lt;p&gt;Copy your lockfile and install deps &lt;strong&gt;first&lt;/strong&gt;, then copy the rest of the source:&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="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci          &lt;span class="c"&gt;# cached until dependencies actually change&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .            # changes every commit, but deps stay cached&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This didn't shrink the final image much, but it turned a 4-minute rebuild into a 20-second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add a real &lt;code&gt;.dockerignore&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Without it, &lt;code&gt;COPY . .&lt;/code&gt; drags your entire &lt;code&gt;.git&lt;/code&gt; history, &lt;code&gt;node_modules&lt;/code&gt;, local &lt;code&gt;.env&lt;/code&gt; files, test fixtures, and CI logs into the build context — bloating the image &lt;em&gt;and&lt;/em&gt; leaking things you don't want baked into a layer.&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;git&lt;/span&gt;
&lt;span class="n"&gt;node_modules&lt;/span&gt;
*.&lt;span class="n"&gt;log&lt;/span&gt;
.&lt;span class="n"&gt;env&lt;/span&gt;*
&lt;span class="n"&gt;dist&lt;/span&gt;
&lt;span class="n"&gt;coverage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Collapse and clean up &lt;code&gt;RUN&lt;/code&gt; layers
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;RUN&lt;/code&gt; is a layer, and deleting files in a &lt;em&gt;later&lt;/em&gt; layer doesn't shrink the earlier one. Install, use, and clean up in a single &lt;code&gt;RUN&lt;/code&gt;:&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="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; some-tool &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rm&lt;/code&gt; has to be in the same &lt;code&gt;RUN&lt;/code&gt; as the &lt;code&gt;apt-get&lt;/code&gt;, or the cache still ships in the layer beneath it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;1.2 GB&lt;/td&gt;
&lt;td&gt;~180 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold pull&lt;/td&gt;
&lt;td&gt;~90s&lt;/td&gt;
&lt;td&gt;~12s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cached rebuild&lt;/td&gt;
&lt;td&gt;~4 min&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of this is exotic — it's multi-stage, a slimmer base, layer order, &lt;code&gt;.dockerignore&lt;/code&gt;, and cleaning up in place. But together they turn a deploy you dread into one you don't think about.&lt;/p&gt;

&lt;p&gt;If you want the deeper reference — including the Docker errors these optimizations sometimes surface (&lt;code&gt;no space left on device&lt;/code&gt;, cache-key failures, and friends) — I keep a full set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://devopsaitoolkit.com/stacks/docker/" rel="noopener noreferrer"&gt;Docker troubleshooting toolkit&lt;/a&gt; and the &lt;a href="https://devopsaitoolkit.com/blog/docker-error-no-space-left-on-device/" rel="noopener noreferrer"&gt;&lt;code&gt;no space left on device&lt;/code&gt; guide&lt;/a&gt; for when the build disk fills up mid-optimization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the smallest you've gotten a &lt;em&gt;real&lt;/em&gt; production image (not a hello-world)? Always looking for tricks I haven't tried.&lt;/p&gt;

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