If you've been using Docker for a while, or perhaps you read my previous post: An intro to Docker & the magic of containers - then you've probably come across the term "multi-stage builds" before. But maybe you aren't sure what they are or how they can be useful to you? Well, you're in the right place! I'll show you how to use multi-stage Docker builds to reduce your Docker image size and ensure you only package the artifacts you need at runtime. This post assumes you have a basic understanding of Docker and some form of container runtime installed so you can follow along - if not, go read my previous post :)
What are multi-stage builds
So I like to refer to Dockerfiles as a recipe with multiple instructions. When we use Docker multi-stage builds we use multiple FROM instructions. The first FROM often uses a base image from a container registry; each new FROM forms a new build stage where you can COPY specific artifacts from previous stages or leave behind unnecessary artifacts that you don't want in your new stage. You can either create the next stage from the previous one or use an entirely different image as the base for the new stage.
How to use multi-stage builds
Let's take the below Dockerfile as an example, or feel free to use this repo with example Dockerfiles. You can see it has a standard FROM instruction at the top and nothing else - this means it is a single-stage build from the base image python:3.11.15-slim.
Note: If you have multiple Dockerfiles in the directory you need to specify the filename:
docker build -f <file-name> -t multi-stage:1 .
FROM python:3.11.15-slim
# Setup a working directory that container will run from
WORKDIR /app
# Copy over any required files from your host
COPY requirements.txt .
# Install the requirements
RUN pip install --no-cache-dir -r requirements.txt
ENTRYPOINT ["./entrypoint.sh"]
To transform this into a multi-stage Docker build we simply add another FROM instruction. In the below example, after our first stage we start a new one - this can use any image as its base in the FROM instruction. Underneath, you can see we have a COPY instruction which specifies an argument: --from=0. The --from=0 argument copies the virtual env from /opt/venv in the first stage into the same path in the new stage, where 0 refers to the first build stage (1 would be the next, and so on). We then put it back on the PATH with the ENV instruction.
Note: In this example we've added a Python virtual environment, which is used so that the compiled Python packages are copied in a reproducible manner between the stages.
FROM python:3.11.15-slim
# Setup a working directory that container will run from
WORKDIR /app
RUN python -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
# Copy over any required files from your host
COPY requirements.txt .
# Install the requirements
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.11.15-slim
COPY --from=0 /opt/venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
ENTRYPOINT ["./entrypoint.sh"]
In this example, because we don't specify a name with AS, each stage is numbered - starting from 0, of course! But that's not easy to read, so most people opt to use "named stages". This way, in our COPY instruction we have a clear and understandable name in the argument: --from=compile. This achieves the same result as our previous example but it's easier to understand what stage it is copying from. All we do is add an AS after our base image and then provide a suitable (and descriptive) name. I've called my first one compile and the second build:
# Dockerfile-named
FROM python:3.11.15-slim AS compile
# Setup a working directory that container will run from
WORKDIR /app
RUN python -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
# Copy over any required files from your host
COPY requirements.txt .
# Install the requirements
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.11.15-slim AS build
COPY --from=compile /opt/venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
ENTRYPOINT ["./entrypoint.sh"]
While this example doesn't show much of a benefit due to the size of the requirements.txt, it does demonstrate how we can clearly save space and create tidier images by removing unnecessary artifacts. Even with only a simple Python package this build is 7MB smaller:
$ docker build -f Dockerfile-named -t single-stage:1 .
[+] Building 1.4s (10/10) FINISHED docker:orbstack
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 574B 0.0s
=> [internal] load metadata for docker.io/library/python:3.11.15-slim 0.8s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/4] FROM docker.io/library/python:3.11.15-slim@sha256:baf89808ec37adeaab83cec287adb4a2afa4a11c1d51e961c7ec737877e61af6 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 37B 0.0s
=> CACHED [2/4] WORKDIR /app 0.0s
=> CACHED [3/4] COPY requirements.txt . 0.0s
=> CACHED [4/4] RUN pip install --no-cache-dir -r requirements.txt 0.0s
=> exporting to image 0.3s
=> => exporting layers 0.0s
=> => writing image sha256:fe4ca7c5d4252f87ace768f773ea2395e48f9c1e67d2c98a76948b6fe121f2e5 0.0s
=> => naming to docker.io/library/single-stage:1
$ docker images | grep single
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
single-stage:1 4d8ef5421beb 323MB 0B
$ docker build . -t multi-stage:1
[+] Building 14.4s (11/11) FINISHED docker:orbstack
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 566B 0.0s
=> [internal] load metadata for docker.io/library/python:3.11.15-slim 0.3s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [compile 1/5] FROM docker.io/library/python:3.11.15-slim@sha256:baf89808ec37adeaab83cec287adb4a2afa4a11c1d51e961c7ec737877e61af6 6.6s
=> [internal] load build context 0.0s
=> => transferring context: 51B 0.0s
=> [compile 2/5] WORKDIR /app 1.2s
=> [compile 3/5] RUN python -m venv /opt/venv 3.4s
=> [compile 4/5] COPY requirements.txt . 0.1s
=> [compile 5/5] RUN pip install --no-cache-dir -r requirements.txt 1.8s
=> [build 2/2] COPY --from=compile /opt/venv /opt/venv 0.4s
=> exporting to image 0.2s
=> => exporting layers 0.2s
=> => writing image sha256:e2fa0778c9f780808a028c7ae516d1340f5f859d68f971627befc5092e801b6f 0.0s
=> => naming to docker.io/library/multi-stage:1
docker images | grep multi
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
multi-stage:1 effcb4cc2b1c 316MB 0B
Compiled languages - let's go!
The Python example is handy for showing the mechanics, but the savings are small because both stages share the same python:slim base. To really see the payoff, let's look at a compiled language like Go.
The trick with Go is that we can compile a single, statically linked binary in a full golang builder image (which is big!), and then copy the much smaller binary into a near-empty base image - nothing else comes along for the ride!
# go/Dockerfile-go
# ---- Build stage ----
FROM golang:1.26 AS build
WORKDIR /app
# Copy the module files first so dependency downloads are cached
COPY go.mod go.sum ./
RUN go mod download
# Copy the source and compile a statically linked binary.
# CGO_ENABLED=0 drops the libc dependency, so the binary can run on a
# base image with no C library at all - like scratch.
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# ---- Final stage ----
FROM scratch AS final
# Copy only the compiled binary out of the build stage
COPY --from=build /server /server
ENTRYPOINT ["/server"]
The golang:1.26 builder weighs in at around 900MB, but the final image is only your binary on top of scratch, which is an empty image often used for building base images. You should now see a much lighter image in the 6-20MB range depending on your app. Your exact numbers will vary, but the difference is pretty dramatic. Here I build one of each and compare the two image sizes:
# Build a single stage go app, including the binaries
$ cd go
$ docker build -f Dockerfile-simple -t single-stage:go .
[+] Building 5.6s (11/11) FINISHED
# Build a multi-stage go image, copying only the required app
$ docker build -f Dockerfile-go -t multi-stage:go .
[+] Building 38.0s (12/12) FINISHED
=> [internal] load build definition from Dockerfile-go 0.1s
=> => naming to docker.io/library/multi-stage:go 0.0s
$ docker images | grep go
multi-stage:go 89f32857db22 7.78MB 0B
single-stage:go 17d97a6d9d69 981MB 0B
A couple of things worth knowing before you go all in using scratch, while it's great as a base image because it's so lightweight there are a few things to note:
- CA certificates.
scratchhas no root certificates, so any outbound HTTPS call (calling an API etc) will fail with a certificate error. If your app needs them, copy them out of the build stage:COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - No shell! This means
docker execinto a running container won't give you a shell.
Run only a specific stage
This part can be a thing of beauty, especially for integrating into a CI/CD pipeline. When images have multiple stages, we can choose to build only a certain part - for example, we want to populate an image with test data for a testing stage, but use a separate stage for production.
Using the --target argument below we tell the Docker build to stop after the test stage has been built:
docker build -f Dockerfile-test --target test -t hello .
Our previous Dockerfile didn't have a test stage, so let's create one which simply runs a pytest suite:
# Dockerfile-test
FROM python:3.11.15-slim AS compile
# Setup a working directory that container will run from
WORKDIR /app
RUN python -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
# Copy over any required files from your host
COPY requirements.txt .
# Install the requirements
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.11.15-slim AS test
COPY --from=compile /opt/venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
RUN pytest
FROM python:3.11.15-slim AS build
COPY --from=compile /opt/venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
ENTRYPOINT ["./entrypoint.sh"]
Re-using the previous stage
In the previous Dockerfile we added a test stage - however, both it and the build stage now set the virtual env path. We could instead re-use the image from the original compile stage for both test and build, reducing both the complexity of the build and the Dockerfile. Similarly, because the build stage is based on the compile image, it doesn't include any of the testing dependencies in the final image.
FROM python:3.11.15-slim AS compile
# Setup a working directory that container will run from
WORKDIR /app
RUN python -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
# Copy over any required files from your host
COPY requirements.txt .
# Install the requirements
RUN pip install --no-cache-dir -r requirements.txt
FROM compile AS test
COPY tests/ ./tests/
RUN pytest ./tests/
FROM compile AS build
ENTRYPOINT ["./entrypoint.sh"]
Other handy patterns
Once the basic idea clicks, multi-stage builds will become second nature and you can use them all over the place. A few patterns worth having in your back pocket:
Copying files straight out of another image. --from isn't limited to your own stages - you can point it at any image and lift files out of it. This is an awesome way to grab a single binary or a set of certs without installing a package manager in your final image:
FROM scratch
# Grab a CLI tool from its official image
COPY --from=hashicorp/terraform:1.9 /bin/terraform /usr/local/bin/terraform
Frontend build, then a lightweight server. If you're building a modern frontend then you're probably well versed in all of the package managers and bloat that comes with something like Node. With multi-stage builds you can build your static assets then throw away the unnecessary artifacts, package managers etc and serve the output from a tiny Nginx image. The node_modules and build tooling never make it into the shipped image.
FROM node:22 AS build
WORKDIR /app
# Copy manifests first so npm's layer is cached until deps change
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:stable-alpine AS final
COPY --from=build /app/dist /usr/share/nginx/html
Keeping build tools (and secrets) out of the final image. This is one for the security nerds out there! Your build stage often needs compilers and sometimes credentials to pull private dependencies. None of that belongs in the image you actually deploy. By copying only the finished artifact into a clean final stage, you:
- reduce the attack surface - fewer binaries and libraries means fewer things for the baddies to exploit;
- cut down on CVEs - every unnecessary package in your final image is something a scanner can flag;
- avoid accidentally baking a private token or SSH key into a published layer.
Pair this with a minimal final base (scratch, distroless, or alpine) and you end up with images that are small, fast to pull, and much easier to keep secure.
Better layer caching. You'll have noticed I keep copying dependency manifests (requirements.txt, go.mod, package.json) before the rest of the source. That's deliberate: Docker caches each layer, so as long as those files haven't changed, the expensive pip install / go mod download / npm ci step is served straight from cache.
That's a wrap!
Multi-stage builds give you a clean way to separate how you build something from what you ship. The idea is simple - more than one FROM, and COPY --from to pull artifacts between stages - which results in smaller images, tidier Dockerfiles and faster pulls! I would suggest starting by splitting your build tooling from your runtime, reach for a minimal final base like scratch or distroless, and use --target when you want a single Dockerfile to serve both testing and production.
Give it a go on one of your own images and see how much weight you can shed (it's way easier than an actual diet!) - I think you'll be pleasantly surprised. As always, the example Dockerfiles are over on GitHub if you want to poke around :)
$ comments