Docker multi-stage builds changed the way I ship software. Before I started using them, my production images were bloated with build tools, compilers, and test dependencies that had no business being in production. A simple .NET API image sitting at 1.2 GB. A React app with the entire Node.js toolchain baked in. It was embarrassing.
What Multi-Stage Builds Actually Are
A multi-stage build lets you use multiple FROM instructions in a single Dockerfile. Each stage is isolated. You can copy only the artifacts you need from one stage into the next. The final image contains only what you explicitly put in it.
Here is what a typical .NET API Dockerfile looks like with multi-stage builds:
# Stage 1: build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish
# Stage 2: runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
The SDK image is around 750 MB. The ASP.NET runtime image is around 220 MB. Your final image only ships the runtime stage. The build stage is discarded.
The Performance Impact Is Real
Beyond image size, multi-stage builds affect build times in CI. Docker caches each layer. If your dependencies do not change between commits, the cache hit on the dependency restore step saves minutes on every pipeline run.
In one project I worked on, switching to multi-stage builds reduced the Docker image from 1.1 GB to 180 MB. The pull time in Kubernetes dropped from 40 seconds to under 8. That adds up across dozens of deployments a week.
A React Frontend Example
The same pattern applies to frontend builds:
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
The Node.js build stage does the heavy lifting. The runtime stage is just nginx with your static files. No node_modules. No source code. A typical React app ends up under 30 MB this way.
Running Tests Inside the Build
One pattern I find underused is running tests as a dedicated stage. If the test stage fails, the build stops. Nothing gets deployed.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
FROM build AS test
RUN dotnet test --no-restore
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
You build the test stage only when you need it. In CI, you explicitly target it:
docker build --target test -t myapp:test .
This separates the test run from the production image build cleanly.
Common Mistakes to Avoid
The biggest mistake I see is copying the wrong things between stages. People sometimes copy the entire source tree into the runtime stage instead of just the compiled output. The whole point of multi-stage builds is selectivity.
Another issue is ignoring the .dockerignore file. If you copy your entire project directory without a proper .dockerignore, you send your local node_modules or bin folders into the build context. That slows down every build, even with caching.
A minimal .dockerignore for a .NET project:
**/bin/
**/obj/
**/.git/
**/*.user
When Multi-Stage Builds Are Not Enough
Multi-stage builds solve image bloat and simplify Dockerfiles. They do not replace a proper base image strategy. If you are still running on debian:latest as your runtime base, you are carrying a lot of unnecessary surface area. Alpine-based images or distroless images take this further.
For production services where image size and attack surface really matter, combine multi-stage builds with distroless base images. Google publishes distroless images for .NET, Java, and Go. The result is an image with no shell, no package manager, and a dramatically reduced set of installed binaries.
The Bottom Line
Multi-stage builds are one of those Docker features that pay back the small investment immediately. Smaller images pull faster, reduce storage costs, and shrink the attack surface. Moving tests into the build pipeline catches failures earlier. There is no good reason to ship a 1 GB image when a 150 MB one does the same job.
If you are not already using multi-stage builds for every service you containerise, start now. The Dockerfile gets longer by ten lines. The benefits compound over time.



