Multi-Stage Builds
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=0You'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
basestage uses Alpine and simulates adding some dependencies - the
buildstage builds on the base and simulates an app build - the
teststage 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 simpleThe 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=1Now 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 simpleBuildKit 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/--targetstates 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.txtThe 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:
- maven and gradle to build Java apps - using eclipse-temurin for the runtime image (Docker Hub's
openjdkimage is deprecated) - python - has Pip installed for dependencies
- node for Node.js apps - this has NPM so you can install packages in the build stage
- golang for Go apps - they don't need a runtime so the final image can start from scratch
- dotnet/sdk for .NET apps, using dotnet/runtime or dotnet/aspnet for the final app image
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) andruns
go mod downloadto 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=golangWoah! The SDK image is hundreds of megabytes; the app image is a few.
Docker 29 splits the old
SIZEcolumn intoDISK USAGEandCONTENT 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 whoami1The
EXPOSEinstruction tells Docker the target port for the container; when you use the-Pflag 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 shThere'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?
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 psfirst if you have other work running.