DevOps

Cutting our Docker image sizes with multi-stage builds

OPS

Several of our production Docker images had quietly grown to well over a gigabyte, mostly because the same image that compiled and built the application also shipped every build-time dependency — compilers, dev packages, the whole npm dev dependency tree — into production. Nobody had set out to build a gigabyte-plus image; it accumulated the same way most infrastructure bloat does, one reasonable-seeming addition at a time with nobody looking at the cumulative result.

Docker's multi-stage build feature let us split this cleanly: one build stage installs everything needed to compile and bundle the application, and a second, much leaner stage copies over only the compiled output and production dependencies, discarding everything else. Our typical Node service image dropped from over a gigabyte to under 200MB, which was a bigger improvement than we expected going in, since we had assumed most of the size was in the actual application code and dependencies rather than build tooling that was never needed past the build step itself.

What this changed in practice

Smaller images mean faster deploys, faster autoscaling when a new container needs to start, and a meaningfully smaller attack surface since fewer unnecessary tools are sitting in the production image — a compiler or a dev-only package in a production image is not just wasted space, it is one more thing an attacker could potentially use if they ever gained access to a running container. On one client's project running autoscaling under variable traffic, the reduced image size measurably cut the time a new instance took to become ready during a scale-up event, which directly improved how quickly the service could respond to a genuine traffic spike.

It is a small change to a Dockerfile that we now apply as standard practice across every project, and we have gone back and retrofitted it onto several older client projects that predated this practice, purely for the deploy-speed and security benefits, even on projects where the original gigabyte-plus image size was not causing any particularly visible problem on its own.

Auditing older projects for the same problem

Once we saw the size reduction on our newest projects, we ran a quick audit across every actively maintained client project's Docker images, ranking them by size to prioritize which ones to retrofit first. A surprising number of older projects were carrying images well over a gigabyte without anyone having specifically noticed, simply because image size was never a metric anyone was actively tracking until this project made it visible as something worth caring about.

A checklist we now apply to every Dockerfile

  • Confirm the final production stage never installs a compiler or dev-only package directly, only copies already-built artifacts from an earlier stage
  • Pin base images to a specific minor version rather than a floating tag, so a base image update does not silently change build behavior between deploys
  • Use a minimal base image for the production stage specifically — an Alpine or distroless variant where the application's runtime dependencies allow it — rather than reusing the same, heavier base image used for the build stage itself
← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients