Quick Links

Multi-stage Docker builds let you write Dockerfiles with multiple

        FROM
    

statements. This means you can create images which derive from several bases, which can help cut the size of your final build.

Docker images are created by selecting a base image using the

        FROM
    

statement. You then add layers to that image by adding commands to your Dockerfile.

With multi-stage builds, you can split your Dockerfile into multiple sections. Each stage has its own

        FROM
    

statement so you can involve more than one image in your builds. Stages are built sequentially and can reference their predecessors, so you can copy the output of one layer into the next.

Multi-Stage Builds in Action

Let's look at how you can create a multi-stage build. We're working with a barebones PHP project which uses Composer for its dependencies and Sass for its stylesheets.

Here's a multi-stage Dockerfile which encapsulates our entire build:

FROM node:14 AS sass

WORKDIR /example

RUN npm install -g node-sass

COPY example.scss .

RUN node-sass example.scss example.css

FROM php:8.0-apache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json .

COPY composer.lock .

RUN composer install --no-dev

COPY --from=sass /example/example.css example.css

COPY index.php .

COPY src/ src

Straightaway, you'll observe we've got two

        FROM
    

statements which split our Dockerfile into two logical sections. The first stage is dedicated to compiling the Sass, while the second one focuses on combining everything together in the final container.

We're using the

        node-sass
    

implementation of Sass. We therefore start with a Node.JS base image, within which we install

        node-sass
    

globally from npm. We then use

        node-sass
    

to compile our stylesheet

        example.scss
    

into the pure CSS

        example.css
    

. The high-level summary of this stage is we take a base image, run a command and obtain an output we'd like to use later in our build (

        example.css
    

).

The next stage introduces the base image for our application:

        php8.0-apache
    

. The last

        FROM
    

statement in your Dockerfile defines the image your containers will end up running. Our earlier

        node
    

image is ultimately irrelevant to our application's containers - it's used purely as a build-time convenience tool.

We next use Composer to install our PHP dependencies. Composer is PHP's package manager but it's not included with the official PHP Docker images. We therefore copy the binary into our container from the dedicated Composer image.

We didn't need a

        FROM
    

statement to do this. As we're not running any commands against the Composer image, we can use the

        --from
    

flag with

        COPY
    

to reference the image. Ordinarily,

        COPY
    

copies files from your local build context into your image; with

        --from
    

and an image name, it'll create a new container using that image and then copy the specified file out of it.

Later on, our Dockerfile uses

        COPY --from
    

again, this time in a different form. Back at the top, we wrote our first

        FROM
    

statement as

        FROM node:14 AS sass
    

. The

        AS
    

clause created a named stage called

        sass
    

.

We now reference the transient container created by this stage using

        COPY --from=sass
    

. This allows us to copy our built CSS into our final image. The remainder of the steps are routine

        COPY
    

operations, used to obtain our source code from our local working directory.

Advantages of Multi-Stage Builds

Multi-stage builds let you create complex build routines with a single Dockerfile. Prior to their introduction, it was common for complex projects to use multiple Dockerfiles, one for each stage of their build. These then needed to be orchestrated by manually written shell scripts.

With multi-stage builds, our entire build system can be contained in a single file. You don't need any wrapper scripts to take your project from raw codebase to final application image. A regular

        docker build -t my-image:latest .
    

is sufficient.

This simplification also provides opportunities to improve the efficiency of your images. Docker images can become large, especially if you're using a language runtime as your base.

Take the official

        golang
    

image: it's close to 300MB. Traditionally, you might copy your Go source into the image and use it to compile your binary. You'd then copy your binary back to your host machine before starting another build. This one would use a Dockerfile with a lightweight base image such as

        alpine
    

(about 10MB). You'd add your binary back in, resulting in a much smaller image than if you'd used the original

        golang
    

base to run your containers.

With multi-stage builds, this kind of system is much easier to implement:

FROM golang:latest

WORKDIR /go

COPY app.go .

RUN go build -o my-binary

FROM alpine:latest

WORKDIR /app

COPY --from=build /go/my-binary .

CMD ["./my-binary"]

In eight lines, we've managed to achieve a procedure that would previously have needed at least three files - a

        golang
    

Dockerfile, an

        alpine
    

Dockerfile and a shell script to manage the intermediary steps.

Conclusion

Multi-stage builds can dramatically simplify the construction of complex Docker images. They let you involve multiple interconnected build steps which can pass output artifacts forwards.

The model also promotes build efficiency. The ease with which you can reference different base images helps developers ensure the final output is as small as possible. You'll benefit from reduced storage and bandwidth costs, which can be significant when using Docker within a CI/CD system.