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.