In this blog post How to Build Lean Reliable .NET Docker Images for Production we will walk through how to package .NET apps into small, fast, and secure Docker images that run the same way on your laptop and in production.
We’ll start with a high-level view of how containers and .NET fit together, then move into a practical, battle-tested Dockerfile you can adapt today. Along the way we’ll cover base image choices, multi-stage builds, caching, security hardening, multi-arch images, and CI/CD tips.
Why containers and .NET play well together
Docker packages your application and its dependencies into an immutable image. At runtime, that image becomes a container with isolated processes, limited filesystem views, and predictable configuration. The image is built as a stack of layers; when you change only part of your app, Docker reuses unchanged layers for speed.
.NET complements this model nicely. Microsoft ships official SDK and runtime images with consistent tags, security updates, and support for multiple architectures. With multi-stage builds, you can compile in a larger SDK image and run in a smaller runtime image. That gives you fast, repeatable builds and compact production containers.
The moving parts in a .NET container
- Base image: The starting point. For .NET you’ll usually pick SDK (build time) and ASP.NET or runtime (run time) images from Microsoft’s registry.
- Layers: Each command in your Dockerfile adds a layer. Arrange commands to maximize caching and minimize rebuilds.
- Registry: Where images live (e.g., Docker Hub, ACR, ECR, GCR). CI/CD pushes images there; orchestrators pull them.
- Entrypoint: The command that starts your app inside the container (e.g., dotnet MyApp.dll).
Choose the right base images for .NET
Microsoft publishes well-maintained .NET images on mcr.microsoft.com. Common choices:
- mcr.microsoft.com/dotnet/sdk:8.0 – full SDK for restore, build, and publish.
- mcr.microsoft.com/dotnet/aspnet:8.0 – runtime for ASP.NET Core web apps.
- mcr.microsoft.com/dotnet/runtime:8.0 or runtime-deps:8.0 – for console/services without ASP.NET.
OS flavor trade-offs:
- Debian/Ubuntu (default): Great compatibility, good tooling, slightly larger.
- Alpine: Very small, uses musl libc; some native dependencies may need tweaks. Validate performance and compatibility.
Tip: Pin your base images to a major.minor version (e.g., 8.0) or digest for deterministic builds.
A production-grade Dockerfile for ASP.NET Core
Here’s a clean, multi-stage Dockerfile for a typical ASP.NET Core app named MyApp. It favors caching, small size, and non-root execution.
# syntax=docker/dockerfile:1.7
ARG DOTNET_VERSION=8.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# 1) Copy project files first to enable restore caching
COPY MyApp/*.csproj MyApp/
# Optional: include a NuGet.Config if you use private feeds
# COPY NuGet.Config ./
RUN dotnet restore MyApp/MyApp.csproj --disable-parallel
# 2) Copy the rest of the source and build
COPY . .
WORKDIR /src/MyApp
RUN dotnet build MyApp.csproj -c Release -o /app/build
RUN dotnet publish MyApp.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
ENV ASPNETCORE_URLS=http://+:8080
WORKDIR /app
# Create and use a non-root user
RUN useradd -m -u 10001 app && chown -R app /app
USER app
EXPOSE 8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]
What this Dockerfile does
- Separates build and runtime: compile in the SDK, run in the slim runtime.
- Optimizes caching: dotnet restore happens before copying the full source.
- Publishes for Release: strips unneeded build assets and reduces size.
- Runs as non-root: a simple, effective security improvement.
Build and run locally
docker build -t myapp:1.0 .
docker run --rm -p 8080:8080 myapp:1.0
Open http://localhost:8080 to test.
Make builds fast and repeatable
Use a .dockerignore
Prevent large or irrelevant files from bloating your build context:
# .dockerignore
**/bin
**/obj
.git
node_modules
*.user
*.suo
Dockerfile
docker-compose.*
Lock dependencies
For deterministic restores, check in packages.lock.json and use locked mode in CI:
dotnet restore --locked-mode
Structure your Dockerfile for cache hits
- Copy .csproj first and restore; only when package references change will that step invalidate.
- Copy the rest of the source and build/publish afterwards.
- Avoid RUN commands that change frequently near the top of the file.
Keep images small and secure
- Choose the smallest suitable runtime: aspnet for web, runtime/runtime-deps for services.
- Publish with trimming where appropriate:
dotnet publish -c Release -p:PublishTrimmed=true
(test thoroughly as trimming can remove reflection-heavy code). - Run as a non-root user (as shown) and keep file permissions minimal.
- Avoid installing shell tools in the runtime image unless you need them.
- Scan images for vulnerabilities regularly with your preferred scanner.
- Keep base images updated; rebuild when Microsoft ships security patches.
Working with secrets and configuration
Never bake secrets into images. Prefer environment variables, mounted files, or a secrets manager. For private NuGet feeds during build, use Docker BuildKit secrets:
# build with BuildKit and a secret
# DOCKER_BUILDKIT=1 docker build --secret id=nuget_config,src=./NuGet.Config -t myapp:1.0 .
# Dockerfile snippet (build stage)
# RUN --mount=type=secret,id=nuget_config,target=/root/.nuget/NuGet.Config \
# dotnet restore MyApp/MyApp.csproj
At runtime, keep configuration external: pass environment variables (-e Key=Value
) or mount configuration files (-v /path:/app/config
), or rely on your orchestrator’s config/secrets facilities.
Multi-architecture images
If you deploy to both x64 and ARM64 (e.g., cloud + Apple Silicon dev machines), publish a multi-arch image:
docker buildx create --use --name mybuilder
# Build and push a manifest for amd64 and arm64
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/myapp:1.0 \
--push .
Add tests to your image build
Catch issues early by running tests in a separate stage. Example layout:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS tests
WORKDIR /src
COPY . .
RUN dotnet test --configuration Release --no-build --logger "trx;LogFileName=test.trx"
Wire this stage before publish so failing tests stop the pipeline.
Optional advanced pattern Native AOT for ultra-small services
For certain workloads, .NET 8’s Native AOT can produce a single self-contained binary with very fast startup.
# Dockerfile (simplified)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -r linux-x64 -p:PublishAot=true -o /app/out
FROM debian:bookworm-slim AS runtime
RUN useradd -m -u 10001 app
WORKDIR /app
COPY --from=build /app/out/MyApp .
USER app
ENTRYPOINT ["./MyApp"]
Note: AOT has compatibility limits; validate your libraries and reflection usage.
Quick checklist
- Use multi-stage builds (SDK for build, runtime for run).
- Pin base image versions; rebuild regularly for patches.
- Adopt a solid .dockerignore.
- Run as non-root; keep images minimal.
- Externalize secrets and config.
- Automate with BuildKit, caching, and CI/CD.
- Scan images and monitor CVEs.
Wrapping up
Building lean, reliable .NET Docker images is mostly about good structure and consistent habits: pick the right base, use multi-stage builds, keep layers cache-friendly, and ship non-root runtime images. With these practices in place, your images will build faster, deploy safer, and run predictably from developer machines to production.
Discover more from CPI Consulting
Subscribe to get the latest posts sent to your email.