{"id":139,"date":"2026-04-22T13:02:31","date_gmt":"2026-04-22T13:02:31","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-what-i-actually-use\/"},"modified":"2026-04-22T13:02:31","modified_gmt":"2026-04-22T13:02:31","slug":"docker-best-practices-2026-what-i-actually-use","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-what-i-actually-use\/","title":{"rendered":"Docker best practices in 2026: the ones I actually use"},"content":{"rendered":"<p>Okay, confession: I was going to write this post two years ago. Then I dockerized a Laravel app, watched the image balloon to 1.8 GB, and decided I wasn&rsquo;t qualified to write about Docker yet. Two years and roughly a hundred Dockerfiles later, I have opinions. Not a lot of them. Most &ldquo;best practices&rdquo; lists read like someone paraphrased the Docker docs and added &ldquo;2026&rdquo; to the title. I&rsquo;d rather tell you the handful of things I actually do on every project, why, and the 2020-era advice I&rsquo;ve quietly stopped caring about.<\/p>\n<p>This is a working-developer list, not a conference talk. If you ship containers to production, some of this will be old news. Some of it should be.<\/p>\n<h2 id=\"multi-stage-builds-with-the-boring-layout-that-actually-works\">Multi-stage builds, with the boring layout that actually works<\/h2>\n<p>If you only take one thing from this post: <a href=\"https:\/\/docs.docker.com\/build\/building\/multi-stage\/\" rel=\"nofollow noopener\" target=\"_blank\">multi-stage builds<\/a> aren&rsquo;t optional. A builder stage compiles, a runtime stage copies just the artifact. The part that trips people up isn&rsquo;t the pattern. It&rsquo;s layering the pattern so the cache actually helps you.<\/p>\n<p>Here&rsquo;s the template I paste into almost every Node or Go project:<\/p>\n<pre><code class=\"language-dockerfile\"># --- deps stage: only changes when dependencies change\nFROM node:22-alpine AS deps\nWORKDIR \/app\nCOPY package.json package-lock.json .\/\nRUN npm ci\n\n# --- build stage: inherits cached deps\nFROM node:22-alpine AS build\nWORKDIR \/app\nCOPY --from=deps \/app\/node_modules .\/node_modules\nCOPY . .\nRUN npm run build\n\n# --- runtime stage: nothing but what production needs\nFROM node:22-alpine AS runtime\nWORKDIR \/app\nENV NODE_ENV=production\nCOPY --from=build \/app\/dist .\/dist\nCOPY --from=build \/app\/node_modules .\/node_modules\nCOPY package.json .\/\nUSER node\nCMD [&quot;node&quot;, &quot;dist\/index.js&quot;]\n<\/code><\/pre>\n<p>Three stages, not two. The <code>deps<\/code> stage is the point: it only rebuilds when <code>package.json<\/code> or the lockfile changes. Your app code can change every ten seconds and the dependency layer stays cached. In a Laravel project the same shape works with <code>composer install<\/code>. I used to inline the install step in the build stage and wonder why CI was slow. That&rsquo;s why.<\/p>\n<h2 id=\"pin-base-images-but-stop-pinning-by-digest-in-app-repos\">Pin base images, but stop pinning by digest in app repos<\/h2>\n<p>This one I&rsquo;ve gone back and forth on. The consensus advice is &ldquo;pin by SHA256 digest, always.&rdquo; I tried it. It made my life worse.<\/p>\n<p>What I do now: pin to a minor version tag (<code>node:22.11-alpine<\/code>, not <code>node:22-alpine<\/code> or <code>node:22.11.0-alpine@sha256:...<\/code>). You get reproducible-enough builds and still get patch updates when you rebuild. If you need byte-exact reproducibility for compliance or audited environments, you pin digests. But then you need <a href=\"https:\/\/docs.github.com\/en\/code-security\/dependabot\/dependabot-version-updates\/configuration-options-for-the-dependabot.yml-file#package-ecosystem\" rel=\"nofollow noopener\" target=\"_blank\">Dependabot digest updates<\/a> or something similar, because otherwise you&rsquo;ll sit on an unpatched base image for six months and not notice.<\/p>\n<p>The honest answer: pick a policy per repo. A payments service I maintain pins by digest with automated PRs. A throwaway internal tool pins to <code>:22-alpine<\/code> and rebuilds weekly via a scheduled CI job. Both are fine.<\/p>\n<h2 id=\"stop-running-as-root-actually-stop\">Stop running as root. Actually stop.<\/h2>\n<p>I know this is lecture material, but I still see it in code review weekly. Every Dockerfile should end with a non-root user. Node images ship with a <code>node<\/code> user ready to go, so it&rsquo;s one line:<\/p>\n<pre><code class=\"language-dockerfile\">USER node\n<\/code><\/pre>\n<p>For Go or Rust binaries where you control the image from scratch, create the user explicitly:<\/p>\n<pre><code class=\"language-dockerfile\">FROM alpine:3.20 AS runtime\nRUN addgroup -S app &amp;&amp; adduser -S -G app app\nCOPY --from=build --chown=app:app \/src\/target\/release\/myapp \/usr\/local\/bin\/\nUSER app\nENTRYPOINT [&quot;myapp&quot;]\n<\/code><\/pre>\n<p>The <a href=\"https:\/\/docs.docker.com\/engine\/security\/\" rel=\"nofollow noopener\" target=\"_blank\">Docker security docs<\/a> cover the reasoning better than I can. If you can&rsquo;t run as non-root because your app writes to <code>\/var\/lib\/something<\/code>, fix the app, don&rsquo;t skip the user. &ldquo;Temporary&rdquo; container permissions become permanent the moment something ships.<\/p>\n<h2 id=\"smaller-images-are-a-feature-not-a-flex\">Smaller images are a feature, not a flex<\/h2>\n<p>The size-obsession crowd loves posting screenshots of 8 MB Go containers. Fun, but the actual reason to care about image size is cold-start latency and pull costs on autoscaling. A 200 MB image pulled 40x a minute across a fleet is a real number on your AWS bill.<\/p>\n<p>What actually helps:<\/p>\n<ol>\n<li><strong>Alpine or distroless for the runtime stage.<\/strong> Alpine is fine for most things. Distroless (<code>gcr.io\/distroless\/nodejs22-debian12<\/code>, <code>gcr.io\/distroless\/static-debian12<\/code> for Go) is smaller and has a smaller attack surface, but you can&rsquo;t <code>docker exec -it ... sh<\/code> into it when debugging, which you will want to do eventually. Pick your trade-off. I use distroless for Go services and Alpine for Node.<\/li>\n<li><strong>Use <code>.dockerignore<\/code> like you mean it.<\/strong> <code>node_modules<\/code>, <code>.git<\/code>, <code>coverage\/<\/code>, <code>*.log<\/code>, local <code>.env<\/code> files. Every file you don&rsquo;t copy is one that can&rsquo;t bloat your image or leak into it. I&rsquo;ve pulled AWS keys out of other people&rsquo;s containers because their <code>.dockerignore<\/code> was empty.<\/li>\n<li><strong>Don&rsquo;t install build tooling in the runtime stage.<\/strong> No <code>npm install -g<\/code>, no <code>apt install build-essential<\/code>, no interactive shells. If you think you need curl to run a healthcheck, use <code>HEALTHCHECK<\/code> with your app&rsquo;s own <code>\/health<\/code> endpoint instead.<\/li>\n<\/ol>\n<p>The size flex is silly. The discipline behind the size is what matters. The <a href=\"https:\/\/docs.docker.com\/build\/building\/best-practices\/\" rel=\"nofollow noopener\" target=\"_blank\">Docker build best practices guide<\/a> is still the cleanest primer on this.<\/p>\n<h2 id=\"buildkit-features-id-have-to-be-pried-away-from\">BuildKit features I&rsquo;d have to be pried away from<\/h2>\n<p>Modern Docker (<code>buildx<\/code> \/ BuildKit) gives you two things that paid for themselves the first week.<\/p>\n<p><strong>Cache mounts<\/strong> let you cache things like <code>~\/.cache\/pip<\/code> or <code>\/root\/.npm<\/code> across builds without baking them into layers:<\/p>\n<pre><code class=\"language-dockerfile\">RUN --mount=type=cache,target=\/root\/.npm \\\n    npm ci\n<\/code><\/pre>\n<p>CI goes from 90 seconds of npm install to about 12. The <a href=\"https:\/\/docs.docker.com\/build\/cache\/optimize\/#use-cache-mounts\" rel=\"nofollow noopener\" target=\"_blank\">BuildKit cache mount docs<\/a> show the syntax for pip, cargo, apt, and go. Use it everywhere.<\/p>\n<p><strong>Secret mounts<\/strong> mean you can pass an <code>NPM_TOKEN<\/code> or a private registry key into a single <code>RUN<\/code> step without it landing in any layer:<\/p>\n<pre><code class=\"language-dockerfile\">RUN --mount=type=secret,id=npm_token \\\n    NPM_TOKEN=$(cat \/run\/secrets\/npm_token) npm ci\n<\/code><\/pre>\n<p>Pair this with <code>DOCKER_BUILDKIT=1<\/code> (default on recent Docker versions) and build-arg passing from CI. It replaces the old pattern of copying <code>.npmrc<\/code> in and deleting it later, which, spoiler, leaves the token in an intermediate layer anyway.<\/p>\n<h2 id=\"compose-files-are-still-where-real-projects-go-sideways\">Compose files are still where real projects go sideways<\/h2>\n<p>I&rsquo;ve become more relaxed about Kubernetes and more particular about <code>docker-compose.yml<\/code>. Two specific things I do now.<\/p>\n<p>First, explicit healthchecks on anything another service depends on. A Postgres container with <code>healthcheck: pg_isready<\/code> lets me use <code>depends_on.condition: service_healthy<\/code> so my app doesn&rsquo;t race the database at startup. Five lines of YAML. Has saved me more flaky CI runs than anything else.<\/p>\n<p>Second, I stopped sharing a single <code>docker-compose.yml<\/code> between local dev and production. Local gets <code>docker-compose.yml<\/code> plus <code>docker-compose.override.yml<\/code> (auto-merged), with bind mounts and debug ports. Production uses a <code>docker-compose.prod.yml<\/code> composed in explicitly with <code>-f<\/code>. Slightly more typing, and it avoids the &ldquo;works on my machine, breaks in staging because of a volume mount&rdquo; class of bug. I wrote a bit about keeping clean boundaries between environments in my <a href=\"https:\/\/abrarqasim.com\/blog\/api-design-best-practices-rest-2026\/\" rel=\"noopener\">API design notes<\/a>; the same instinct applies to infra.<\/p>\n<h2 id=\"things-i-used-to-do-that-ive-quietly-dropped\">Things I used to do that I&rsquo;ve quietly dropped<\/h2>\n<p>In the spirit of admitting things.<\/p>\n<ul>\n<li><strong>Fat <code>ENTRYPOINT<\/code> shell scripts that do migrations, seeding, and starting the app.<\/strong> Too clever. I split these into separate compose services now, or use init containers on Kubernetes. An <code>entrypoint.sh<\/code> that does three things fails in three places and logs in one.<\/li>\n<li><strong>Obsessing over the exact order of every <code>COPY<\/code> line.<\/strong> The 2019 blog posts treated layer ordering like a sacred rite. With BuildKit&rsquo;s DAG-based builds and cache mounts, micro-optimising layer order matters much less than it used to. Get the dependency step cached early and move on.<\/li>\n<li><strong>Shipping a single &ldquo;dev&rdquo; image with every tool installed.<\/strong> Delightful for a week, unmaintainable by month three. I use a small runtime image and a separate <code>dev<\/code> stage (via <code>target: dev<\/code> in compose) when I want hot reload and debuggers.<\/li>\n<\/ul>\n<h2 id=\"what-to-actually-do-this-week\">What to actually do this week<\/h2>\n<p>If your Dockerfiles are older than a year, open your most-deployed service and check three things.<\/p>\n<ol>\n<li>Are you on BuildKit? (<code>docker buildx version<\/code> should work.) If not, enable it. The cache-mount change alone is worth an afternoon.<\/li>\n<li>Does your image run as non-root? Grep your Dockerfile for <code>USER<\/code>. If there&rsquo;s no <code>USER<\/code> line, add one before your next deploy.<\/li>\n<li>Is your <code>.dockerignore<\/code> empty or missing? Write one. At minimum: <code>node_modules<\/code>, <code>.git<\/code>, <code>.env*<\/code>, <code>*.log<\/code>, <code>coverage\/<\/code>, <code>dist\/<\/code> if you build inside the image.<\/li>\n<\/ol>\n<p>That&rsquo;s a 20-minute PR for most projects and it&rsquo;ll outlive any 2026-flavoured &ldquo;top 10 Docker tips&rdquo; post. If you want a look at the work behind the opinions here, I keep notes on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">project page<\/a>; a lot of my consulting time goes to shipping containerised services for teams who don&rsquo;t want to think about Docker every sprint. The best Dockerfile is the one nobody on the team has opened in a year.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;ve shipped a hundred Dockerfiles over two years. Here are the Docker best practices that actually earned their keep, and the 2020-era tips I quietly dropped.<\/p>\n","protected":false},"author":2,"featured_media":138,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I've shipped a hundred Dockerfiles over two years. Here are the Docker best practices that actually earned their keep, and the 2020-era tips I quietly dropped.","rank_math_focus_keyword":"docker best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45],"tags":[49,128,127,129,80,125,126],"class_list":["post-139","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","tag-backend","tag-buildkit","tag-containers","tag-deployment","tag-devops","tag-docker","tag-dockerfile"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/139","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=139"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/139\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/138"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=139"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=139"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=139"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}