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:
docker image ls myappHow 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 /appOnly 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
-slimvariants (Debian based) remove documentation and extra packages but keepglibc. They are the safest default for Python and most languages.-alpinevariants are smaller still but usemuslinstead ofglibc. Some compiled packages behave differently or need rebuilding.- Distroless and
scratchimages 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
.dockerignorethat 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:
docker build --target build -t myapp:debug .Check it works
Build the new image and compare sizes:
docker build -t myapp:multistage .
docker image ls myappThe 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 buildand look inside withdocker run --rm -it myapp:debug sh.- App crashes with a missing library on Alpine: a package expects
glibc. Switch the final stage to a-slimimage. - Go binary fails with
no such file or directoryon a minimal image: it was built with cgo enabled. SetCGO_ENABLED=0or use a base withglibc. - Image is still large: check
docker historyfor big layers, and make sure.dockerignorekeepsnode_modulesand.gitout of the context.
Next steps
- Publish the image: Push images to Docker Hub or GitHub Container Registry.
- Harden what you ship: Docker security checklist for a public VPS.
- Build for ARM and x86 at once: Fix "exec format error" and image platform mismatches.
