<?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: Maruf</title>
    <description>The latest articles on DEV Community by Maruf (@marufing).</description>
    <link>https://dev.to/marufing</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%2F1066322%2F245eef45-91ea-4820-8dc3-2cd88ec2d81f.jpg</url>
      <title>DEV Community: Maruf</title>
      <link>https://dev.to/marufing</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marufing"/>
    <language>en</language>
    <item>
      <title>One item per row: designing bulk order creation</title>
      <dc:creator>Maruf</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:07:04 +0000</pubDate>
      <link>https://dev.to/marufing/one-item-per-row-designing-bulk-order-creation-4lh7</link>
      <guid>https://dev.to/marufing/one-item-per-row-designing-bulk-order-creation-4lh7</guid>
      <description>&lt;p&gt;A merchant who ships forty parcels a day doesn't want to fill in a form forty times. Most of them already keep those orders somewhere else, in a spreadsheet or an export from the store they sell on. What they need is a way to bring a day's orders in at once, fix whatever is wrong before anything is created, and never end up with the same order twice.&lt;/p&gt;

&lt;p&gt;That's the whole brief for bulk order creation. The interesting part is that the first design decision, what one row means, shapes everything after it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: the first plan put one order on a row and hid its items in a drawer. the shipped sheet puts one item on a row, the way the file already does, and sends the orders in one bulk request. &lt;a href="https://maruf.ing/writing/one-item-per-row" rel="noopener noreferrer"&gt;See it on maruf.ing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  the need behind it
&lt;/h2&gt;

&lt;p&gt;Three things matter to the merchant, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Getting the orders in.&lt;/strong&gt; Most of them arrive as a CSV, not typed by hand. If the import is fussy, people give up and go back to the one-by-one form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing problems before anything is created.&lt;/strong&gt; A wrong city or a missing phone number should be caught on the sheet, not discovered after a parcel is booked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No duplicates.&lt;/strong&gt; Creating forty orders is a batch. If the network drops halfway, retrying must not create the first twenty again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every decision below is in service of one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  first approach: one order per row
&lt;/h2&gt;

&lt;p&gt;My first sketch put &lt;strong&gt;one order on each row&lt;/strong&gt;. Recipient name, phone and address on the left, then a products cell showing a summary like "2 items". Clicking it opened a side drawer with the same product and variant picker the single-order screen uses.&lt;/p&gt;

&lt;p&gt;It looked tidy. An order is the thing the merchant is creating, so one row per order felt right.&lt;/p&gt;

&lt;p&gt;The problem showed up as soon as I thought about the import. The files merchants already have put &lt;strong&gt;one item on each row&lt;/strong&gt;, with the order reference repeated for a multi-item order. That's how most store and shipping exports work, Shopify's included. A sheet shaped differently from the file turns every import into a translation step, and every error points at a cell that doesn't exist in the merchant's own spreadsheet.&lt;/p&gt;

&lt;p&gt;So I dropped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  second approach: one item per row
&lt;/h2&gt;

&lt;p&gt;The sheet now has the same shape as the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one row is one line item&lt;/li&gt;
&lt;li&gt;adjacent rows with the same order reference are one order&lt;/li&gt;
&lt;li&gt;a row with a blank order reference is its own single-item order&lt;/li&gt;
&lt;li&gt;the same reference on rows that aren't next to each other is an error, not a silent merge&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order ref&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;th&gt;phone&lt;/th&gt;
&lt;th&gt;city&lt;/th&gt;
&lt;th&gt;sku&lt;/th&gt;
&lt;th&gt;qty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A-101&lt;/td&gt;
&lt;td&gt;Rahim&lt;/td&gt;
&lt;td&gt;017…&lt;/td&gt;
&lt;td&gt;Dhaka&lt;/td&gt;
&lt;td&gt;TSHIRT-L&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A-102&lt;/td&gt;
&lt;td&gt;Karim&lt;/td&gt;
&lt;td&gt;018…&lt;/td&gt;
&lt;td&gt;Dhaka&lt;/td&gt;
&lt;td&gt;MOUSE-01&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A-102&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;CABLE-1M&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This also lines up with the backend. Each group of rows becomes exactly the payload the single-order form already sends, so the server doesn't need a second idea of what an order is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drawback&lt;/strong&gt; is readability. Once an order spans rows, it's easy to lose track of where one ends and the next begins, and recipient details repeated on every row look like separate orders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Order-level cells (recipient, address, amount to collect) show only on an order's first row. The rows below stay empty, so a total never looks like it's charged twice. The order reference repeats in a muted color as the group marker.&lt;/li&gt;
&lt;li&gt;A bracket in the row-number column marks each multi-item order, inset at both ends so two neighbouring orders stay visibly separate. Dividers are dashed inside an order and solid between orders.&lt;/li&gt;
&lt;li&gt;No merged cells. &lt;code&gt;rowSpan&lt;/code&gt; looks like the natural tool, but inserting rows, keeping row keys stable and sorting all get harder once cells span rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  an import that doesn't give up
&lt;/h2&gt;

