{"id":260,"date":"2026-05-21T05:01:33","date_gmt":"2026-05-21T05:01:33","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-what-i-stopped-doing-in-production\/"},"modified":"2026-05-21T05:01:33","modified_gmt":"2026-05-21T05:01:33","slug":"docker-best-practices-2026-what-i-stopped-doing-in-production","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/docker-best-practices-2026-what-i-stopped-doing-in-production\/","title":{"rendered":"Docker Best Practices in 2026: What I Stopped Doing in Production"},"content":{"rendered":"<p>I shipped a Dockerfile last March that I was quietly proud of. 47 lines, three build stages, non-root user, every base image pinned to a digest, and a 38MB final image. I felt clever.<\/p>\n<p>Then a teammate looked at it for three seconds and said &ldquo;why are you still doing <code>apt-get update<\/code> on a distroless image?&rdquo; Reader, I was not, but I had two other things in there that were just as silly. So I spent a weekend going through every production Dockerfile I owned and ripping out habits I&rsquo;d kept since 2021. Some were fine in 2021 and not fine now. Others were never fine, I just hadn&rsquo;t noticed.<\/p>\n<p>This post is the list. Not &ldquo;complete&rdquo; best practices, because I find those exhausting. Just the changes that actually moved a needle in production: build time, image size, security posture, or my sleep at 2 AM.<\/p>\n<h2 id=\"i-stopped-optimizing-image-size-below-50mb\">I stopped optimizing image size below 50MB<\/h2>\n<p>I&rsquo;ve watched developers spend a full day shaving 8MB off an image that ships once a week. It feels like work. It is not work.<\/p>\n<p>At our pull rates, going from 60MB to 52MB saved roughly 11 seconds a day across the whole fleet, and added an hour of Dockerfile complexity. The trade was bad and I should have known.<\/p>\n<p>What I actually optimize for now: a sensible base image, multi-stage builds where they cost nothing, and one good <code>.dockerignore<\/code>. If the image is under 150MB and CI pulls it in under five seconds, I stop. Done.<\/p>\n<p>Where size still matters: edge functions, Lambda containers, anything that pays per-image-MB on cold start. For a long-running server, it almost never does.<\/p>\n<p>One small heuristic. If your runtime image isn&rsquo;t already a <code>gcr.io\/distroless<\/code> variant or an <code>alpine<\/code>\/<code>slim<\/code> flavor, switching is usually worth a 20-minute experiment. Going from <code>alpine<\/code> to <code>scratch<\/code> is usually not, unless you genuinely have no shared libraries.<\/p>\n<h2 id=\"multi-stage-builds-are-still-the-best-habit-i-have\">Multi-stage builds are still the best habit I have<\/h2>\n<p>If you take one thing from this post, take this: separate build dependencies from runtime dependencies. Always.<\/p>\n<p>Here&rsquo;s what I used to write for a Go service:<\/p>\n<pre><code class=\"language-dockerfile\">FROM golang:1.22\nWORKDIR \/app\nCOPY . .\nRUN go build -o \/server .\/cmd\/server\nCMD [&quot;\/server&quot;]\n<\/code><\/pre>\n<p>About 1.1GB. Compilers, source code, the Go module cache, plus a layer of &ldquo;I&rsquo;ll clean this up later&rdquo; that I never did.<\/p>\n<p>Here&rsquo;s what I write now:<\/p>\n<pre><code class=\"language-dockerfile\"># Build stage\nFROM golang:1.22 AS build\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\/server .\/cmd\/server\n\n# Runtime stage\nFROM gcr.io\/distroless\/static-debian12\nCOPY --from=build \/out\/server \/server\nUSER nonroot:nonroot\nEXPOSE 8080\nENTRYPOINT [&quot;\/server&quot;]\n<\/code><\/pre>\n<p>Roughly 18MB. No shell, no package manager, no curl. If an attacker breaks in, there is nothing for them to run.<\/p>\n<p>The same pattern works for Rust (build on <code>rust:1.75<\/code>, copy the binary into <code>distroless\/cc<\/code>), Python via <code>python:3.12-slim<\/code> plus a venv copy, Node via <code>node:20-alpine<\/code> plus a <code>node_modules<\/code> copy. I covered similar production trade-offs in my <a href=\"https:\/\/abrarqasim.com\/blog\/go-web-frameworks-2026-what-i-actually-reach-for\/\" rel=\"noopener\">Go web frameworks post<\/a>, where the deploy story is almost always &ldquo;containerize and ship&rdquo;.<\/p>\n<h2 id=\"i-stopped-writing-latest-anywhere\">I stopped writing <code>latest<\/code> anywhere<\/h2>\n<p>I used <code>:latest<\/code> for years. The argument was speed: pin once, get updates automatically. The reality was that two of our environments would run different versions of the same image because they pulled at different times. That bug took me four hours to find. I haven&rsquo;t trusted <code>:latest<\/code> since.<\/p>\n<p>The fix is boring. Pin by digest in production:<\/p>\n<pre><code class=\"language-dockerfile\">FROM golang:1.22.3@sha256:e5d6326abc...\n<\/code><\/pre>\n<p>For local dev, version tags like <code>:1.22-bookworm<\/code> are fine. The official <a href=\"https:\/\/docs.docker.com\/build\/building\/best-practices\/\" rel=\"nofollow noopener\" target=\"_blank\">Docker best practices guide<\/a> has been saying this for years and I ignored it because pinning felt like extra work. It is extra work. It is also the only way &ldquo;works on my machine&rdquo; stops being a sentence anyone has to say.<\/p>\n<p>Renovate or Dependabot handles the actual updates. I just review the PRs. Total time cost: about ten minutes a week. Time saved when an incident isn&rsquo;t caused by a silent base-image bump: incalculable, mostly because I haven&rsquo;t had one of those incidents since.<\/p>\n<h2 id=\"healthchecks-i-now-write-differently\">Healthchecks I now write differently<\/h2>\n<p>For a long time my healthchecks looked like this:<\/p>\n<pre><code class=\"language-dockerfile\">HEALTHCHECK --interval=30s --timeout=3s \\\n  CMD curl -f http:\/\/localhost:8080\/health || exit 1\n<\/code><\/pre>\n<p>Two problems. First, <code>curl<\/code> isn&rsquo;t in distroless images, so this either silently fails or forces me to add curl back, which defeats the purpose. Second, <code>\/health<\/code> for most of my services was a trivial endpoint that returned 200 whether the database was reachable or not. Docker thought the container was healthy while the app was returning 503s to real traffic.<\/p>\n<p>Now I do two things. I expose a tiny healthcheck binary inside the image, a small Go program that calls the real <code>\/ready<\/code> endpoint over a unix socket. And I make <code>\/ready<\/code> actually check the things that matter: database connection, downstream service ping, queue connectivity. Liveness stays cheap. Readiness gets honest.<\/p>\n<p>If you&rsquo;re running on Kubernetes, the Dockerfile <code>HEALTHCHECK<\/code> is mostly ignored anyway in favor of liveness and readiness probes. The principle still applies. Make readiness check the things whose failure means &ldquo;stop sending traffic here&rdquo;, and keep liveness dumb.<\/p>\n<h2 id=\"docker-compose-anti-patterns-i-dropped\">docker-compose anti-patterns I dropped<\/h2>\n<p>Compose is where I had the most embarrassing habits. Three I burned:<\/p>\n<p>Bind-mounting <code>node_modules<\/code> from the host. I did this thinking it&rsquo;d speed up dev. It mostly produced platform mismatches when my Mac mounted Linux containers. The fix: use a named volume for <code>node_modules<\/code> and bind-mount only the source. Faster, fewer surprises.<\/p>\n<p><code>restart: always<\/code> on services that should not restart always. Migrations, init scripts, seed jobs. These should be <code>restart: no<\/code> or a one-shot service. I had a migration container in a restart loop for an hour once. Cost me a Saturday afternoon and a chunk of my dignity.<\/p>\n<p>Not using profiles for optional services. I&rsquo;d comment out the <code>mailpit<\/code> service whenever I didn&rsquo;t need email locally, then forget to comment it back in. Profiles solve this:<\/p>\n<pre><code class=\"language-yaml\">services:\n  mailpit:\n    image: axllent\/mailpit\n    profiles: [&quot;email&quot;]\n<\/code><\/pre>\n<p><code>docker compose up<\/code> skips it. <code>docker compose --profile email up<\/code> includes it. No more commented-out blocks rotting in version control.<\/p>\n<h2 id=\"security-scanning-is-cheap-and-i-should-have-started-years-ago\">Security scanning is cheap, and I should have started years ago<\/h2>\n<p>For ages I had no scanning in my pipeline. The reason was that the tooling felt heavy, and I was scared of what I&rsquo;d find. Both excuses aged poorly.<\/p>\n<p>In 2026, a basic scan is one line of CI:<\/p>\n<pre><code class=\"language-yaml\">- name: Scan image\n  run: docker scout cves --exit-code --only-severity critical,high $IMAGE\n<\/code><\/pre>\n<p><a href=\"https:\/\/docs.docker.com\/scout\/\" rel=\"nofollow noopener\" target=\"_blank\">Docker Scout<\/a> ships with the CLI. Snyk, Trivy, and Grype all work too. The <a href=\"https:\/\/snyk.io\/blog\/10-docker-image-security-best-practices\/\" rel=\"nofollow noopener\" target=\"_blank\">Snyk Docker security cheat sheet<\/a> is a decent primer if you&rsquo;ve never thought about this layer.<\/p>\n<p>What I won&rsquo;t pretend: most scan findings on a typical image are low-impact. CVE noise is real. The ones I care about are CRITICAL with a known exploit, and HIGH on packages that actually run in the request path. Everything else goes in a tracking issue and gets batched into a base-image bump.<\/p>\n<h2 id=\"what-id-do-if-i-were-starting-fresh\">What I&rsquo;d do if I were starting fresh<\/h2>\n<p>If you&rsquo;re setting up Docker for a service in 2026 and want one config to copy, here&rsquo;s the short version: multi-stage Dockerfile, non-root user, distroless or slim runtime, a real readiness endpoint, digest-pinned base images, Scout in CI. Six things. You&rsquo;re past 90% of teams I&rsquo;ve audited, and you didn&rsquo;t have to think about it more than once.<\/p>\n<p>If you want one thing to try this week: pick your most-deployed service, run <code>docker scout cves --only-severity critical,high<\/code> against the current image, and see what falls out. I bet it&rsquo;s either nothing, in which case you&rsquo;ve earned a small celebration, or one obvious issue you can fix in an afternoon. You can see more of the production trade-offs I tend to chew on in <a href=\"https:\/\/abrarqasim.com\/work\/\" rel=\"noopener\">the rest of my work<\/a>.<\/p>\n<p>The point isn&rsquo;t a perfect Dockerfile. It&rsquo;s stopping the habits that don&rsquo;t pay rent.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Six Docker habits I dropped from production in 2026, the ones I kept, and the small changes that cut image size, build time, and CVE noise the most.<\/p>\n","protected":false},"author":2,"featured_media":259,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Six Docker habits I dropped from production in 2026, the ones I kept, and the small changes that cut image size, build time, and CVE noise the most.","rank_math_focus_keyword":"docker best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,302],"tags":[305,127,80,304,125,303,126],"class_list":["post-260","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-devops","tag-ci-cd","tag-containers","tag-devops","tag-distroless","tag-docker","tag-docker-compose","tag-dockerfile"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/260","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=260"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/260\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/259"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=260"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=260"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=260"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}