{"id":561,"date":"2026-08-08T13:01:53","date_gmt":"2026-08-08T13:01:53","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/docker-multi-stage-builds-the-1-2gb-image-i-stopped-shipping\/"},"modified":"2026-08-08T13:01:53","modified_gmt":"2026-08-08T13:01:53","slug":"docker-multi-stage-builds-the-1-2gb-image-i-stopped-shipping","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/docker-multi-stage-builds-the-1-2gb-image-i-stopped-shipping\/","title":{"rendered":"Docker Multi-Stage Builds: The 1.2GB Image I Stopped Shipping"},"content":{"rendered":"<p>I want to tell you about the day I found out our &ldquo;small Go microservice&rdquo; was shipping to production as a 1.2GB Docker image. A deploy that normally takes twenty seconds took four minutes, and I went digging for the cause like it was somebody else&rsquo;s fault. It wasn&rsquo;t. The Dockerfile was mine, and the 900MB of Go toolchain riding along to production was very much mine.<\/p>\n<p>The fix took fifteen minutes and one feature: the multistage build. It has been in Docker since version 17.05, which is to say, since 2017. I&rsquo;d read about it. I&rsquo;d nodded along at conference talks about it. I just hadn&rsquo;t done it, because the fat image worked fine and nothing forces you to look at image sizes until something hurts.<\/p>\n<p>Short version for the impatient: compile your app in one stage, copy the result into a tiny runtime stage, and ship only that second stage. If you want the actual Dockerfiles and the traps I hit along the way, keep reading.<\/p>\n<h2 id=\"what-a-multistage-build-actually-does\">What a multistage build actually does<\/h2>\n<p>A Dockerfile can contain more than one <code>FROM<\/code> line. Each <code>FROM<\/code> starts a new stage with a fresh filesystem, and later stages can pull files out of earlier ones with <code>COPY --from<\/code>. The final image contains only the layers of the last stage. Everything else, the compiler, the package caches, your source code, gets discarded when the build finishes.<\/p>\n<p>That&rsquo;s the entire trick. The <a href=\"https:\/\/docs.docker.com\/build\/building\/multi-stage\/\" rel=\"nofollow noopener\" target=\"_blank\">official multi-stage build docs<\/a> explain it in about a page, which is roughly the length the feature deserves. It matters because build dependencies and runtime dependencies are wildly different sizes. The <code>golang<\/code> base image is over 800MB. A compiled Go binary is often under 15MB. Shipping the first to run the second is like mailing someone a cake inside the oven you baked it in.<\/p>\n<h2 id=\"the-before-what-i-was-actually-shipping\">The before: what I was actually shipping<\/h2>\n<p>Here&rsquo;s a lightly anonymized version of my original Dockerfile:<\/p>\n<pre><code class=\"language-dockerfile\">FROM golang:1.24\nWORKDIR \/app\nCOPY . .\nRUN go build -o server .\/cmd\/server\nEXPOSE 8080\nCMD [&quot;.\/server&quot;]\n<\/code><\/pre>\n<p>Six lines. It builds, it runs, it passes CI, and it quietly packs the whole Go toolchain, the module cache, and every file in my repo into the production image. My <code>.dockerignore<\/code> was also missing, so <code>COPY . .<\/code> was hauling in the <code>.git<\/code> directory too. If you&rsquo;ve never checked, run <code>docker history<\/code> on one of your images. Mine read like a confession.<\/p>\n<p>None of this was a mystery. It was just invisible, because nothing in the normal dev loop ever shows you the number. The image built, the container started, the endpoints answered. Everyone was happy until the registry bill and the deploy times stopped being happy.<\/p>\n<h2 id=\"the-after-two-stages-and-a-12mb-image\">The after: two stages and a 12MB image<\/h2>\n<p>Here&rsquo;s the replacement:<\/p>\n<pre><code class=\"language-dockerfile\"># Stage 1: build\nFROM golang:1.24 AS build\nWORKDIR \/app\nCOPY go.mod go.sum .\/\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -ldflags=&quot;-s -w&quot; -o \/server .\/cmd\/server\n\n# Stage 2: run\nFROM gcr.io\/distroless\/static-debian12\nCOPY --from=build \/server \/server\nEXPOSE 8080\nENTRYPOINT [&quot;\/server&quot;]\n<\/code><\/pre>\n<p>The final image is 12MB. Same binary, same behavior, roughly one percent of the size.<\/p>\n<p>A few choices worth explaining. <code>CGO_ENABLED=0<\/code> produces a fully static binary, which means the runtime stage needs no libc at all. The <code>-ldflags=\"-s -w\"<\/code> flags strip debug symbols, which took another 30 percent off my binary. And copying <code>go.mod<\/code> and <code>go.sum<\/code> before the rest of the source means Docker can cache the dependency download layer, so rebuilds after a code change skip the slowest step.<\/p>\n<p>For the runtime stage I used <a href=\"https:\/\/github.com\/GoogleContainerTools\/distroless\" rel=\"nofollow noopener\" target=\"_blank\">distroless<\/a>, Google&rsquo;s family of minimal base images. The <code>static-debian12<\/code> variant is around 2MB and contains CA certificates, a timezone database, and almost nothing else. No shell, no package manager, no <code>curl<\/code>. That felt weird at first. It stops feeling weird the first time you read a CVE report and realize none of the affected packages exist in your image.<\/p>\n<h2 id=\"node-is-messier-but-it-still-works\">Node is messier, but it still works<\/h2>\n<p>Go is the easy case because the compiler hands you one file. Node can&rsquo;t compile away <code>node_modules<\/code>, so the win is smaller, but it&rsquo;s still real. Before:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22\nWORKDIR \/app\nCOPY . .\nRUN npm ci &amp;&amp; npm run build\nCMD [&quot;node&quot;, &quot;dist\/server.js&quot;]\n<\/code><\/pre>\n<p>That ships your dev dependencies, your TypeScript source, and the npm cache. After:<\/p>\n<pre><code class=\"language-dockerfile\">FROM node:22 AS build\nWORKDIR \/app\nCOPY package*.json .\/\nRUN npm ci\nCOPY . .\nRUN npm run build\nRUN npm prune --omit=dev\n\nFROM node:22-slim\nWORKDIR \/app\nENV NODE_ENV=production\nCOPY --from=build \/app\/node_modules .\/node_modules\nCOPY --from=build \/app\/dist .\/dist\nCMD [&quot;node&quot;, &quot;dist\/server.js&quot;]\n<\/code><\/pre>\n<p>The <code>npm prune --omit=dev<\/code> line drops everything your build needed but your runtime doesn&rsquo;t, and the <code>node:22-slim<\/code> base is a few hundred MB lighter than the full image. On the one Node service I maintain, this change took the image from 1.1GB to about 210MB. Not 12MB. I&rsquo;m not going to pretend Node gives you the Go ending, but an 80 percent cut is worth six extra lines.<\/p>\n<h2 id=\"the-traps-i-hit-so-you-dont-have-to\">The traps I hit so you don&rsquo;t have to<\/h2>\n<p>The tutorials make this look frictionless. It mostly is, but I lost real time to four things.<\/p>\n<p>TLS certificates. If you use <code>FROM scratch<\/code> instead of distroless, your image contains literally nothing, including CA certificates. My first attempt built fine and then failed every outbound HTTPS call. Distroless includes the certs; scratch doesn&rsquo;t. That difference cost me an hour of staring at x509 errors before I understood what was missing.<\/p>\n<p>Alpine and musl. If you build with CGO enabled on a glibc system and run the binary on Alpine, it won&rsquo;t start, because Alpine uses musl instead of glibc. Either keep <code>CGO_ENABLED=0<\/code> or do the build itself on an Alpine builder image. I knew about this one only because it bit a colleague the month before it would have bitten me.<\/p>\n<p>The build context still matters. Multistage builds don&rsquo;t fix a missing <code>.dockerignore<\/code>. Docker still uploads your entire directory to the daemon before the first stage runs, so a bloated context slows every build even if none of it reaches the final image. Add <code>.git<\/code> and <code>node_modules<\/code> to <code>.dockerignore<\/code> anyway.<\/p>\n<p>You can target intermediate stages. Running <code>docker build --target build .<\/code> gives you the fat builder stage as its own image, which is handy when you need a shell and a compiler to debug something. I went weeks thinking multistage meant giving that up. It doesn&rsquo;t.<\/p>\n<h2 id=\"smaller-images-are-also-safer-images\">Smaller images are also safer images<\/h2>\n<p>The size win gets the headlines, but the security win is probably worth more. Every package in your image is something a scanner can flag and an attacker can potentially use. A distroless runtime has no shell, so a whole class of reverse shells and container escapes simply has nothing to execute. Docker&rsquo;s own <a href=\"https:\/\/docs.docker.com\/build\/building\/best-practices\/\" rel=\"nofollow noopener\" target=\"_blank\">best practices guide<\/a> recommends multistage builds for exactly this reason.<\/p>\n<p>I covered the rest of my hardening routine in <a href=\"https:\/\/abrarqasim.com\/blog\/docker-container-security-the-checklist-i-actually-run\" rel=\"noopener\">my Docker security checklist<\/a>, and multistage builds sit at the top of it on purpose: they shrink the attack surface without changing a line of application code. It&rsquo;s the same advice I give clients in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my consulting work<\/a> when they ask where to start with container security. Start with what&rsquo;s in the image, because the safest package is the one that isn&rsquo;t there.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Run <code>docker images<\/code> and sort by size. Pick the fattest image you own, split its Dockerfile into a build stage and a runtime stage, and measure the difference. On most services it&rsquo;s a fifteen minute change. If you want to see exactly where the bytes went, <a href=\"https:\/\/github.com\/wagoodman\/dive\" rel=\"nofollow noopener\" target=\"_blank\">dive<\/a> will show you the layers one by one.<\/p>\n<p>My bet: your first multistage conversion cuts at least 70 percent, and afterward you&rsquo;ll be mildly annoyed that nobody made you do this earlier. I certainly was.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I cut a 1.2GB Go service image down to 12MB with a Docker multistage build. The before and after Dockerfiles, the base images I picked, and the traps I hit.<\/p>\n","protected":false},"author":2,"featured_media":560,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I cut a 1.2GB Go service image down to 12MB with a Docker multistage build. The before and after Dockerfiles, the base images I picked, and the traps I hit.","rank_math_focus_keyword":"docker multistage build","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302,45],"tags":[127,80,125,126,623],"class_list":["post-561","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-programming","tag-containers","tag-devops","tag-docker","tag-dockerfile","tag-multistage-build"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/561","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=561"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/561\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/560"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=561"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=561"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=561"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}