Docker Multi Stage Build Tutorial for Smaller Production Images

Docker Multi Stage Build Tutorial for Smaller Production Images

The Dockerfile can compile perfectly, deploy successfully, and still carry hundreds of megabytes that your application never uses.

A Docker image can be technically correct and operationally awful.

That is the part people miss.

Your service starts. The health check passes. CI is green. Nobody complains. Then traffic jumps, Kubernetes asks for more pods, and every new node has to pull a giant image before the application can do a single useful thing.

The 1GB sitting in your registry was never only a storage problem.

It was startup time.

It was network traffic.

It was slower rollouts.

It was more software to patch.

It was more packages for a scanner to inspect.

And in many compiled applications, most of that weight exists for one embarrassing reason: the production image contains the tools that built the application even though those tools are no longer needed to run it.

Docker has had the fix for years. It is called a multi stage build.

The syntax is simple. The idea behind it is more important.

Access without medium partner: Docker Image Reduction

The mistake is treating build time and runtime as the same environment

Consider a small Go service.

A Dockerfile like this looks completely reasonable:

FROM golang:1.26
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .
CMD ["./server"]

It builds the program inside the official Go image and starts the resulting binary.

Nothing obviously wrong.

But look at what reaches production.

The final image still contains the Go compiler, development tools, operating system packages, files used during compilation, and everything else already present in that base image.

Your application might need one executable.

You shipped the factory that manufactured it.

That is the real problem.

Docker’s current documentation recommends multi stage builds for exactly this reason. One stage can contain the compiler and everything required to produce the program. A later stage can contain only the runtime files needed after compilation.

For a statically compiled Go service, that final stage can be almost empty.

The Dockerfile I would ship instead

Here is the same idea with the build environment separated from production:

# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" \
-o /out/server .
FROM scratch
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

The first stage can be as messy as necessary.

It can have the Go SDK. It can download modules. It can run tests. It can contain temporary files. None of that automatically enters the final image.

The second FROM starts a new filesystem.

scratch is not a tiny Linux distribution. Docker describes it as a special empty base. There is no shell hiding inside it, no package manager, no normal operating system userland. The first file you copy into it becomes part of the image.

In this example, production receives the server binary.

That is almost the whole image.

A small static Go application can therefore end up in the single digit or low double digit megabyte range depending on the binary and anything else you deliberately copy in.

The interesting part is not whether your final number is 8MB, 12MB, or 23MB.

The interesting part is that the compiler is gone.

Smaller images matter most when machines are under pressure

People sometimes dismiss container size with one sentence:

“Disk is cheap.”

True.

It is also mostly irrelevant.

Think about what happens during a deployment.

A node may need to pull the image from a registry. The image has to move across a network. Layers need to be unpacked. The container needs to start. Only then can the new replica pass readiness checks and receive traffic.

If a layer is already cached, wonderful.

If it is not, image size becomes part of your recovery time.

That matters during autoscaling because the whole reason you asked for another pod was that you needed capacity now.

It matters during a node replacement.

It matters when a fresh cluster starts.

It matters when you deploy to many regions.

It matters when CI repeatedly transfers images.

It matters more on slow links, remote edge systems, and short lived compute.

A 1.2GB image does not mean every deployment will wait for 1.2GB to cross the network because Docker layers are cached and shared. That is an important correction. Good layer reuse can make large images less painful than the total size suggests.

But unnecessary bytes still have no upside.

If production does not need the compiler, there is no prize for shipping it.

Multi stage builds also make the security story cleaner

The security argument is easy to exaggerate, so it is worth being precise.

A tiny image is not magically secure.

Your application can still have a vulnerability. Its dependencies can still be vulnerable. A compromised process can still access whatever the container is allowed to access. Bad Kubernetes permissions do not become safe because the image is small.

What a minimal runtime image can do is remove software that serves no production purpose.

No compiler.

No package manager.

Possibly no shell.

Fewer operating system packages.

Fewer utilities an attacker might use after gaining code execution.

Fewer components for your vulnerability scanner to report.

That is useful.

Docker’s own current guidance says separating build and runtime environments reduces final image size and security risk. Its recent container image lab also recommends combining multi stage builds with other boring controls such as a proper .dockerignore, running as a non root user, choosing an appropriate production base, and keeping build secrets out of image layers.

That last point matters.

A tiny container built with leaked credentials is still a bad container.

scratch is not the correct answer for every application

This is where “my image is 8MB” articles can accidentally teach the wrong lesson.

The goal is not FROM scratch.

The goal is a final image containing only legitimate runtime requirements.

Those are different things.

A completely empty runtime works very well for a static binary that truly brings everything it needs. Go is a common example when CGO is disabled.

Real services often need more.

HTTPS may need trusted CA certificates

Suppose your service calls Stripe, AWS, an identity provider, or another HTTPS API.

A bare scratch image does not come with the normal operating system certificate bundle.

Your binary may start perfectly and then fail the first time it tries to verify a TLS certificate.

You can solve this by copying the CA bundle from a trusted build or runtime stage into the final image, or by choosing a minimal runtime image that already contains certificates.

The right choice depends on the service.

Timezone data may be missing

If the program converts times using named locations such as Asia/Kolkata or America/New_York, it may need timezone data that does not exist in an empty filesystem.

Go can embed timezone information, or you can deliberately copy the required data.

