What is a multi-stage Docker build and when should you use it?
A multi-stage build uses multiple FROM statements in one Dockerfile. Each stage starts fresh; you COPY --from=<stage> only what you need into the final image.
Without multi-stage — the SDK ships in production:
FROM mcr.microsoft.com/dotnet/sdk:9.0
COPY . /app
WORKDIR /app
RUN dotnet publish -o out
ENTRYPOINT ["dotnet", "out/MyApi.dll"]
Result: 800 MB image with the full SDK + your source code shipped to every server. Slow to pull, large attack surface.
With multi-stage — only the runtime artifact ships:
# Build stage — never reaches production
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln ./
COPY src/MyApi/*.csproj src/MyApi/
RUN dotnet restore src/MyApi/MyApi.csproj
COPY src/ src/
RUN dotnet publish src/MyApi/MyApi.csproj -c Release -o /app/publish --no-restore
# Runtime stage — what actually ships
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Result: ~230 MB image with just the runtime + compiled DLLs. Source code, compilers, NuGet packages — all stripped.
When to use multi-stage:
- Any compiled language (C#, Go, Rust, C++, Java) — always.
- Frontend builds where the SDK is huge (Node tools) but the runtime is just static files served by nginx.
- When you want testing as part of the build pipeline:
FROM build AS test
RUN dotnet test --no-build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
COPY --from=publish /app/publish .
docker build --target=test . runs tests; docker build . builds the runtime image. One Dockerfile, multiple outputs.
Push it further with chiseled / distroless base images:
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled-extra AS runtime
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["./MyApi"]
With PublishAot=true you get a 65 MB image with no shell, no package manager, no extra binaries — only your compiled app.
Rule: if your final image is over 200 MB for a typical .NET API, you can probably halve it with five lines of Dockerfile changes.