Docker Images for .NET: From 800MB to 100MB
Layer-by-layer walkthrough of shrinking a .NET 9 production image: multi-stage builds, distroless base, trimming, and the one mistake that wipes out all the gains.
- Author
- Randhir Jassal
- Published
- Reading time
- 10 min read
Starting point: the naive Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o out
ENTRYPOINT ["dotnet", "out/MyApi.dll"]
Built image: 812 MB. Boot time: 4.2s. Pull time over a slow CI runner: 90s+. Every CI build pushes a fresh 800MB blob.
Win 1: multi-stage build (~580 MB saved)
The SDK image is 700+ MB. You don't need it at runtime — only the much smaller ASP.NET Core runtime.
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
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
The runtime stage doesn't include the SDK at all. Image: 230 MB.
Win 2: alpine or chiseled base (~110 MB saved)
aspnet:9.0 defaults to Debian. The Alpine variant strips musl-incompatible bits and uses busybox:
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS runtime
Image: 120 MB. Caveat: native AOT and globalisation-invariant mode need extra packages.
For the absolute minimum, use a chiseled (distroless) base:
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled-extra AS runtime
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["./MyApi"]
That requires native AOT publishing (PublishAot=true). Image: 65 MB. Boot time: 100ms.
Win 3: layer ordering for cache hits (CI speed)
Without proper ordering, every code change triggers a dotnet restore. Copy .csproj files first, restore, then copy source.
# Restore layer (cached unless csproj changes)
COPY src/MyApi/*.csproj src/MyApi/
COPY src/MyApi.Domain/*.csproj src/MyApi.Domain/
RUN dotnet restore src/MyApi/MyApi.csproj
# Source layer (cached unless src changes)
COPY src/ src/
RUN dotnet publish src/MyApi/MyApi.csproj -c Release -o /app/publish --no-restore
A pure code change now skips restore entirely. CI builds drop from 90s to 15s.
Win 4: assembly trimming (~30 MB saved)
For published apps that don't use reflection-heavy frameworks:
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
Trims unused assemblies. Test thoroughly — some libraries (older Newtonsoft, dynamic proxies) break.
The mistake that wipes out everything
COPY . .
Without a .dockerignore, you include bin/, obj/, .git/, node_modules/, secrets, test data. The build context balloons and so does any layer that touches root.
Always start with:
# .dockerignore
**/bin/
**/obj/
**/.vs/
**/.idea/
**/.git/
**/node_modules/
**/coverage/
**/*.user
README.md
LICENSE
.env*
*.log
Final result
| Strategy | Image size | Boot time |
|---|---|---|
| Naive single-stage | 812 MB | 4.2s |
| Multi-stage | 230 MB | 2.1s |
| + Alpine | 120 MB | 1.4s |
| + Trimming | 92 MB | 1.1s |
| + Chiseled + AOT | 65 MB | 100ms |
The first three rows take 30 minutes to apply and pay for themselves in CI time within a week. The last row (AOT + chiseled) is a real project that takes a sprint to vet. Don't skip the first three by chasing the fourth.
Get the next issue
A short, curated email with the newest posts and questions.