Multi-Stage Docker Builds for .NET Applications

A naive Dockerfile that copies your source code into an SDK image and runs dotnet publish works — but the resulting image is enormous. The .NET SDK image weighs in at over 800 MB. Your production container doesn't need the compiler, NuGet, or MSBuild. Multi-stage builds solve this by separating the build environment from the runtime environment.

The Problem with Single-Stage Builds

Consider this simple Dockerfile:

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o /out
ENTRYPOINT ["dotnet", "/out/MyApp.dll"]

This image ships the entire .NET SDK, your source code, intermediate build artefacts, and the published output. It's slow to pull, wastes storage, and increases your attack surface.

A Proper Multi-Stage Dockerfile

The fix is to use one stage for building and a separate stage for running:

Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

COPY *.sln .
COPY src/MyApp/*.csproj src/MyApp/
RUN dotnet restore

COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app/publish --no-restore

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .

USER $APP_UID
ENTRYPOINT ["dotnet", "MyApp.dll"]

The final image is based on the ASP.NET runtime image, which is roughly 220 MB — a fraction of the SDK image. Only the published output is copied across.

Optimising Layer Caching

The order of COPY instructions matters enormously for build speed. Docker caches layers, so you want the things that change least frequently at the top.

The pattern above copies project files and restores first, then copies the full source. This means NuGet restore is cached as long as your .csproj files haven't changed — which is most of the time.

For solutions with many projects, you can copy all .csproj files at once:

Dockerfile
COPY src/MyApp/*.csproj src/MyApp/
COPY src/MyApp.Core/*.csproj src/MyApp.Core/
COPY src/MyApp.Data/*.csproj src/MyApp.Data/
COPY *.sln .
RUN dotnet restore

Adding a Test Stage

You can run tests inside the build without shipping test assemblies in the final image:

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore

FROM build AS test
RUN dotnet test -c Release --no-build --no-restore

FROM build AS publish
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MyApp.dll"]

The test stage runs during the build, but its output is never included in the final image. If tests fail, the build fails.

Trimming and Self-Contained Builds

For even smaller images, publish as self-contained and trimmed, then use the runtime-deps base image:

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj \
    -c Release \
    -r linux-x64 \
    --self-contained true \
    -p:PublishTrimmed=true \
    -o /app/publish

FROM mcr.microsoft.com/dotnet/runtime-deps:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["./MyApp"]

The runtime-deps image contains only the native OS dependencies — no .NET runtime at all. Combined with trimming, you can get images well under 100 MB.

Security Considerations

A few practices to keep your images secure:

Summary

Multi-stage builds are not optional for production .NET containers. They keep your images small, your builds fast (through layer caching), and your deployments secure. The pattern is always the same: build in the SDK image, run in the smallest runtime image that meets your needs.