This document assumes you already have working knowledge of Docker, Dockerfiles, and the general concepts behind building and using container images.
Optimization… the eternal pursuit of squeezing every bit of performance out of a system. Working with Docker images is no exception, and it's actually a pretty important topic because it's not just about reducing disk space and storage costs. It has an impact across the entire lifecycle of the image and the application running inside it.
For us, the most relevant points are
https://aws.amazon.com/ecr/pricing/
https://docs.docker.com/build/building/best-practices/
Every instruction in a Dockerfile that produces a filesystem change (RUN, COPY, ADD) creates a new read-only layer on top of the previous one. Docker uses OverlayFS to stack these layers and present them as a single unified directory.
Important considerations for commands
Considerations when understanding image sizing
So that we can all be on the same page with the commands we'll run, we're going to work with public images that you can pull and follow along with interactively — testing things as we go.
Go server app
github.com/docker/docker-gs-ping
You can build the image with the following command:
Note: You may want to review the Dockerfile located at the project root before executing these commands.
The result is an image that weighs in at over 1.11 GB. Quite big for a simple compiled Go application.

What happens is that we're using the official Go image (golang:1.19) as the base. This image is based on Debian (which will make more sense shortly). As you'd expect, it includes all the libraries and tools needed to compile and run a Go application. As we mentioned earlier, this affects not just the final image size but also security once the container is deployed.
Now, as you might have already guessed, the image contains tools we don't actually need at runtime. At that point, all we need is the compiled binary /docker-gs-ping.
As we saw before, the image weighs around 1.17 GB, but we can use a more interesting command to see which layers are taking up the most space:
This should display something similar to the screenshot above.

This command shows the image layers from bottom to top, which is the order in which they're built. There are a few interesting things here: the image is based on Debian as mentioned earlier, and a large part of its weight comes from the packages it needs to function and the Go toolchain installed via set -eux; arch=... (459 MB).
As you can see, your binary (/docker-gs-ping) — which is the actual application — ends up weighing only 41.5 MB.
https://docs.docker.com/reference/cli/docker/image/history/
Now, to formally define what Docker's multi-stage builds are about: it's a way of defining multiple stages (each initialized with its own FROM instruction) in complete isolation, while allowing you to efficiently share artifacts between them.
The final image, after the entire build process, consists only of the last stage. Everything else is discarded.
Multi-stage builds were introduced in Docker 17.05.
https://docs.docker.com/build/building/multi-stage/
Systax breakdown
There are a few syntax points worth highlighting when working with multi-stage builds:
Moving forward with multi-stage, we can now look at the file available for this in the repository we've been working with. It's at the root with the name Dockerfile.multistage. This file defines three stages: one for compiling the application, one for running tests, and one that builds the final image with just the application binary.
Now, building the image with the multi-stage Dockerfile:
The result

You might wonder why it's even smaller than the 41.5 MB we mentioned before. This is mainly because that earlier figure was a layer diff reported by Docker, which can include some additional data particularly cache-related metadata.
Here, we're only copying the compiled binary and discarding everything else, so we end up with an even lighter image.
Now, as you may have noticed, even our binary needs a base image to run on. For this, we try to use the smallest possible image with just the minimum required for the application to execute.
If you want to dig deeper into the final sizes of the base image and the Go binary, remember you can run:
The application we'll use for this example is another one from Docker's repository: docker/getting-started-todo-app.
You can check it out here: https://github.com/docker/getting-started-todo-app/ — it's basically an app that packages the frontend and backend together.
To get started:
Let's review the Dockerfile at the project root. You'll see that this file has many more stages, designed to split the build as cleanly as possible and carry only the dependencies and files that are actually needed for the final image.
Stage Summary
https://docs.docker.com/build/cache/invalidation/ – cache invalidation
The final stage has some interesting points to highlight.
First, package.json and package-lock.json are copied from the test stage, which forces the test stage to actually run and succeed. If the test stage fails, the build stops and the final image is never produced.
Second, from client-build you'll notice that only the dist/ output is copied over, leaving the Vite toolchain behind.
Third, npm ci --production installs only production dependencies, which is the intended behavior.
And lastly, you may notice that the base is node:22, which could be swapped for node:22-alpine or node:22-slim to build an even smaller final image.
BuildKit Cache Mounts.
This is a BuildKit cache mount which is different from Docker's layer cache. It tells BuildKit to persist the contents of /root/.npm across builds, so cached dependency downloads are available on subsequent runs and reduce network overhead. The target path is where npm stores its cache by default, so no extra configuration is needed.
Worth noting: this cache is only available after the first build has populated it.
Another example, following the same pattern:
Copy order matters
The idea behind this ordering is simple: arrange your instructions so that you avoid invalidating the cache unnecessarily.
Docker's layer cache works like this: if a command that creates a new layer changes, then everything after it is also invalidated — even if those later instructions haven't changed.
For example, if we had something like:
It's very likely that the dependencies haven't changed and the layer cache could be reused for npm install. But since we copied the source code first (which changes frequently), everything from that point to the end of the stage gets invalidated.
This is why the order in which you place instructions in the Dockerfile is highly relevant..
When multi-stage builds are not the right tool
Even though multi-stage builds seem to add value in every scenario, that's not always the case. Over-engineering is a real problem, and it's easy to fall into with this approach.
The question to ask is simple: does the image contain anything that's only needed to produce the artifact but not when running it? If yes, then a multi-stage build makes sense. If not, a single-stage Dockerfile with the smallest possible base image is perfectly fine.
To consider
Multi-stage builds are not an advanced optimization — they are the baseline for building production-ready container images. The core principle is simple: the build environment and the runtime environment are different things, and conflating them has real costs in deployment speed, storage, and security.