labs

This lab has been modernised but not yet verified end to end. Tell us if something breaks.

Multi-Stage Builds

40 dkdockerView on GitHub
On this page

Multi-Stage Builds

Multi-stage builds use the standard Dockerfile syntax, with multiple stages separated with FROM commands. They give you a repeatable build with minimal dependencies.

You won't see multi-stage builds used everywhere, but they're a great way to centralize your toolset - developers and build servers just need Docker and the source code, all the tools come packaged in Docker images, so everyone's using the same versions.

Reference

Sample Dockerfiles

It's the standard docker build command for multi-stage builds. The Dockerfile syntax uses multiple FROM instructions; the patterns are the same for all languages, but the individual details are specific.

These are samples in the major languages:


Multi-Stage Dockerfiles

We'll start with the legacy build engine, because it runs every stage in order and that makes the structure of a multi-stage Dockerfile obvious. Then we'll switch to BuildKit and compare:

# on macOS or Linux:
export DOCKER_BUILDKIT=0

# OR with PowerShell:
$env:DOCKER_BUILDKIT=0

You'll see a warning: _"DEPRECATED: The legacy builder is deprecated and will be removed in a future release."_ That's expected. BuildKit has been the default since Docker 23 and the legacy builder is on its way out — we use it here for one build only, to see the contrast.

Here's a simple multi-stage Dockerfile:

  • the base stage uses Alpine and simulates adding some dependencies
  • the build stage builds on the base and simulates an app build
  • the test stage starts from the build output and simulates automated testing
  • the final stage starts from base and copies in the build output

📋 Build an image called simple from the labs/multi-stage/simple Dockerfile.

Not sure how?
# just a normal build:
docker build -t simple ./labs/multi-stage/simple/

All the stages run, but the final app image only has content explicitly added from earlier stages.

Run a container from the image and it prints content from the base and build stages:

docker run simple

The final image doesn't have the additional content from the test stage.

BuildKit and build targets

BuildKit is Docker's build engine and the default since Docker 23. It's heavily optimized for multi-stage builds, running stages in parallel and skipping stages if the output isn't used.

Switch back to BuildKit by setting the environment variable (or just unsetting the one above):

# on macOS or Linux:
export DOCKER_BUILDKIT=1

# OR with PowerShell: 
$env:DOCKER_BUILDKIT=1

Now repeat the build for the simple Dockerfile - this time Docker will use BuildKit:

docker build -t simple:buildkit ./labs/multi-stage/simple/

You'll see output from different stages at the same time - and if you look closely you'll see the test stage is skipped.

📋 Run a container from the new image. Is the output the same? Compare the image details.

Not sure how?
# run a container - the output is the same:
docker run simple:buildkit

# list images - they're the same size but not the same image:
docker image ls simple

BuildKit skips the test stage because none of the output is used in later stages. You can explicitly build an image up to a specific stage with the target flag:

docker build -t simple:test --target test ./labs/multi-stage/simple/
  • --target states the target stage, Docker will build all stages up to and including the named one

This image is the output of the test stage not the final stage.

📋 Run a container from the test build, printing the contents of the build.txt file.

Not sure how?
# no output here - the test stage has no CMD instruction
docker run simple:test

# run the cat command to see the output
docker run simple:test cat /build.txt

The output is from the build stage plus the test stage.

Simple Go application

Real multi-stage builds use an SDK (Software Development Kit) image to compile the app in the build stage and a smaller runtime image (with no build tools) to package the compiled app.

The images you use and the commands you run are different for each language, but you'll find official images on Docker Hub for all the major platforms, including:

We won't cover different languages in detail. The whoami Dockerfile shows how the pattern works, using a Go application:

  • the builder stages starts from the Go SDK image
  • it installs the OS packages needed to build the app
  • then it copies the library list (go.mod) and the lock file (go.sum) and

    runs go mod download to install the app's dependencies

  • next it copies the source code and compiles the app
  • the final app image sets up the container environment
  • then it copies in the compiled output from the builder

📋 Build an image called whoami from the folder labs/multi-stage/whoami.

Not sure how?
docker build -t whoami ./labs/multi-stage/whoami/

You'll see all the stage output from BuildKit.

SDK images are typically very large, having the whole build toolset. You don't want to use an SDK image in your final stage, otherwise you'll have all that stuff in your app image.

📋 Compare the sizes of the whoami and golang images.

Not sure how?
docker pull golang:1.27.2-alpine

docker image ls -f reference=whoami -f reference=golang

Woah! The SDK image is hundreds of megabytes; the app image is a few.

Docker 29 splits the old SIZE column into DISK USAGE and CONTENT SIZE, so you'll see two numbers - the gap is just as big either way.

The app is a simple web server. Run a container publishing a random port and find the port:

docker run -d -P --name whoami1 whoami

docker port whoami1

The EXPOSE instruction tells Docker the target port for the container; when you use the -P flag Docker publishes all exposed ports.

Now you can use the app:

curl localhost:<port>

The server just prints some details about the environment and the request.

The container is running from a minimal image - there's nothing in the image which isn't necessary for the app to run.

This helps minimize the attack surface of the application - try connecting to a shell session in the container:

# this will fail
docker exec -it whoami1 sh

There's no shell, so if an attacker compromised the application they'd have more work to do before they could launch an exploit.

Lab

Apps need special Linux permissions to listen on the standard HTTP ports - even inside a container.

The whoami app supports an option to configure the port it listens on, so you can use a non-standard port and potentially run with tighter security.

Your goal for this lab is to run the whoami app in a container - using the -port application argument to listen on a specific port. What happens when you run a container with the -P (--publish-all) option? Does Docker map the new port correctly?

What do you need to do to run a working container?

Stuck? Try hints or check the solution.


Cleanup

Cleanup by removing all containers:

docker rm -f $(docker ps -aq)

On PowerShell the command substitution syntax is different:

docker rm -f @(docker ps -aq)

⚠️ Either version removes every container on your machine, not just the ones from this lab. Run docker ps first if you have other work running.