Multi-stage Dockerfile for .NET: build once, run lean
Published: 2026-03-08
A .NET application compiled in a container with the full SDK produces an image north of 800 MB. The same application in a multi-stage build using the runtime-only image lands under 200 MB and has no compiler, SDK, or build tools in production.
The pattern
Multi-stage builds use multiple FROM instructions. Each stage can copy files from a previous stage. Only the last stage ends up in the final image.
dockerfile# Stage 1: restore dependencies (cached separately from build)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src
COPY MyApp.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
RUN dotnet restore src/MyApp/MyApp.csproj
# Stage 2: build and publish
FROM restore AS build
COPY src/ src/
RUN dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-o /app/publish \
--no-restore
# Stage 3: runtime image (final)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# Run as non-root
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
COPY --from=build /app/publish .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
Why separate restore from build
COPY src/MyApp/MyApp.csproj src/MyApp/ + dotnet restore is a cache optimization. Docker caches each layer. If you only change application code (not .csproj dependencies), the restore step is skipped on rebuild.
Without this split:
dockerfile# WRONG: copies everything, restore reruns on every code change
FROM sdk AS build
COPY . .
RUN dotnet restore && dotnet publish
Every code change invalidates the restore cache. With the split, dependency changes (rare) trigger restore, and code changes (frequent) only touch the build layer.
Which base image to use
| Image | Size | Use case |
|---|---|---|
dotnet/sdk:8.0 |
~750 MB | Build stage only |
dotnet/aspnet:8.0 |
~215 MB | Runtime for ASP.NET apps |
dotnet/runtime:8.0 |
~190 MB | Runtime for non-web apps |
dotnet/runtime-deps:8.0 |
~95 MB | Self-contained trimmed apps |
For web apps, use aspnet (not runtime or sdk). runtime lacks ASP.NET libraries and your app will fail to start.
Non-root user
Running as root in a container means a container escape gives the attacker root on the host. Always add a system user:
dockerfileRUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
USER appuser
Reinforce with Kubernetes securityContext:
yamlspec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
containers:
- securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true requires the app to write only to explicitly mounted volumes. Mount /tmp as an emptyDir if needed:
yamlvolumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Multi-project solution
For a solution with multiple projects sharing a base library:
dockerfileFROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src
COPY MyApp.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
COPY src/MyApp.Core/MyApp.Core.csproj src/MyApp.Core/
COPY src/MyApp.Infrastructure/MyApp.Infrastructure.csproj src/MyApp.Infrastructure/
RUN dotnet restore src/MyApp/MyApp.csproj
FROM restore AS build
COPY src/ src/
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app/publish --no-restore
Copy all .csproj files before copying source — any change to a .csproj triggers restore, code-only changes skip it.
Trimmed self-contained publish
For even smaller images with no runtime dependency:
dockerfileFROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-p:PublishTrimmed=true \
-o /app/publish
FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=build /app/publish .
RUN adduser --system --uid 1001 appuser && chown -R appuser /app
USER appuser
ENTRYPOINT ["./MyApp"]
Image size: ~80 MB. Startup time: faster (no JIT warmup). Caveats: trimming removes unused code via static analysis and can break reflection-heavy libraries (EF Core, AutoMapper). Test thoroughly.
BuildKit and layer caching in CI
yamlbuild:
variables:
DOCKER_BUILDKIT: "1"
script:
- |
docker buildx build \
--cache-from type=registry,ref=${REGISTRY}/myapp:cache \
--cache-to type=registry,ref=${REGISTRY}/myapp:cache,mode=max \
--platform linux/amd64 \
--tag ${REGISTRY}/myapp:${CI_COMMIT_SHORT_SHA} \
--push .
mode=max caches all intermediate layers, not just the final one. On a clean runner, this cuts .NET build time from 4–5 minutes to under 1 minute for code-only changes.
Health check
dockerfileHEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1
Kubernetes ignores the Dockerfile HEALTHCHECK and uses its own probes, but this is useful for local development with docker run.