A multi-stage build uses several FROM instructions in one Dockerfile. Early stages contain compilers, package managers and source code; the final stage copies only the result it needs, so none of the build tooling ends up in the image you ship. In this tutorial you will build a Go web service first with a single-stage Dockerfile and then with a multi-stage one, compare the sizes, speed up rebuilds with BuildKit cache mounts, and apply the same pattern to a Python application.
Prerequisites
To follow this tutorial you need:
- A machine running Ubuntu 24.04 LTS, such as a CubePath VPS, or any system with Docker Engine 23.0 or later. BuildKit, which the examples rely on, is the default builder since that release.
- A non-root user with
sudoprivileges who is a member of thedockergroup. - Basic familiarity with writing a Dockerfile.
Check that the BuildKit-based builder is available:
docker buildx version
github.com/docker/buildx v0.28.0 ...
Any recent version is fine.
Step 1 - Creating a sample Go application
Create a project directory with a minimal HTTP server:
mkdir -p ~/multistage/go-app && cd ~/multistage/go-app
nano main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a multi-stage build")
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Create the module file. It declares the module path and the minimum Go version:
nano go.mod
module example.com/hello
go 1.25
Add a .dockerignore file so the build context does not include files the image never needs. A smaller context is faster to send to the builder and avoids leaking files such as .git or local secrets into a COPY . .:
nano .dockerignore
.git
*.md
Dockerfile*
Step 2 - Building a single-stage image as a baseline
Start with the naive approach so you have something to compare against. This Dockerfile compiles and runs the program in the official Go image:
nano Dockerfile.single
FROM golang:1.25
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/hello .
EXPOSE 8080
CMD ["hello"]
Build it:
docker build -f Dockerfile.single -t hello:single .
The image works, but it carries the entire Go toolchain, the Debian base system and your source code. You will measure its size in Step 4.
Step 3 - Writing a multi-stage Dockerfile
Now split the build into two stages. The first stage, named build, compiles a static binary. The second stage starts from a minimal distroless image and copies only that binary:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src
# Download dependencies in their own layer so they are cached
# until go.mod changes
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hello .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hello /hello
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/hello"]
The important parts:
AS buildnames the stage so later stages can reference it withCOPY --from=build.CGO_ENABLED=0produces a statically linked binary that does not need the C library, so it runs on an image without one.-trimpathremoves local file paths from the binary, and-ldflags="-s -w"strips debug symbols, which makes the binary smaller.gcr.io/distroless/static-debian12:nonrootcontains only CA certificates, time zone data and a non-root user. There is no shell or package manager, which leaves very little for an attacker to use.- Copying
go.modbefore the rest of the source means that editingmain.godoes not invalidate the dependency download layer.
Build the image:
docker build -t hello:multi .
Run it and send a request:
docker run -d --name hello -p 8080:8080 hello:multi
curl http://localhost:8080
Hello from a multi-stage build
Remove the test container:
docker rm -f hello
Step 4 - Comparing image sizes
List both images:
docker image ls hello
Exact numbers depend on the Go version and platform, but the difference is always around two orders of magnitude:
REPOSITORY TAG IMAGE ID CREATED SIZE
hello multi 3c1d2e4f5a6b 1 minute ago 9.1MB
hello single 7a8b9c0d1e2f 3 minutes ago 880MB
You can see where the size of each image comes from, layer by layer, with docker history:
docker history hello:multi
The multi-stage image contains only the distroless base layers plus one layer of a few megabytes with your binary. Smaller images pull faster on every node, start faster and carry fewer packages to show up in vulnerability scans.
Step 5 - Speeding up rebuilds with cache mounts
Layer caching skips steps whose inputs have not changed, but as soon as you edit a source file, go build starts again from scratch inside a fresh layer. BuildKit cache mounts keep the compiler cache and module cache between builds without storing them in the image.
Replace the two RUN lines in the build stage of Dockerfile:
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hello .
Build once to fill the cache, change the message in main.go, and build again while timing it:
docker build -t hello:multi .
sed -i 's/multi-stage build/cached build/' main.go
time docker build -t hello:multi .
The second build recompiles only the changed package, which in a real project with dependencies turns minutes into seconds. The cache lives in the builder, not in the image, so the final image size does not change.
Step 6 - Building a specific stage for debugging
Because the final image has no shell, you cannot docker exec into it to look around. When you need to debug the build, stop at an earlier stage with --target:
docker build --target build -t hello:build .
docker run --rm -it hello:build bash
Inside that container you have the full Go toolchain and the compiled binary at /out/hello. Type exit when you are done. The same flag lets one Dockerfile produce several images, for example a test stage that runs go test ./... in CI.
Step 7 - Applying the pattern to a Python application
Interpreted languages benefit too. Packages with C extensions need a compiler to install, but not to run. The pattern is to install dependencies into a virtual environment in a build stage and copy the whole environment into a slim runtime stage.
Create a small Flask project:
mkdir -p ~/multistage/py-app && cd ~/multistage/py-app
nano app.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def index():
return "Hello from Python\n"
List the dependencies:
nano requirements.txt
flask==3.1.*
gunicorn==23.*
Create the Dockerfile:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build /venv /venv
ENV PATH="/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /home/app
COPY --chown=app:app app.py .
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Both stages use the same base image, so the virtual environment's paths and Python version match. If a dependency needs a compiler, add RUN apt-get update && apt-get install -y --no-install-recommends build-essential to the build stage only; it never reaches the final image.
Build and test it:
docker build -t hello-py .
docker run -d --name hello-py -p 8000:8000 hello-py
curl http://localhost:8000
Hello from Python
Clean up:
docker rm -f hello-py
Choosing a runtime base image
The final stage is where most of the size is decided:
| Base image | Typical size | Shell | Good for |
|---|---|---|---|
scratch | 0 MB | No | Static binaries that need nothing, not even CA certificates |
gcr.io/distroless/static-debian12 | about 2 MB | No | Static Go or Rust binaries that make HTTPS calls |
alpine | about 8 MB | Yes | Small images when you need a shell or apk packages |
python:3.12-slim, node:22-slim | 40 to 80 MB | Yes | Interpreted languages that need glibc |
Alpine uses musl instead of glibc. Python wheels and other prebuilt binaries built for glibc may not work on it or may need compiling from source, so for Python and Node.js the -slim Debian variants are usually the safer choice.
Troubleshooting
exec /hello: no such file or directoryon a distroless or scratch image: the binary is dynamically linked. Build Go withCGO_ENABLED=0, or usegcr.io/distroless/base-debian12, which includes glibc.the --mount option requires BuildKit: you are using the legacy builder. Upgrade Docker Engine, or build withdocker buildx build.COPY --from=buildcannot find a file: check the path in the build stage withdocker build --target buildand inspect the container as shown in Step 6.x509: certificate signed by unknown authorityat runtime: the final image lacks CA certificates. Use a distroless base instead ofscratch, or copy/etc/ssl/certs/ca-certificates.crtfrom the build stage.
Conclusion
You built the same service as an 880 MB single-stage image and as a 9 MB multi-stage image that runs as a non-root user, added BuildKit cache mounts for fast rebuilds, and used the same approach for a Python application. Next, push these images to a private registry, add a HEALTHCHECK or an orchestrator health probe, and scan the final images in CI to confirm they stay free of build tooling.
