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:
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:
# 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:
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:
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:
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:
- Run as non-root. The official .NET images define an
APP_UIDuser. UseUSER $APP_UIDbefore the entrypoint. - Use specific tags. Don't use
latest— pin to a specific version like9.0.1or at minimum9.0. - Scan your images. Tools like
docker scoutor Trivy can identify known vulnerabilities in your base images.
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.