{"id":368,"date":"2026-06-25T13:01:49","date_gmt":"2026-06-25T13:01:49","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-dockerfile-habits-i-finally-killed\/"},"modified":"2026-06-25T13:01:49","modified_gmt":"2026-06-25T13:01:49","slug":"docker-best-practices-2026-dockerfile-habits-i-finally-killed","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-dockerfile-habits-i-finally-killed\/","title":{"rendered":"Docker Best Practices 2026: The Dockerfile Habits I Finally Killed"},"content":{"rendered":"<p>Okay, confession time: until about eight months ago, my Dockerfiles were embarrassing. The kind of thing I&rsquo;d write, ship to staging, and quietly pray nobody from the platform team would <code>cat<\/code> them. They worked. They were also bloated, slow to build, and running as root.<\/p>\n<p>What changed wasn&rsquo;t a new tool. It was a long Tuesday afternoon where a colleague pulled up one of my images, ran <code>docker history<\/code> on it, and asked, very politely, why a Node.js API needed 1.4 GB of base image and a full apt cache. I had no answer. I went home and read the Docker BuildKit docs that night. This post is what I should have done years ago.<\/p>\n<p>If you ship containers and you&rsquo;ve been copy-pasting the same Dockerfile shape since 2021, this is for you. None of this is exotic. It&rsquo;s the stuff I should have been doing the whole time.<\/p>\n<h2 id=\"picking-the-right-base-image-stop-reaching-for-ubuntulatest\">Picking the right base image (stop reaching for <code>ubuntu:latest<\/code>)<\/h2>\n<p>The first habit I killed was using full distro base images out of inertia. <code>ubuntu:latest<\/code> is over 75 MB compressed before you install anything. Add Node, your app, npm cache leftovers, and you&rsquo;re looking at 1 GB+ images for what is essentially a 50-line HTTP server.<\/p>\n<p>What I do now: start from the smallest base that actually works for the runtime. For Node, that&rsquo;s <code>node:22-alpine<\/code> (about 50 MB) or, if I want glibc, <code>node:22-slim<\/code>. For Go binaries, <code>gcr.io\/distroless\/static-debian12<\/code> is around 2 MB. For Python, <code>python:3.13-slim<\/code>.<\/p>\n<p>Here&rsquo;s the before:<\/p>\n<pre><code class=\"language-dockerfile\">FROM ubuntu:24.04\nRUN apt-get update &amp;&amp; apt-get install -y nodejs npm\nWORKDIR \/app\nCOPY . .\nRUN npm install\nCMD [&quot;node&quot;, &quot;server.js&quot;]\n<\/code><\/pre>\n<p>After:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-alpine\nWORKDIR \/app\nCOPY package*.json .\/\nRUN npm ci --omit=dev\nCOPY . .\nCMD [&quot;node&quot;, &quot;server.js&quot;]\n<\/code><\/pre>\n<p>That swap alone took one of my images from 1.2 GB to about 180 MB. The Docker docs have a <a href=\"https:\/\/docs.docker.com\/build\/building\/best-practices\/#choose-the-right-base-image\" rel=\"nofollow noopener\" target=\"_blank\">good page on choosing a base image<\/a> that I wish I&rsquo;d read sooner.<\/p>\n<p>One caveat I had to learn the hard way: Alpine uses musl, not glibc. If your app pulls in a native module compiled against glibc (looking at you, certain Node native deps), you&rsquo;ll get cryptic runtime errors. When that bites, fall back to <code>-slim<\/code> rather than fighting it.<\/p>\n<h2 id=\"multi-stage-builds-for-anything-compiled\">Multi-stage builds for anything compiled<\/h2>\n<p>The second habit was shipping build tools into production. If you have a step like <code>npm install<\/code>, <code>cargo build<\/code>, or <code>go build<\/code> in the same stage as your final image, you&rsquo;re paying for compilers and source files at runtime forever.<\/p>\n<p>Multi-stage builds fix this in maybe ten extra lines. Build everything in a fat builder stage, copy the artifact into a clean final stage, ship the final stage. Here&rsquo;s a Go example I use:<\/p>\n<pre><code class=\"language-dockerfile\"># Builder\nFROM golang:1.23-alpine AS builder\nWORKDIR \/src\nCOPY go.mod go.sum .\/\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -ldflags=&quot;-s -w&quot; -o \/out\/app .\/cmd\/api\n\n# Runtime\nFROM gcr.io\/distroless\/static-debian12\nCOPY --from=builder \/out\/app \/app\nUSER nonroot:nonroot\nENTRYPOINT [&quot;\/app&quot;]\n<\/code><\/pre>\n<p>The final image is about 8 MB. No shell, no package manager, no compiler. That last bit also matters for security, not only size. If there&rsquo;s no <code>sh<\/code>, an attacker who pops your container can&rsquo;t drop into a shell. The <a href=\"https:\/\/github.com\/GoogleContainerTools\/distroless\" rel=\"nofollow noopener\" target=\"_blank\">distroless project<\/a> is run by Google and is what I reach for whenever I&rsquo;m shipping a compiled binary.<\/p>\n<p>I held off on distroless for a while because I was scared of debugging. The trick: there&rsquo;s a <code>:debug<\/code> variant of each distroless image that includes a busybox shell. Use it for triage, then switch back.<\/p>\n<h2 id=\"cache-mounts-and-dockerignore-the-boring-ci-wins\">Cache mounts and <code>.dockerignore<\/code> (the boring CI wins)<\/h2>\n<p>Once builds got smaller, I noticed they were still slow on CI. The culprit was usually the same: I was copying my entire repo into the build context, which busted layer caching every time a README changed.<\/p>\n<p>Two fixes, both boring, both huge.<\/p>\n<p>First, write a real <code>.dockerignore<\/code>. Mine for a typical Node project now looks like:<\/p>\n<pre><code>node_modules\n.git\n.env\n.env.*\n*.log\ncoverage\/\n.next\/cache\n.vscode\n.idea\nREADME.md\n<\/code><\/pre>\n<p>That alone shaved 40 seconds off my cold builds because Docker stopped uploading my local <code>node_modules<\/code> into the context.<\/p>\n<p>Second, use BuildKit cache mounts for package managers. This is the single feature I wish I&rsquo;d known about two years earlier:<\/p>\n<pre><code class=\"language-dockerfile\"># syntax=docker\/dockerfile:1.7\nFROM node:22-alpine\nWORKDIR \/app\nCOPY package*.json .\/\nRUN --mount=type=cache,target=\/root\/.npm \\\n    npm ci --omit=dev\nCOPY . .\nCMD [&quot;node&quot;, &quot;server.js&quot;]\n<\/code><\/pre>\n<p>The <code>--mount=type=cache<\/code> line tells BuildKit to keep <code>npm<\/code>&rsquo;s cache between builds, even across CI runners that share the cache backend. My CI install step went from 90 seconds to about 12. There&rsquo;s a <a href=\"https:\/\/docs.docker.com\/build\/cache\/optimize\/\" rel=\"nofollow noopener\" target=\"_blank\">BuildKit cache optimisation page<\/a> that covers this and the related <code>--mount=type=bind<\/code> for read-only sources.<\/p>\n<h2 id=\"stop-running-as-root\">Stop running as root<\/h2>\n<p>This was the habit I felt worst about once I noticed it. By default, processes in a container run as root. If your app gets popped, the attacker has root inside the container, which is not as bad as root on the host but still much worse than necessary.<\/p>\n<p>Switch to a non-root user. Most official images already give you one:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-alpine\nWORKDIR \/app\nCOPY --chown=node:node package*.json .\/\nRUN npm ci --omit=dev\nCOPY --chown=node:node . .\nUSER node\nCMD [&quot;node&quot;, &quot;server.js&quot;]\n<\/code><\/pre>\n<p>The <code>node<\/code> user ships in the Node Alpine images. For your own images, add a user explicitly:<\/p>\n<pre><code class=\"language-dockerfile\">RUN addgroup -S app &amp;&amp; adduser -S app -G app\nUSER app\n<\/code><\/pre>\n<p>This is a one-line change that closes a real security gap, and I cannot defend why I waited so long to make it standard. The same rule I follow when I&rsquo;m picking infrastructure defaults in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work on backend systems<\/a> applies here: the secure default beats the convenient one almost every time.<\/p>\n<h2 id=\"pin-versions-pin-digests-for-things-you-care-about\">Pin versions, pin digests for things you care about<\/h2>\n<p><code>FROM node:latest<\/code> is convenient and wrong. Latest tags drift. A reproducible build means the same Dockerfile produces the same image six months from now.<\/p>\n<p>At minimum, pin to a major version:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-alpine\n<\/code><\/pre>\n<p>For anything sensitive, pin the image digest:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22-alpine@sha256:abc123...\n<\/code><\/pre>\n<p>Yes, it&rsquo;s ugly. Yes, it means you have to update digests when you bump versions. Renovate and Dependabot both handle this automatically now. The payoff is that a CVE landing in a base image doesn&rsquo;t silently make its way into your prod build on the next deploy.<\/p>\n<h2 id=\"cmd-vs-entrypoint-and-why-i-now-mostly-use-entrypoint\"><code>CMD<\/code> vs <code>ENTRYPOINT<\/code> (and why I now mostly use <code>ENTRYPOINT<\/code>)<\/h2>\n<p>For a long time I used <code>CMD<\/code> for everything and never thought about it. Then a colleague ran <code>docker run myimage --debug<\/code> and was surprised when <code>--debug<\/code> replaced the whole command. That&rsquo;s because <code>CMD<\/code> is the default command and gets clobbered by anything you pass to <code>docker run<\/code>.<\/p>\n<p><code>ENTRYPOINT<\/code> is the binary you always want to run. <code>CMD<\/code> is the default arguments. If you write:<\/p>\n<pre><code class=\"language-dockerfile\">ENTRYPOINT [&quot;node&quot;, &quot;server.js&quot;]\nCMD [&quot;--port=3000&quot;]\n<\/code><\/pre>\n<p>then <code>docker run myimage<\/code> runs <code>node server.js --port=3000<\/code>, and <code>docker run myimage --port=4000<\/code> runs <code>node server.js --port=4000<\/code>. That&rsquo;s almost always what I actually want. There&rsquo;s a <a href=\"https:\/\/docs.docker.com\/reference\/dockerfile\/#understand-how-cmd-and-entrypoint-interact\" rel=\"nofollow noopener\" target=\"_blank\">decent summary in the official Dockerfile reference<\/a> if you want the full interaction table.<\/p>\n<h2 id=\"healthchecks-that-actually-mean-something\">Healthchecks that actually mean something<\/h2>\n<p>I used to skip healthchecks because they felt like ceremony. Then I had an outage where my container was running but the app was deadlocked on a database connection, and the orchestrator happily kept routing traffic to it. A <code>HEALTHCHECK<\/code> would have caught it.<\/p>\n<pre><code class=\"language-dockerfile\">HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \\\n  CMD wget --quiet --tries=1 --spider http:\/\/localhost:3000\/healthz || exit 1\n<\/code><\/pre>\n<p>The trick is that the healthcheck endpoint should actually exercise the thing you care about. A <code>\/healthz<\/code> that returns <code>200 OK<\/code> whether the database is connected or not is lying to your orchestrator. Mine does a cheap query against the primary connection pool and a Redis ping.<\/p>\n<h2 id=\"whats-still-on-my-list\">What&rsquo;s still on my list<\/h2>\n<p>I&rsquo;m not done. Two things I&rsquo;m working on next.<\/p>\n<p>First, moving more of my Compose files toward production-grade defaults. I covered some of this thinking in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/pgcat-vs-pgbouncer-2026-when-i-actually-switched\" rel=\"noopener\">when I finally switched from pgbouncer to pgcat<\/a>, and the same instinct applies: stop trusting defaults that were written for the easy case.<\/p>\n<p>Second, image signing with cosign. I have it set up on one project and it works fine, but I haven&rsquo;t made it standard yet. That&rsquo;s the next habit to add, not kill.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Pick one image you maintain. Run:<\/p>\n<pre><code>docker history &lt;image&gt;\ndocker images &lt;image&gt; --format &quot;{{.Size}}&quot;\n<\/code><\/pre>\n<p>If the image is over 200 MB for a typical web app, switch to a smaller base. If the final stage contains compilers, add a multi-stage split. If your CI install step takes longer than 30 seconds on a warm cache, add a <code>--mount=type=cache<\/code>. Pick one. Ship it Friday. That&rsquo;s how I worked through the list above, and it&rsquo;s the only way I&rsquo;d recommend to anyone else.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Seven Dockerfile habits I changed after my images got embarrassing: smaller bases, multi-stage builds, cache mounts, non-root users, and pinned digests.<\/p>\n","protected":false},"author":2,"featured_media":367,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Seven Dockerfile habits I changed after my images got embarrassing: smaller bases, multi-stage builds, cache mounts, non-root users, and pinned digests.","rank_math_focus_keyword":"docker best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302],"tags":[128,127,80,125,126],"class_list":["post-368","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","tag-buildkit","tag-containers","tag-devops","tag-docker","tag-dockerfile"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/368","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=368"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/368\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/367"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=368"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=368"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=368"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}