Again, the lesson is not “never use scratch.”

It is “know what your program uses.”

Dynamically linked binaries need their libraries

CGO_ENABLED=0 is popular in Go container examples because it makes producing a self contained binary much easier.

If your program depends on C libraries, database drivers using native code, graphics libraries, or other dynamic components, copying one executable into scratch may simply produce a container that cannot start.

In that case, use a runtime image with the libraries you need.

Minimal does not mean empty.

No shell is great until you need a shell

A production image without a shell can remove an unnecessary tool from the container.

It also changes debugging.

You cannot casually run:

docker exec -it my-container sh

if sh does not exist.

Some teams like that. They use logs, metrics, traces, ephemeral debug containers, or a dedicated debug build when something breaks.

Other teams have operational reasons to keep a small shell available.

Neither choice should happen accidentally.

The best production image is not always the smallest one

Imagine two images.

Image A is 8MB and causes TLS failures because somebody forgot certificates.

Image B is 18MB and contains the binary, CA certificates, timezone information, and exactly the runtime files the service needs.

Image B is better.

This sounds painfully obvious when written down. Container optimisation discussions still turn into screenshot competitions where the lowest number wins.

That is the wrong metric.

I would ask four questions instead.

Does this file need to exist after the build has finished?

Does the application need it to serve a real production request?

Does an operator intentionally need it for production support?

Does keeping it create a maintenance or security cost we can avoid?

If nobody can explain why something belongs in the final image, it probably should not be there.

That rule scales beyond Go.

The same idea works for Node, Python, Java, Rust, and almost everything else

Compiled languages make the difference dramatic because the final artifact can be very small.

Interpreted languages still benefit from the same separation.

A Node application may need Node at runtime but not TypeScript, test packages, source maps meant only for development, build tools, or every development dependency.

A Python application needs Python and its runtime packages, but it probably does not need GCC, header files, pip caches, test data, and compilation dependencies that were required to build a native wheel.

A Java application may build with a full JDK and Maven or Gradle, then run with a smaller runtime. Docker’s current documentation even recommends considering jlink when you want a Java runtime containing only the modules the application needs.

Rust and C services can compile in one stage and move the final artifact plus required runtime libraries into another.

The language changes.

The question does not.

What is needed to build this?

What is needed to run this?

Stop treating those answers as though they must be identical.

Before rewriting the Dockerfile, inspect the image you already have

You do not need to guess where the size came from.

Start with:

docker image ls

Then inspect the layer history:

docker history your-image:tag

Large COPY operations become visible.

Huge package installation layers become visible.

Commands that download files and never clean them up become visible.

Another common problem is the build context itself.

If your Dockerfile says:

COPY . .

and the project directory contains .git, local build artifacts, test fixtures, coverage reports, editor files, or a giant node_modules folder, Docker may send a lot more into the build than you realise.

Use .dockerignore.

A basic one might contain:

.git
.env
node_modules
dist
coverage
*.log

Do not blindly copy that list either. Ignore what your build does not need.

Docker’s own guidance says a .dockerignore should generally resemble the parts of .gitignore that are irrelevant to the image build.

This improves more than final size. A smaller build context can make remote and cloud builds faster too.

Do not delete files in a later layer and assume the size disappeared

Docker layers have another trap.

Suppose one instruction downloads 500MB of build files.

A later instruction deletes them.

The later layer records the deletion, but the earlier layer may still contain those bytes in the image history.

This is one reason multi stage builds are cleaner than trying to tidy a single giant stage after the fact.

Build all the ugly temporary stuff in a stage that never becomes production.

Copy the result out.

Leave the rest behind.

Docker even notes that its experimental layer squashing feature is not the preferred answer for most cases. Multi stage builds give you much more deliberate control.

A production Dockerfile should make the boundary obvious

Here is a slightly more realistic Go layout:

# syntax=docker/dockerfile:1
FROM golang:1.26 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
FROM deps AS test
COPY . .
RUN go test ./...
FROM deps AS build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" \
-o /out/server ./cmd/server
FROM scratch
COPY --from=build /out/server /server
# Copy this only if the application needs outbound TLS.
COPY --from=build \
/etc/ssl/certs/ca-certificates.crt \
/etc/ssl/certs/ca-certificates.crt
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]

Now the Dockerfile documents the architecture.

Dependencies are prepared.

Tests have a stage.

The binary has a stage.

Production has a stage.

The boundary is visible to the next engineer who opens the file.

That may be the best benefit of all.

A good container image should tell you what production actually depends on.

The number I would optimise is not megabytes

I like tiny Docker images.

They pull quickly. They are easier to reason about. They usually contain less junk. For small Go services, seeing an image fall from hundreds of megabytes to something near the binary size is satisfying.

But 8MB is not a badge of engineering maturity.

A 70MB image containing exactly what the service needs is better than a 7MB image held together by assumptions nobody documented.

The real win happens when the build machine stops leaking into production.

Once you make that separation, the rest becomes much easier.

Your compiler can stay in the build stage.

Your package cache can stay there.

Your tests can stay there.

Your source can stay there if the runtime does not need it.

Production receives the application and the runtime pieces you can actually justify.

That is the difference worth caring about.

Not because storage is expensive.

Because when your service needs another container right now, the cleanest production image is the one that has the least unnecessary work standing between “start one” and “ready.”


Post a Comment

Previous Post Next Post