# Docker Multi-Stage Builds: Smaller and More Secure Images

> Use Docker multi-stage builds to separate the build from the runtime: examples in Go and Node.js, build targets, distroless images and non-root users.

- Source: https://www.itwonderlab.com/docker-multi-stage-builds/
- Published: 2026-10-06
- Updated: 2026-10-06
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## The problem: build tools in production

To compile an application you need a compiler, headers, a package manager and test tools. To run it you only need the result. If you build and run in the same image, you ship hundreds of megabytes that you never use, plus a shell and tools an attacker would love to find.

A **multi-stage build** uses several `FROM` instructions in one [Dockerfile](https://www.itwonderlab.com/dockerfile-tutorial/). Each `FROM` starts a new stage. You build in the first stage and copy **only the artifact** into the final, small stage. Everything else is discarded.

![Multi-stage build: a first stage with the compiler and sources builds the binary, and the final stage copies only the binary into a small runtime image](https://www.itwonderlab.com/media/tutorials/Diagrams/ITWL-Multi-Stage-Build.svg "Only the last stage becomes the image you push")

## Example in Go

```go title="main.go"
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from a tiny container")
	})
	http.ListenAndServe(":8080", nil)
}
```

```dockerfile title="Dockerfile"
# ---- Stage 1: build
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
# A static binary does not need libc in the final image
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server .

# ---- Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
```

Create the module file and build:

```shell
$ go mod init example.com/hello    # or create go.mod by hand
$ docker build -t hello-go:1.0 .
$ docker image ls hello-go
REPOSITORY   TAG   IMAGE ID       CREATED          SIZE
hello-go     1.0   9a1c0f5e1b2d   10 seconds ago   7.4MB
$ docker run --rm -p 8080:8080 hello-go:1.0
```

Compare it with `golang:1.24`, which is several hundred megabytes. The final image contains the binary and almost nothing else: no shell, no package manager, and it runs as the non-root user that the `:nonroot` tag defines.

> [!NOTE]
> The exact size depends on the Go version and the base image. Run `docker image ls` to see yours.

## Example in Node.js

Interpreted languages still benefit: install the development dependencies and compile in one stage, ship only production dependencies in the other.

```dockerfile title="Dockerfile"
FROM node:22-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
```

Docker builds only the stages that the final one depends on, and BuildKit builds independent stages (`build` and `prod-deps` here) in parallel.

## Build one stage: --target

Name your stages with `AS` and stop at any of them. This is useful to run tests in CI or to have a debug image:

```shell
$ docker build --target build -t myapp:build .
$ docker build --target runtime -t myapp:1.0 .
```

A common pattern is a `test` stage that runs the tests, so that `docker build --target test .` fails the pipeline when a test fails, while the normal build skips it.

## Copy from another image

`COPY --from` accepts an image as well as a stage. It is a handy way to take a single binary from a published image:

```dockerfile
COPY --from=busybox:1.37 /bin/busybox /usr/local/bin/busybox
```

## Choosing the final image

| Final image | Size | Shell | When to use it |
|---|---|---|---|
| `scratch` | 0 | No | A fully static binary (Go, Rust). You must add CA certificates and users yourself. |
| `distroless` | Very small | No | Static or language-runtime images (Java, Python, Node) with a minimal attack surface. |
| `alpine` | About 5 MB | Yes (BusyBox) | Small and with a package manager. It uses musl instead of glibc, which can break some binaries. |
| `-slim` (Debian) | Tens of MB | Yes | A good default when you need glibc and compatibility. |

Distroless images have no shell, so you cannot `docker exec ... sh`. For debugging, use a debug tag (`:debug`) or attach a temporary container with `docker run --pid container:<name> --network container:<name> -it nicolaka/netshoot`.

## Next steps

- Persist data with [volumes and bind mounts](https://www.itwonderlab.com/docker-volumes-bind-mounts/).
- Speed up builds with [BuildKit cache mounts](https://www.itwonderlab.com/docker-buildkit-buildx/).
- Scan the result with [Trivy](https://www.itwonderlab.com/docker-image-security-scanning/).
