Tutorials  /  Docker

Shrink Docker Images with Multi-Stage Builds

LLudwig · August 2026 ·10 min read ·Docker, Tutorial

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 docker group or sudo rights
  • 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:

Console
$ docker version --format '{{.Server.Version}}'
23.0.6

Measure 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:

Dockerfile
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:

Console
$ docker build -t myapp:single .
$ docker images myapp:single --format '{{.Repository}}:{{.Tag}} {{.Size}}'
myapp:single 1.21GB

The 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.

VM

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:

Dockerfile
# 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 build names 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 ci installs from package-lock.json and produces reproducible builds, unlike npm install.
  • npm prune --omit=dev strips dev dependencies from node_modules before 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:

Console
$ 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:

Console
$ docker build --target build -t myapp:build .
$ docker run --rm myapp:build npm test

You can also add a dedicated test stage that is never part of the default build path:

Dockerfile
FROM build AS test
RUN npm run lint && npm test

Because 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:

Dockerfile
# 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=0 produces 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-debian12 is about 2 MB and ships no shell, so docker exec ... /bin/sh will 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:

Console
$ 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 present

docker 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:

Console
$ 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-test

Troubleshooting

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:

Dockerfile
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

Jetzt 200 € Guthaben sichern

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.

Ludwig Technische Redaktion

Schreibt bei centron über Linux-Administration, Container und Datenbanken – mit Fokus auf Anleitungen, die im Betrieb tatsächlich funktionieren.

Kategorie Docker
Teilen
Noch offene Fragen?

Our team will help you with your specific setup - in German or English, by people who run the platform themselves.

War dieses Tutorial hilfreich?

Your answer is stored anonymously and helps us improve our tutorials.

Kommentare

No comments yet - be the first to ask a question about this tutorial.

Sign in to comment

Comments are open to centron customers. Sign in to your account to ask a question about this tutorial.

Weiterlesen

Das könnte Sie auch interessieren

Jetzt kostenlos anfangen

Melden Sie sich an und erhalten Sie in den ersten 60 Tagen ein Guthaben von 200 € bei centron.

Dieses Werbeangebot gilt nur für neue Konten. Angebot ausschließlich für Gewerbetreibende.

Jetzt loslegen Sales kontaktieren