&lt;p&gt;A strict importer is easy to build and miserable to use: one unexpected column header and the whole file is rejected. Since getting orders in is the first need, the import here never refuses a file. It loads what it can and marks the rest on the sheet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Headers&lt;/strong&gt; are matched ignoring case, spaces and punctuation, so &lt;code&gt;Phone&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt; and &lt;code&gt;Phone (*)&lt;/code&gt; all land in the same column. Columns the sheet doesn't know are ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Common aliases&lt;/strong&gt; map to the same field. A product can arrive as &lt;code&gt;sku&lt;/code&gt; or as a free-text &lt;code&gt;item name&lt;/code&gt;, a pickup point as &lt;code&gt;pickup location&lt;/code&gt; or &lt;code&gt;store&lt;/code&gt;, so files exported from different tools load without editing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown products&lt;/strong&gt; become custom line items named after the text. An item that isn't in the catalogue yet is still a real parcel, so it's not a reason to reject the order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locations&lt;/strong&gt; are matched against the real list. An exact match is fine. A close match ("banani dohs" for "Banani Old DOHS") is filled in and marked yellow for review. No match is red. When the city or zone is missing, an address parser fills it in from the street address, also yellow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown pickup locations&lt;/strong&gt; fall back to the merchant's default one, marked yellow until someone picks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe543n0oxv41infavgufx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe543n0oxv41infavgufx.png" alt="The bulk order sheet after an import" width="800" height="375"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The sheet after an import: A-102 is one order across two rows, a best-guess zone in yellow, a phone to fix in red, and an unknown product kept as a custom item. confirm stays off until the red cell is fixed.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;tip:&lt;/strong&gt; Red blocks the submit, yellow doesn't. A "go to error" button jumps to the top-most, left-most red cell, so fixing a file walks it in reading order.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  submitting the batch
&lt;/h2&gt;

&lt;p&gt;Once the sheet is clean, the orders go to the server in one request. The obvious shortcut, looping over the single-order endpoint, is slow for big batches and falls apart on exactly the third need: a request that times out after the server already created the order looks like a failure, and a retry creates a duplicate.&lt;/p&gt;

&lt;p&gt;So the batch gets its own endpoint, built on the same rules as the single one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /orders/bulk

