Dockerfile Best Practices for .NET Projects
Writing a Dockerfile that works is easy. Writing one that builds quickly, produces small images, and doesn't introduce security vulnerabilities takes a bit more thought. These are the patterns that matter most for .NET projects.
1. Copy Project Files Before Source Code
This is the single most impactful optimisation. Docker caches layers, and a cached dotnet restore saves significant time:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy project files first
COPY src/MyApp/MyApp.csproj src/MyApp/
COPY src/MyApp.Core/MyApp.Core.csproj src/MyApp.Core/
COPY MyApp.sln .
# Restore (cached unless .csproj files change)
RUN dotnet restore
# Now copy everything else
COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app --no-restore
If you only change a .cs file, the restore layer is served from cache. On a project with dozens of NuGet dependencies, this can save minutes per build.
2. Use a .dockerignore File
Without a .dockerignore, Docker sends your entire build context — including bin/, obj/, .git/, and node_modules/ — to the daemon. This slows down every build.
Create a .dockerignore at the repository root:
# File: .dockerignore
**/.git
**/bin
**/obj
**/node_modules
**/.vs
**/.idea
**/TestResults
**/*.user
This can reduce your build context from hundreds of megabytes to a few megabytes.
3. Pin Image Tags
Never use latest in production Dockerfiles:
# Bad — unpredictable
FROM mcr.microsoft.com/dotnet/aspnet:latest
# Better — pinned to major.minor
FROM mcr.microsoft.com/dotnet/aspnet:9.0
# Best — pinned to specific patch
FROM mcr.microsoft.com/dotnet/aspnet:9.0.1-noble
Pinning to a specific patch version ensures your builds are reproducible. Use Dependabot or Renovate to keep these updated.
4. Run as Non-Root
The official .NET images define a non-root user. Use it:
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MyApp.dll"]
The $APP_UID environment variable is set in the base image and maps to a user with no elevated privileges. If a vulnerability in your app is exploited, the attacker has limited permissions.
5. Don't Install Unnecessary Packages
If you need to install system packages in your build stage, don't let them leak into your runtime image. Multi-stage builds handle this naturally, but be deliberate:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
libgdiplus \
&& rm -rf /var/lib/apt/lists/*
The --no-install-recommends flag avoids pulling in suggested packages, and clearing the apt lists reduces layer size.
6. Use COPY --link
Docker BuildKit supports COPY --link, which can improve caching by making layers independent:
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --link --from=build /app/publish .
With --link, the copy operation doesn't depend on the previous layers, so Docker can cache and reuse layers more aggressively.
7. Set Proper Health Checks
Don't rely on the container runtime to guess if your app is healthy:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1
For chiselled images without curl, use a .NET health check endpoint with wget or configure the orchestrator's health probes instead.
8. Use Build Arguments for Flexibility
Make your Dockerfile reusable across projects:
ARG DOTNET_VERSION=9.0
ARG PROJECT_PATH=src/MyApp/MyApp.csproj
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
COPY . .
RUN dotnet publish ${PROJECT_PATH} -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApp.dll"]
Override at build time with docker build --build-arg PROJECT_PATH=src/OtherApp/OtherApp.csproj.
9. Set Environment Variables Correctly
Use ENV for runtime configuration, not ARG:
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0
Disabling diagnostics with DOTNET_EnableDiagnostics=0 is sensible for production containers — it prevents the diagnostic pipe from being created, which reduces the attack surface slightly.
Summary
Most of these practices take minutes to implement but pay dividends across every build and deployment. Start with the .dockerignore and layer caching — those two changes alone can cut your build times dramatically. Add non-root users and pinned tags for security, and you have a solid foundation for containerised .NET applications.