A Node.js image built the straightforward way easily reaches 1.2 GB, and most of that is a compiler toolchain, dev dependencies and source files that the running container never touches. Multi-stage builds fix this by compiling in one stage and copying only the finished artifacts into a second, minimal stage. This tutorial rewrites a single-stage Dockerfile step by step and measures the difference.
What is a Docker multi-stage build?
A Docker multi-stage build is a single Dockerfile containing two or more FROM instructions, where a later stage copies finished artifacts out of an earlier one with COPY --from=<stage>, so compilers, package managers and build dependencies never end up in the published image.
Only the layers of the last stage (or the stage selected with --target) become the final image. Everything else exists solely during the build and is discarded afterwards. The feature has been available since Docker 17.05 and is the default approach for any language with a compile or bundle step.
Prerequisites
- A Linux host with Docker Engine 23.0 or newer, where BuildKit is the default builder
- A user account in the
dockergroup orsudorights - An application with a build step (this tutorial uses a Node.js and a Go example)
- Roughly 3 GB of free disk space for the intermediate build stages
All commands were tested on Ubuntu 24.04 running on a scalable Cloud-VPS. Check your version first:
$ docker version --format '{{.Server.Version}}'
23.0.6Measure the single-stage baseline
Before optimizing anything, build the current Dockerfile and write down its size. Every later improvement is measured against this number.
This is the typical single-stage Dockerfile for a TypeScript service:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]Build it and check the size:
$ docker build -t myapp:single .
$ docker images myapp:single --format '{{.Repository}}:{{.Tag}} {{.Size}}'
myapp:single 1.21GBThe image carries the full node:22 base (about 1.1 GB, including Python, GCC and Git for native module compilation), the complete node_modules tree with dev dependencies, and the TypeScript source next to the compiled output. The container needs none of that at runtime.
Matching infrastructure at centron
No hardware needed to follow along: ccloud³ VMs with full root access, billed by the hour and ready in seconds. Rent a cloud server →
Rewrite the Dockerfile as a multi-stage build
Split the file into a named build stage that compiles the application and a runtime stage that copies only what the process actually loads:
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]Three details carry the weight:
AS buildnames the stage so it can be referenced later. Without a name you would have to use the numeric index--from=0, which breaks as soon as you insert another stage.npm ciinstalls frompackage-lock.jsonand produces reproducible builds, unlikenpm install.npm prune --omit=devstrips dev dependencies fromnode_modulesbefore that directory is copied into the runtime stage.
The data flow looks like this:
graph TD
A["Dockerfile"] --> B["Stage build: node:22"]
B --> C["npm ci and npm run build"]
C --> D["/app/dist plus pruned /app/node_modules"]
D -->|"COPY --from=build"| E["Stage runtime: node:22-slim"]
E --> F["Published image"]
B -.->|"discarded after build"| G["Compiler, dev dependencies, source tree"]
Build and compare:
$ docker build -t myapp:multi .
$ docker images 'myapp' --format '{{.Tag}}\t{{.Size}}'
multi 241MB
single 1.21GB| Aspect | Single-stage | Multi-stage |
|---|---|---|
| Base image | node:22 |
node:22-slim |
| Compiler toolchain shipped | Yes | No |
| Dev dependencies shipped | Yes | No |
| Source code shipped | Yes | No |
| Typical size | 1.2 GB | 240 MB |
Why is the final image so much smaller?
The final image is smaller because Docker publishes only the layers of the last stage. Earlier stages are build-time scratch space: their layers stay in the local build cache and are never pushed to a registry.
This has a second effect that matters in production. Removing files in a later RUN step does not shrink an image, since the deleted data still exists in the earlier layer. A multi-stage build sidesteps that entirely, because the unwanted files were never part of the final stage's layer chain. It also shrinks your attack surface: an image without apt, curl or a compiler gives an attacker far fewer tools to work with, and image scanners report fewer CVEs simply because fewer packages are present.
Smaller images also pull faster. If your workloads scale horizontally and pull the same image onto many nodes, cutting 1 GB per pull is measurable in startup time. Persisting build caches or registry data alongside those workloads is worth planning for on the same scalable virtual machines that run the builds.
Build a single stage with --target
docker build --target stops the build at a named stage instead of running to the end. Use it to run tests inside the fully equipped build environment without shipping it:
$ docker build --target build -t myapp:build .
$ docker run --rm myapp:build npm testYou can also add a dedicated test stage that is never part of the default build path:
FROM build AS test
RUN npm run lint && npm testBecause test is not referenced by the last stage, docker build . skips it. BuildKit only builds stages the target actually depends on.
Go example: from 900 MB to 15 MB
A statically linked Go binary copied into a distroless base produces the most extreme reduction, because the runtime stage contains nothing but the binary, CA certificates and timezone data:
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]CGO_ENABLED=0produces a binary without dynamic links to glibc, which is what makes a base image without a libc work at all.-ldflags="-s -w"strips the symbol table and DWARF debug info, typically saving 25 to 30 percent of binary size.distroless/static-debian12is about 2 MB and ships no shell, sodocker exec ... /bin/shwill not work. That is intentional.
The golang:1.24 build stage is around 900 MB. The resulting image is usually 10 to 20 MB, depending on your binary.
Verify the result
Confirm the size, then confirm that the build tooling is genuinely gone:
$ docker images myapp:multi --format '{{.Size}}'
241MB
$ docker history myapp:multi --format '{{.Size}}\t{{.CreatedBy}}' | head -5
$ docker run --rm myapp:multi sh -c 'command -v gcc || echo "no compiler present"'
no compiler presentdocker history shows only the layers of the final stage. If you still see a RUN npm run build line in the output, the runtime stage inherited from the build stage instead of from a fresh base image. Check the second FROM line.
Finally, confirm the application still starts:
$ docker run --rm -d -p 3000:3000 --name myapp-test myapp:multi
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/health
200
$ docker rm -f myapp-testTroubleshooting
failed to solve: unknown stage "build" — the stage name in COPY --from=build does not match any AS <name>, or it refers to a stage defined further down the file. A stage can only copy from stages declared above it.
exec /app: no such file or directory — the binary is dynamically linked but the runtime base has no matching libc. This is the classic result of building Go with CGO_ENABLED=1 and running on alpine or distroless/static. Either set CGO_ENABLED=0 or use gcr.io/distroless/base-debian12, which includes glibc.
x509: certificate signed by unknown authority — the minimal base image has no CA bundle. Copy it from the build stage:
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/Every build reinstalls dependencies — you copied the whole source tree before installing. Copy package*.json or go.mod/go.sum first, install, and only then copy the rest. That keeps the dependency layer cached until the manifest actually changes.
Wrap-up
A multi-stage build keeps compile-time and runtime concerns in one Dockerfile while shipping only the artifacts a container needs to run. Start by naming your existing build steps as a build stage, add a slim runtime stage, and copy across exactly the paths your entrypoint reads.
From here, two additions pay off quickly: BuildKit cache mounts (RUN --mount=type=cache,target=/root/.npm npm ci) to keep package caches warm across builds, and a non-root USER in the runtime stage, which is easier to enforce once the image no longer needs a package manager.
More on Docker
- Run Ollama in a Docker Container
- Encrypted Docker Volume Backups with Restic
- Run Nextcloud with Docker Compose
Testen Sie Ihr Setup auf ccloud³
Registrieren Sie sich in der ccloud³ und erhalten Sie 200 € Startguthaben für Ihr Projekt – z. B. für eine PostgreSQL-VM mit automatischen Backups.