{
  "orders": [
    { "idempotency_key": "A-101", "order": { ...the single-order payload... } },
    { "idempotency_key": "A-102", "order": { ...the single-order payload... } }
  ]
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;

{
  "results": [
    { "idempotency_key": "A-101", "status": "created", "order_id": "ord_8f2k" },
    { "idempotency_key": "A-102", "status": "failed",
      "errors": { "pickup_location": "this location is inactive" } }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each element is the exact single-order payload&lt;/strong&gt;, and the server runs the same create logic for each one. There's one definition of a valid order, not two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results are per order.&lt;/strong&gt; One bad order doesn't fail the batch. The response says which ones were created and which failed, and why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each order carries an idempotency key&lt;/strong&gt;, built from the merchant's order reference. If the same key comes back, the server returns the order it already created instead of making a new one, so a retry after a timeout is safe. Two guards make that hold up:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope the key and fingerprint the payload.&lt;/strong&gt; Merchants restart their numbering, so a fresh sheet can start at A-101 again. Store the key per merchant, together with a hash of the order payload. Same key and same payload returns the existing order. Same key with a different payload is a &lt;code&gt;409 Conflict&lt;/code&gt;, never a silent return of last week's order. Letting keys expire after a day or so also fits how sheets are used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only a created order claims the key.&lt;/strong&gt; Write the key in the same transaction that creates the order. If a failed attempt stored it, the fixed resubmit below would get the old failure back.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field errors map back to cells.&lt;/strong&gt; &lt;code&gt;pickup_location&lt;/code&gt; in the response turns that cell red on the sheet, with the server's message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the sheet, created orders lock and failed ones stay editable, so the merchant fixes only what failed and submits again. The keys make that second submit harmless for the orders that already went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  keeping the sheet fast
&lt;/h2&gt;

&lt;p&gt;A spreadsheet that lags while you type is worse than a form. The first version re-rendered every cell on every keystroke: fine with ten orders, sluggish with sixty. Two changes fixed it. Cells type into local state and commit to the sheet on a short pause or on blur, and rows are memoized so an edit only re-renders the row it touched. Validation and the address parser then run per committed value, not per key.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order fields live on the first row.&lt;/strong&gt; To change a recipient you edit the order's first row, not any row. That's the price of matching the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big orders get tall.&lt;/strong&gt; A forty-item wholesale order is forty rows. For a tool aimed at many small orders, that's acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial success needs a UI.&lt;/strong&gt; Per-order results are more honest than all-or-nothing, but the sheet has to show a status on every order and make "fix and resubmit" obvious.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  the lesson
&lt;/h2&gt;

&lt;p&gt;I started by designing the sheet around &lt;del&gt;what an order is&lt;/del&gt; what the file looks like. When most of the data arrives as a file, the file's shape is the interface. The database, the API and the validation can all adapt to it, and here they barely had to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://maruf.ing/writing/one-item-per-row" rel="noopener noreferrer"&gt;maruf.ing&lt;/a&gt;. I write about boring backend systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>api</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Patch the image, not the version</title>
      <dc:creator>Maruf</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:30:37 +0000</pubDate>
      <link>https://dev.to/marufing/patch-the-image-not-the-version-2a2</link>
      <guid>https://dev.to/marufing/patch-the-image-not-the-version-2a2</guid>
      <description>&lt;p&gt;October 21 was a busy day for anyone who runs servers. PHP fixed two critical remote code execution bugs. GitLab fixed one rated 10.0. Node.js shipped security releases for its 22, 24 and 26 lines, including an out-of-bounds write in the Brotli decoder that the advisory says can lead to remote code execution.&lt;/p&gt;

&lt;p&gt;Different stacks, same job: patch it, then prove the patch is running.&lt;/p&gt;

&lt;p&gt;The Node one looked like a one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- FROM node:22.12.0-alpine
&lt;/span&gt;&lt;span class="gi"&gt;+ FROM node:22.23.4-alpine
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump the number, rebuild, deploy. I did this on a set of containerized services that week, and the version bump was the easiest part. The real work was finding every place the version lived, noticing what else the new image changed, and checking which pods actually picked it up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram of the whole trip (① pick the patch, ② change every pin, ③ the image moves its OS packages too, ④ only rebuilt services get the fix, ⑤ deployed isn't running, so check the pods): &lt;a href="https://maruf.ing/writing/patch-the-image" rel="noopener noreferrer"&gt;see it on maruf.ing&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Patch now, upgrade later
&lt;/h2&gt;

&lt;p&gt;The advisory lists a fixed version per line: 22.23.4 for 22.x, 24.22.1 for 24.x. Both carry the same security fixes, so the only question is how much else you want to change today.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;stay on 22&lt;/th&gt;
&lt;th&gt;move to 24&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Change&lt;/td&gt;
&lt;td&gt;patch only&lt;/td&gt;
&lt;td&gt;major&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;APIs, native modules, deps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support ends&lt;/td&gt;
&lt;td&gt;Apr 30, 2027&lt;/td&gt;
&lt;td&gt;Apr 30, 2028&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I took the 22 patch. A security fix should ship fast, and if it rides along with a major upgrade, a failure could come from either change. The move to 24 is real work (native modules rebuild, deprecated APIs, dependency ranges) and gets its own change and its own testing.&lt;/p&gt;

&lt;p&gt;That doesn't make 24 optional. Node 22 has about six months of support left, so the patch buys time to do the upgrade properly, not a reason to skip it. And if your line is already end of life, the order flips: there is no patch to take.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Find every pin
&lt;/h2&gt;

&lt;p&gt;"Which Node do we run?" usually has several answers in one repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;FROM&lt;/code&gt; lines in each Dockerfile, often two per file in a multi-stage build&lt;/li&gt;
&lt;li&gt;the image the CI jobs build and test in&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;engines&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;.nvmrc&lt;/code&gt;, &lt;code&gt;.tool-versions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;dev containers and local compose files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These drift. A new service gets copied from an old template and keeps its old tag. CI tests on one version while production runs another. A fix applied to "the version" lands in some of these places and quietly misses the rest.&lt;/p&gt;

&lt;p&gt;So the first step is a search, not an edit:&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;# find-pins.sh&lt;/span&gt;
&lt;span class="c"&gt;# Every place a Node version might be pinned.&lt;/span&gt;
&lt;span class="c"&gt;# Review each hit; not every match is a runtime pin.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'node:[0-9]|"node":|nodejs'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Dockerfile*'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.y*ml'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'package.json'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'.nvmrc'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'.tool-versions'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hit to leave alone: &lt;code&gt;@types/node&lt;/code&gt; has its own version numbers and isn't the runtime.&lt;/p&gt;

&lt;p&gt;The longer fix is fewer pins. One build argument every Dockerfile reads, and CI and local tooling reading the same file. Then the next advisory is one edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The image is part of the change
&lt;/h2&gt;

&lt;p&gt;This was the part that surprised me.&lt;/p&gt;

&lt;p&gt;A tag like &lt;code&gt;node:22.23.4-alpine&lt;/code&gt; doesn't say which Alpine it's built on. It means whatever Alpine the maintainers build on today. The image I was replacing was built on an older one, so the new tag moved Alpine forward two releases.&lt;/p&gt;

&lt;p&gt;Each Alpine release ships its own versions of every system package. Anything your service installs with &lt;code&gt;apk add&lt;/code&gt; moves with it. For me that was &lt;code&gt;ffmpeg&lt;/code&gt;, which one service uses to process uploaded media: 6.1 on Alpine 3.21, 8.0 on 3.23. A Node security patch had become a major upgrade of a media tool, and nothing in the diff said ffmpeg.&lt;/p&gt;

&lt;p&gt;Yours might be ImageMagick, libvips, a Postgres client, &lt;code&gt;git&lt;/code&gt;, fonts or the CA certificates. Same thing: the tag you bumped says Node, and something else moved.&lt;/p&gt;

&lt;p&gt;A container image is a stack: base OS, system packages, runtime, your dependencies, your code. Changing the tag can move any layer under it. Call it base-image drift.&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;# Dockerfile&lt;/span&gt;
&lt;span class="c"&gt;# Floating base: Alpine moves when this tag is rebuilt.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22.23.4-alpine&lt;/span&gt;

&lt;span class="c"&gt;# Explicit base: only Node moves.&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22.23.4-alpine3.23&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming the Alpine release makes the change visible in review. It doesn't always let you keep the old base, since new Node patches are only published for the Alpine releases still being built on. When the old one isn't offered, the base move is part of the patch, and you test it that way. Debian tags like &lt;code&gt;-bookworm&lt;/code&gt; already name the release, so the OS stays put across Node patches, though its packages still pick up updates on rebuild.&lt;/p&gt;

&lt;p&gt;Either way, before shipping, compare the system packages your service actually uses in the old and new image. &lt;code&gt;apk info -v&lt;/code&gt; (Alpine) or &lt;code&gt;dpkg -l&lt;/code&gt; (Debian) in each is a quick diff.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;careful:&lt;/strong&gt; A major bump in a system tool can pass a smoke test and still behave differently: new defaults, removed flags, slower output. Running the service's exact command in a test container only proves the command runs. Push real input through it on staging (an upload, an import, a report) and compare the result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  4. Green isn't proof
&lt;/h2&gt;

&lt;p&gt;Two things can make a successful pipeline lie to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path filters.&lt;/strong&gt; Many pipelines rebuild only the services whose files changed. A service you didn't touch keeps its old image and its old Node. If a service gets its runtime from a shared base image instead of its own &lt;code&gt;FROM&lt;/code&gt; line, the filter won't see that it needs a rebuild. Touch every affected service or force a full rebuild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Deployed."&lt;/strong&gt; With Helm, &lt;code&gt;STATUS: deployed&lt;/code&gt; means the manifests were applied and the release was recorded. Without &lt;code&gt;--wait&lt;/code&gt;, it says nothing about whether new pods started, passed readiness, or replaced the old ones. A pod stuck on a bad image sits behind a release that says deployed.&lt;/p&gt;

&lt;p&gt;So check what is actually running:&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;# check-rollout.sh&lt;/span&gt;
&lt;span class="c"&gt;# Did the rollout finish, and which image runs?&lt;/span&gt;
kubectl rollout status deploy/api &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5m
kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[*].spec.containers[*].image}'&lt;/span&gt;
&lt;span class="c"&gt;# The runtime itself, from inside a live pod.&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; node &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first waits for the rollout. The second shows the image the pods started from. The third answers the original question from inside a running container. Do it for every affected service, not one sample.&lt;/p&gt;

&lt;p&gt;In my case the deploy log showed every release upgraded, but my cluster login had expired, so for a while that log was all I had. After logging back in I checked the pods, and they were running the patched version. Only then was it done. A deploy log is evidence that a deploy happened, not that the fix is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make the pipeline check it
&lt;/h2&gt;

&lt;p&gt;Checking every service by hand is tedious, which is a good sign it belongs in the pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rebuild everything that depends on a shared base image when that image changes&lt;/li&gt;
&lt;li&gt;deploy with &lt;code&gt;--wait&lt;/code&gt; and a timeout, so the pipeline fails when pods don't come up&lt;/li&gt;
&lt;li&gt;after rollout, read the runtime version from a pod and compare it to the expected one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For one service with no system packages, most of this collapses into bump, deploy, check. The list grows with the number of places the version lives and the number of things the image carries besides Node.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The advisory gives you a version number. It doesn't tell you where that number lives, what else moves with the image, or which pods picked it up.&lt;/p&gt;

&lt;p&gt;The version bump is one line. Proving it reached production is the work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://maruf.ing/writing/patch-the-image" rel="noopener noreferrer"&gt;maruf.ing&lt;/a&gt;. I write about boring backend systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>docker</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
