Skip to content

Multi Stage Builds


Every Dockerfile you’ve written so far has had exactly one FROM. That’s worked fine for nginx serving a static file, because nginx doesn’t need to be built — it just needs to be installed and run. But plenty of real applications need a build step first: code that has to be compiled, dependencies that have to be fetched and bundled, assets that have to be processed. And the tools needed for that step — compilers, build systems, package managers — are usually large, and usually only needed once, during the build.

The problem is that a single-stage Dockerfile can’t tell the difference between “I needed this to build the app” and “I need this to run the app” or even better is “I need to distribute this app”. Everything you RUN apt-get install sticks around in the final image forever, whether the container ever touches it again or not. Multi-stage builds exist to fix exactly this: build in one place, ship only what’s actually needed in another.

    flowchart LR
    subgraph Stage1["Build stage"]
        A[Compiler / build tools]
        B[Source code]
        A --> C[Compiled output]
        B --> C
    end
    subgraph Stage2["Final stage"]
        D[Minimal base]
        E[Only the compiled output]
    end
    C -->|COPY --from| E
  

A Concrete Example: Compiling C on Ubuntu

Note

You don’t need to know C at all to understand this. Any language will do or even if you don’t know any programming language — this still makes sense.

Let’s keep this grounded in something everyone can follow without needing a specific framework’s context — compiling a tiny C program. Say you have a hello.c:

#include <stdio.h>
int main() {
    printf("Hello from inside the container\n");
    return 0;
}

A naive, single-stage Dockerfile to run this looks like:

FROM ubuntu:22.04
WORKDIR /app
COPY hello.c .
RUN apt-get update && apt-get install -y gcc
RUN gcc hello.c -o hello
CMD ["./hello"]

This works. It also ships gcc — a full C compiler, along with everything it pulls in — inside the image you run in production, forever, even though the compiler’s entire job was finished the moment hello was produced. That’s the exact problem multi-stage builds solve.

Splitting It Into Stages

# Stage 1: build
FROM ubuntu:22.04 AS builder
WORKDIR /app
COPY hello.c .
RUN apt-get update && apt-get install -y gcc
RUN gcc hello.c -o hello

# Stage 2: run
FROM ubuntu:22.04
WORKDIR /app
COPY --from=builder /app/hello .
CMD ["./hello"]

Two things changed. First, the builder stage got a name — AS builder — so it can be referred to later. Second, the final stage starts completely fresh from ubuntu:22.04 again, and instead of installing gcc and compiling anything, it just reaches into the previous stage and copies out the one file that actually matters:

COPY --from=builder /app/hello .

Everything else from the builder stage — gcc, the apt package cache, the original hello.c source — never makes it into the final image at all. It existed only long enough to produce the binary, then got discarded along with the rest of that stage.

Why Size Actually Matters Here

This isn’t a cosmetic difference. Build docker image build -t hello:single . from the single-stage version and docker image build -t hello:multi . from the multi-stage one, then compare:

docker image ls
REPOSITORY   TAG      SIZE
hello        single   210MB
hello        multi    78MB

That gap is almost entirely gcc and its dependencies — real weight that a single-stage build carries around on every pull, every deploy, every disk it sits on, for a tool the running container never uses even once. On a small project this is a minor annoyance. Multiply it across dozens of services and repeated deploys, and it becomes real bandwidth, storage, and startup time.

Note

You can confirm this yourself with a tool you already know — docker image history hello:multi will show you the builder stage’s layers are simply gone. They were never part of this image to begin with.

Why Security Actually Matters Here

Size is the easy sell. The security angle is the one worth taking just as seriously, and it’s simple: every tool you leave in a running image is something an attacker can use if they ever get a shell inside your container.

A compiler sitting in your production image doesn’t just waste space — it’s a tool someone can use to build something after breaking in. A package manager left behind means new software can be pulled and installed on the fly. Every extra binary is one more thing that could carry a known vulnerability, and one more thing a security scanner will flag against your image. None of that is hypothetical: attackers who land inside a container commonly look for exactly this kind of leftover tooling to expand what they can do next.

Multi-stage builds mean your final image only contains what the running application needs — not what the build process needed. That’s a smaller, more honest attack surface, and it costs you nothing at runtime since none of the discarded tooling was ever going to be used there anyway.

Going Further: Distroless Images

ubuntu:22.04 as a final-stage base is already a big improvement over shipping the compiler — but it’s still a full Linux distribution: a shell, a package manager, core utilities, all sitting there unused by your one compiled binary. Distroless images go a step further by stripping almost all of that away, leaving just enough to run your application and nothing else — often not even a shell.

FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/hello .
CMD ["./hello"]

No apt, no bash, no sh. If an attacker does manage to get code execution inside a distroless container, there’s no shell for them to drop into, and no package manager to pull in more tools — the environment simply doesn’t offer those capabilities to begin with.

Note

This cuts both ways for you too — without a shell, you can’t docker exec into a distroless container to poke around interactively. It’s a deliberate tradeoff: less convenience for debugging, in exchange for meaningfully less attack surface. Some distroless variants ship a -debug tag with a minimal shell included specifically for this situation.

Where to Find Distroless Images

Distroless images are maintained by Google as an open-source project, and that’s the canonical place to get them:

  • Images: gcr.io/distroless/... (for example, gcr.io/distroless/base-debian12, gcr.io/distroless/static-debian12, plus language-specific variants for Java, Python, Node.js, and others)
  • Source and full list of available images: github.com/GoogleContainerTools/distroless

The GitHub repo is worth bookmarking rather than memorizing — it documents exactly which variant fits which kind of application (static for statically-compiled binaries with zero dependencies, base for things that need glibc, and so on), and that mapping is easier to look up per project than to carry around in your head.

Naming and Targeting Stages

Once a Dockerfile has multiple stages, AS name becomes genuinely useful beyond just COPY --from. You can build only up to a specific stage — handy when you want a debug build that stops before the minimal final stage:

docker image build --target builder -t hello:debug .

This builds and tags just the builder stage, complete with gcc and all, leaving the slimmed-down final stage untouched. Useful during development; not something you’d ship.

Wrapping Up

Multi-stage builds don’t change anything about the instructions you already know — FROM, RUN, COPY, CMD are all still doing exactly what they did before. What changes is that you’re no longer forced to choose between “have the tools to build my app” and “ship a small, safe image.” You get a build stage with everything you need, and a final stage with only what your application actually uses at runtime — smaller, and with meaningfully less for an attacker to work with if they ever get inside.

Last updated on