Multi‑stage Docker builds for smaller production images
Reduce Docker image size by using multi‑stage builds keeping only runtime artifacts in the final layer while discarding build tools.
10 Feb 2026, 01:10 UTC

When your Docker images outgrow their usefulness
Modern applications often accumulate build tools, source files, and debug symbols inside their container images. This inflates image size, slows deployment, and increases attack surface. A common solution is the multi‑stage build, a Docker feature that separates the build environment from the runtime environment.
Decision: multi‑stage build or not?
Choose multi‑stage when your goal is a lean production image and you can tolerate a modest change in how you write Dockerfiles. Choose single‑stage when you need the simplest possible file, accept a larger image, or your build pipeline heavily relies on sharing layers across many services.
Constraints to weigh
- Base‑image compatibility: The final stage’s base image must provide the runtime libraries your application expects. Mismatched glibc versions, for example, will cause the container to fail at startup.
- Build‑cache behavior: Docker caches each instruction. Changing a file in an early stage invalidates all subsequent stages, forcing a full rebuild. If you frequently tweak source code, this can increase CI time.
- Debuggability: Stripping away build tools and source makes it harder to inspect the running container. You may need to keep a separate development image.
Supported options at a glance
| Option | Typical base image | Artifacts retained | Image size (typical) | Cache impact |
|---|---|---|---|---|
| Single‑stage | node:16 or python:3.11 | Source, modules, build tools, runtime | 150–900 MB | One cache chain; changing source invalidates from COPY onward |
| Multi‑stage (standard) | Builder: node:16; Runtime: node:16‑alpine or distroless | Only compiled output, runtime dependencies | 25–150 MB | Two cache chains; early‑stage changes rebuild both stages |
| Multi‑stage with buildx | Same as standard, plus cross‑platform output | Same as standard | Similar to standard | Same cache semantics, with platform‑matrix overhead |
Trade‑offs in practice
Multi‑stage builds typically reduce final image size by 70–80% compared with a monolithic image, because the runtime layer no longer carries gcc, npm, or source control metadata. The price is a slightly more complex Dockerfile and the cache‑invalidation caveat: if you update a dependency file that's copied early, Docker rebuilds every later stage. For teams that deploy frequently with stable dependency trees, the rebuild cost is negligible. For fast‑changing codebases, you might pair multi‑stage builds with docker build --cache‑from or orchestrate rebuilds via your CI system.
Base‑image choice matters just as much as the multi‑stage pattern. Alpine Linux is musl‑based; if your application links against glibc‑compiled binaries, Alpine may cause runtime failures. Distroless images from Google eliminate even the shell, which is great for security but requires your application to bundle its own entrypoint logic.
Concrete implementation
Multi‑stage Dockerfile for a Node.js application
# syntax=docker/dockerfile:1
# Builder stage: install dependencies and compile
FROM node:16 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Final stage: only the production runtime and built artifacts
FROM node:16-alpine
WORKDIR /app
# Copy only the built output from the builder stage
COPY --from=builder /app/dist ./dist
# Copy only the production node modules needed to run
COPY --from=builder /app/node_modules ./node_modules
# Expose the port your app listens on
EXPOSE 8080
# Start the server
CMD [ 'node', 'dist/index.js' ]
Where to run this: on any host with Docker Engine installed and running. You need read access to the project directory and membership in the docker group (or root privileges) to execute docker build.
Meaningful placeholders: node:16 can be replaced with any language‑specific build image; npm ci versus npm install ensures a deterministic install; --from=builder references the earlier AS builder label.
Expected checks after building: docker images should show the new image with a size noticeably smaller than the original. Running the container with docker run -p 8080:8080 your‑image should start the service; a curl to http://localhost:8080 should return a healthy response.
Relevant risk: if your application relies on binaries compiled in the builder (e.g., native addons), those binaries must be copied explicitly in the final stage. Omitting the copy will result in missing‑file errors at runtime.
Limitations and practical verification
- Build the image without cache:
docker build --no-cache -t myapp:multistage . - Record the image size:
docker images myapp:multistage; note the IMAGE ID and SIZE. - Compare against your previous single‑stage image size with the same command.
- Run the container and test functionality:
docker run -d -p 8080:8080 --name myapp-run myapp:multistage - Send a request:
curl -s http://localhost:8080/healthshould return a JSON status code 200. - Inspect the running process:
docker exec myapp-run ps auxconfirms only your application and its runtime dependencies are present.
Cache invalidation is the most common pain point. Changing package.json or any file listed before the COPY --from=builder line forces Docker to rebuild the builder stage and, consequently, the final image. If your development cycle touches these files often, consider using docker build --cache-from or restructuring the Dockerfile to copy dependency manifests late.
Base‑image mismatch is another risk. The builder uses node:16 (debian‑based, glibc) while the final stage uses node:16-alpine (musl). If you compile native modules in the builder and expect them to run in alpine, you'll hit cannot open shared object file. Stick with the same family or compile statically.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.