Multi-Stage Builds: Small, Fast, Secure Images
Use multi-stage builds to compile your app with a full toolchain and ship only the result — shrinking a 508 MB image to 8 MB.
On this page 11 sections
Introduction
To build many applications you need compilers, package managers and build tools — but to run them, you need none of that. Multi-stage builds let one Dockerfile use a big image for building and a tiny image for running, so your final image contains only what the application needs.
- Why single-stage images often carry hundreds of megabytes of build tools.
- How to write a multi-stage Dockerfile with FROM ... AS and COPY --from.
- What the empty scratch image is, and the trade-offs of images without a shell.
- How to build a single stage with --target, and how the pattern applies to other languages.
The Problem: Build Tools in Your Image
Priya has written a small web service in Go. Go compiles a program into a single executable file, which makes it a perfect example. Her project has three files:
treeClick Run to see what this code prints.
package main
import ( "fmt" "log" "net/http" "os")
func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { host, _ := os.Hostname() fmt.Fprintf(w, "Hello from a tiny Go container (%s)\n", host) }) log.Println("listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil))}Her first Dockerfile is a straightforward single stage: start from the official Go image, copy the code, compile it, run it:
FROM golang:1.27-alpineWORKDIR /srcCOPY . .RUN go build -o /hello .EXPOSE 8080CMD ["/hello"]docker build -t hello-go:single .Click Run to see what this code prints.
It works, but the image includes the entire Go compiler and standard library source — over 70 MB just for one of its layers — none of which the finished program needs.
Writing a Multi-Stage Dockerfile
A multi-stage Dockerfile has several FROM lines. Each starts a new stage, and only the last stage becomes the final image. Earlier stages can be named with AS, and later stages copy files out of them with COPY --from:
# Stage 1: build the program with the full Go toolchainFROM golang:1.27-alpine AS buildWORKDIR /srcCOPY go.mod ./COPY *.go ./RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello .
# Stage 2: copy only the finished program into an empty imageFROM scratchCOPY /hello /helloEXPOSE 8080ENTRYPOINT ["/hello"]FROM golang:1.27-alpine AS build— the build stage, named build.CGO_ENABLED=0makes a fully static program that needs no system libraries;-ldflags="-s -w"strips debugging information to make it smaller.FROM scratch— the final stage starts from scratch, a special, completely empty image.COPY --from=build /hello /hellocopies just the compiled program from the build stage.
docker build -t hello-go:multi .Click Run to see what this code prints.
BuildKit labels each step with its stage: [build 1/5] to [build 5/5] for the build stage, then [stage-1 1/1] for the final, unnamed stage. Note that WORKDIR /src was CACHED — it was identical to the single-stage build.
Comparing the Results
docker images hello-goClick Run to see what this code prints.
From 508 MB to 8.38 MB on disk — about 60 times smaller, and only 2.52 MB to download. It still works exactly the same:
docker run -d --name hello -p 8080:8080 hello-go:multi > /dev/nullsleep 1curl -s http://localhost:8080/docker logs helloClick Run to see what this code prints.
| Benefit | Why it matters |
|---|---|
| Faster downloads and deploys | Servers pull 2.5 MB instead of 100 MB on every release |
| Less storage | Registries and servers keep many versions of each image |
| Smaller attack surface | No shell, compiler or package manager for an attacker to use |
| Fewer vulnerability reports | Security scanners have almost nothing to flag |
In Priya’s multi-stage Dockerfile, which of these ends up in the final hello-go:multi image?
Images Without a Shell
The scratch image really is empty — there is not even a shell, so you cannot exec into the container:
docker exec -it hello shClick Run to see what this code prints.
That is good for security but makes debugging harder. If you need a few basic tools, use a small base such as alpine for the final stage instead of scratch. Another popular choice is Google’s “distroless” images, which contain language runtimes and CA certificates but no shell or package manager. Note also that scratch has no CA certificates: a Go program that calls HTTPS APIs needs them copied in from the build stage (COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/).
Building One Stage: --target
--target stops the build at a named stage. That is handy for debugging the build itself, or for running tests in the build environment:
docker build -q --target build -t hello-go:build-stage . > /dev/nulldocker run --rm hello-go:build-stage go versionClick Run to see what this code prints.
Multi-Stage for Other Languages
| Language | Build stage | Final stage |
|---|---|---|
| Go, Rust | Compile with the full toolchain | scratch, distroless or alpine with just the binary |
| Java | Build a .jar with Maven or Gradle (JDK image) | A JRE-only image with the .jar |
| React, Vue, Angular | npm ci && npm run build (Node image) | nginx:alpine serving the generated static files |
| Python | Build wheels for packages that need compiling | python:slim installing the prepared wheels |
| .NET | dotnet publish (SDK image) | The smaller ASP.NET runtime image |
A front-end example — the Node image builds the site, and Nginx serves the result:
FROM node:24-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM nginx:alpineCOPY /app/dist /usr/share/nginx/htmlPut the parts of a multi-stage Dockerfile for a compiled app in order.
- FROM a minimal runtime image
- COPY --from=build the compiled output
- Copy the dependency list and download dependencies
- Copy the source code and compile it
- Set the command with CMD or ENTRYPOINT
- FROM a full SDK image AS build
Common Mistakes
- Copying a dynamically linked program into scratch, where its system libraries do not exist — build it statically (CGO_ENABLED=0 for Go).
- Forgetting CA certificates or time zone data in a scratch image when the app needs them.
- Using COPY --from with a stage name that does not match the AS name.
- Building the front end in the final stage and shipping node_modules to production.
Frequently Asked Questions
No — often it is faster overall, because each stage caches independently, and the final image is quicker to push and pull.
Yes. COPY --from=nginx:alpine /etc/nginx/nginx.conf /tmp/ copies a file straight out of any image.
Use it whenever building needs tools that running does not. For a simple Python app with no compiled packages, a single slim stage is often enough.
Key Takeaways
- Multi-stage builds separate the build environment from the runtime image.
- Name stages with FROM ... AS name; copy results with COPY --from=name.
- Only the last stage becomes the image — Priya’s went from 508 MB to 8.38 MB.
- scratch is empty: no shell, no certificates. Alpine or distroless are useful middle grounds.
- --target builds a single stage, useful for debugging and tests.
Summary
You can now build images that are small, fast and safe. Next you will learn to diagnose containers that crash, refuse to start or behave strangely.