Containerization
Packaging .NET for containers -- multi-stage Dockerfiles, the dotnet publish container target, chiseled and distroless images, and container-aware runtime tuning
Containerization
.NET is a first-class container citizen, and it has been for years. Microsoft ships official images for every release on mcr.microsoft.com, the SDK can build an OCI image with no Dockerfile at all, and -- the part teams underuse -- the runtime is container-aware, reading cgroup CPU and memory limits and reconfiguring itself accordingly. The goals for a production image are the usual trio: small, so pulls are fast and the attack surface is small; secure, meaning non-root and minimal packages; and fast to start, so scale-out and cold deploys do not stall behind a warming JIT. Modern .NET gives you strong, first-party tools for all three -- the trick is knowing which lever does what.
The Multi-Stage Dockerfile
The multi-stage build is the baseline, and almost every hand-written .NET Dockerfile should be one. You build with the SDK image -- which carries the compiler, NuGet, and the full toolchain -- and you run on the slim runtime image, which carries only what is needed to execute. The build tools never ship to production: they exist only in an intermediate stage that the final image copies a single directory out of and then discards. This is the single biggest size and security win available, and it costs you nothing.
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["dotnet", "MyApi.dll"]The ordering of the COPY lines is the layer-caching detail that matters. Copying the .csproj files and running dotnet restore before copying the rest of the source means the expensive restore layer is keyed only on your project and package references. Edit a controller and rebuild, and the restore layer is reused from cache -- you only re-run the cheap compile. Copy everything in one COPY . . up front and every source change busts the restore cache, so every build re-downloads the world. The last detail is USER $APP_UID: $APP_UID is a built-in non-root user baked into the official images, a recent and very welcome default. Set it and your process no longer runs as root inside the container, which is exactly what you want and what most hand-rolled Dockerfiles forget.
The Image Variants
The official images come in a family, and choosing the right base is the second size-and-security decision after going multi-stage. The tradeoff runs from "easiest, largest, most debuggable" to "tiniest, hardest to poke at, smallest attack surface." Ordered with the smallest attack surface last:
aspnet/runtime-- Debian-based, with a full shell and package manager. The easiest to work with and to debug, and the largest. The sensible default when you are starting out.-alpine-- musl-based and much smaller than the Debian images. Watch for native-dependency quirks: libraries that assume glibc can misbehave against musl, and you occasionally need to installicu/tzdatapackages yourself.- chiseled (
-noble-chiseled) -- Ubuntu chiseled: no shell, no package manager, no root, only the exact files .NET needs to run. Tiny, with a dramatically smaller attack surface, and the modern recommended production base. The tradeoff is the point of the design -- you cannotdocker execa shell to poke around inside it, because there is no shell. Debug with ephemeral debug containers (kubectl debug) that attach a toolbox to the running pod instead of baking tools into the image. - chiseled + Native AOT / runtime-deps -- for an AOT-published app there is no managed runtime to host at all. You ship your native binary onto a
runtime-depsimage (or its chiseled variant), which carries only the native OS dependencies and no .NET runtime. This is the smallest and fastest-starting option there is.
| Base | Size | Shell / pkg mgr | Root | Best for |
|---|---|---|---|---|
aspnet (Debian) | Largest | Yes | Yes (until USER) | Getting started, easy debugging |
-alpine | Small | Yes (apk) | Yes (until USER) | Size with a shell still available |
-noble-chiseled | Tiny | No | No | Recommended production default |
chiseled runtime-deps + AOT | Smallest | No | No | AOT apps, fastest cold start |
Building Without a Dockerfile
The SDK has built-in container support, and for simple services it removes the Dockerfile entirely. You point dotnet publish at a container target and it produces an OCI image directly -- no docker build, no Dockerfile to maintain and keep in sync with your project.
dotnet publish -t:PublishContainer -c ReleaseBy default this uses chiseled, non-root base images, so you land on the recommended secure baseline without thinking about it. It honors MSBuild properties in the .csproj -- ContainerBaseImage to override the base, ContainerImageTags to set the tags it produces, ContainerRegistry to push directly -- so the image definition lives next to the rest of your build configuration rather than in a separate file. This is genuinely great for simple microservices and workers where a Dockerfile would be pure ceremony. Reach back for a hand-written Dockerfile when you need something the SDK target does not model: custom system packages, a multi-tool build stage, or a non-standard layout.
The Runtime Is Container-Aware
This is the part most teams miss, and it is the one that bites in production. The .NET runtime reads the container's cgroup CPU and memory limits and sizes itself against those, not against the host machine. GC heap sizing, GC heap count, and thread-pool defaults all adapt to the limits you set on the container -- which means if you set those limits carelessly, you are silently reconfiguring the runtime.
Memory limits are the sharp one. Set a container memory limit and the GC will size its heaps against that limit -- but Server GC, which is the default for ASP.NET Core, is tuned for throughput and will grow heaps optimistically, holding more memory before it collects. On a generously sized service that is exactly what you want. On a tiny memory-constrained sidecar it can push you into the OOM-killer. For those cases, consider switching to Workstation GC, or cap the heap explicitly with DOTNET_GCHeapHardLimit, or enable the conserve-memory settings so the GC trades some throughput for a smaller footprint.
// In the .csproj -- pick a GC mode deliberately per workload
// <ServerGarbageCollection>false</ServerGarbageCollection> // Workstation GC for tiny containers
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>CPU limits feed the same machinery. The CPU quota the container is granted affects how many GC heaps the runtime creates and how it sizes the thread pool; a quota below one full core changes behavior meaningfully, and pinning it too low can starve a Server GC that expected several cores. DOTNET_gcServer toggles the GC mode and DOTNET_GCHeapCount pins the heap count when you want to override the runtime's read of the CPU budget. The mental model to carry: the container's limits are inputs to the runtime's self-tuning, so set them on purpose.
The last container-aware lever is startup latency. By default .NET uses TieredCompilation, which JITs hot code lazily, so the very first requests after a deploy or a scale-out event run cold and slow while the JIT warms up. ReadyToRun (R2R) pre-compiles your assemblies to native code at publish time, and Native AOT goes all the way to a fully ahead-of-time binary -- either one cuts that warmup, which is the difference between a clean rollout and a wave of latency spikes every time the orchestrator adds a pod.
Health and Lifecycle
A container that an orchestrator cannot observe or shut down cleanly is a liability regardless of how small it is. Expose health-check endpoints -- a liveness probe so Kubernetes can restart a wedged process, and a readiness probe so it holds traffic until dependencies are reachable -- and wire them to your actual dependency checks rather than returning a constant 200. ASP.NET Core's health-checks package maps these to endpoints in a couple of lines.
Honor SIGTERM for graceful shutdown. When the orchestrator stops a pod it sends SIGTERM and then waits for terminationGracePeriodSeconds before the hard SIGKILL. The host already wires this up: IHostApplicationLifetime and the stopping token fire on SIGTERM, ASP.NET Core stops accepting new connections, and in-flight requests drain. Your job is to make sure your own background work observes the stopping token and finishes within that grace window rather than getting killed mid-flight.
Finally, do not bake configuration or secrets into the image. The image is a build artifact that should be identical across every environment; the environment-specific values -- connection strings, keys, feature flags -- get injected at runtime through environment variables or your platform's secret mechanism. An appsettings.json committed into the image is fine for defaults, but anything that differs per environment, and anything secret, comes in from outside. (See the configuration page for the layering rules.)
Sharp Edges
The recurring failures are predictable. Forgetting USER leaves your process running as root inside the container -- the default if you do not say otherwise, and a finding in every security scan. Using the SDK image at runtime instead of multi-staging ships a multi-hundred-megabyte image full of build tools you do not need and do not want exposed. The lack of a shell in chiseled images surprises people the first time they try to docker exec in to debug -- that is by design, and the answer is ephemeral debug containers, not switching the base back. Memory limits combined with Server GC's optimistic sizing produce OOM-kills that look mysterious until you realize the GC is holding memory against a limit you set too tight -- cap the heap or move to Workstation GC. Missing or fake health endpoints cause bad rollouts, where the orchestrator routes traffic to a process that is not actually ready. Latency spikes after every scale-out trace back to JIT warmup with no R2R or AOT. And remember that an appsettings.json baked into the image still needs environment-variable overrides per environment -- the image is the same everywhere; the configuration is not.