Docker & DevOps

Smaller Docker images with multi-stage builds

Use Docker multi-stage builds to separate build tools from the runtime image. Worked examples for Node.js, Go and Python, plus slim base images and layer caching tips.

IntermediateLinux server5 min read

A typical image for a compiled or bundled app carries compilers, package managers and source files that the running app never uses. They make the image larger, slower to pull and riskier, because every extra package is something that can have a vulnerability. A multi-stage build compiles in one stage and copies only the result into a clean final stage. This guide shows the pattern for Node.js, Go and Python.

Before you start

  • A Linux VPS with Docker Engine installed. Recent Docker versions build with BuildKit by default, which multi-stage builds benefit from.
  • A project that already builds with a single-stage Dockerfile. If you are new to Dockerfiles, start with Write a Dockerfile and build your first image.
  • A note of your current image size, so you can compare:
Bash
docker image ls myapp

How multi-stage builds work

A Dockerfile can contain several FROM lines. Each one starts a new stage. You name a stage with AS, and later stages copy files from it with COPY --from=:

FROM some-build-image AS build
RUN make build

FROM some-small-image
COPY --from=build /path/to/output /app

Only the last stage becomes the final image. Everything in earlier stages, including compilers and source code, is left behind.

Example: Node.js app with a build step

For an app that runs npm run build (TypeScript, a bundler or a framework that outputs a dist folder):

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image has production dependencies and the compiled output only. Dev dependencies and source files stay in the build stage. Adjust dist/server.js to your app's entry point.

Example: Go app

Go compiles to a single binary, so the final image can be almost empty:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

CGO_ENABLED=0 produces a static binary that needs no C library. The distroless base contains certificates and time zone data but no shell or package manager. Use the Go version your project's go.mod requires; the tag above is only an example.

Note: Distroless images have no shell, so docker exec -it app sh will not work. If you need one for debugging, use alpine or debian:12-slim as the final stage instead.

Example: Python app

Python has no compile step, but packages with C extensions need build tools. Build wheels in one stage and install them in a slim one:

FROM python:3.12-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
RUN useradd --create-home appuser
USER appuser
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

The gcc toolchain stays in the build stage. Change the last line to match how you start your app.

Choose a slim base image

  • -slim variants (Debian based) remove documentation and extra packages but keep glibc. They are the safest default for Python and most languages.
  • -alpine variants are smaller still but use musl instead of glibc. Some compiled packages behave differently or need rebuilding.
  • Distroless and scratch images contain almost nothing. They suit static binaries.

Pick the smallest base your app runs on reliably, and test it before switching production.

Keep builds fast with layer caching

  • Copy dependency manifests (package-lock.json, go.sum, requirements.txt) and install dependencies before copying the rest of the code.
  • Keep a .dockerignore that excludes .git, node_modules, local virtualenvs and build output.
  • Use a cache mount so package managers keep their download cache between builds:
RUN --mount=type=cache,target=/root/.npm npm ci
  • Build a single stage when you need to debug it:
Bash
docker build --target build -t myapp:debug .

Check it works

Build the new image and compare sizes:

Bash
docker build -t myapp:multistage .
docker image ls myapp

The multistage tag should be noticeably smaller than the old one. Then run it and test the app's main page or health endpoint before you replace the old image in production. docker history myapp:multistage shows which layers remain and how large each is.

Troubleshooting

  • COPY --from=build... not found: the path in the build stage is wrong. Build that stage with --target build and look inside with docker run --rm -it myapp:debug sh.
  • App crashes with a missing library on Alpine: a package expects glibc. Switch the final stage to a -slim image.
  • Go binary fails with no such file or directory on a minimal image: it was built with cgo enabled. Set CGO_ENABLED=0 or use a base with glibc.
  • Image is still large: check docker history for big layers, and make sure .dockerignore keeps node_modules and .git out of the context.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.