Containerizing .NET applications with Docker is now the default way to ship ASP.NET Core APIs, workers and background services to Kubernetes, Azure Container Apps and every other modern platform. Getting an app into a container takes five minutes. Producing an image that is small, secure, fast to build and well behaved under an orchestrator takes more care. This guide covers the Docker best practices that matter for .NET 8, .NET 9 and .NET 10: multi-stage Dockerfiles, SDK container publishing, base image choices, non-root execution, layering, globalization, health checks, configuration, Docker Compose and security scanning.
What Does Containerizing a .NET App Involve?#
A container image packages your compiled app together with everything it needs at runtime: an operating system userland, native libraries and, unless you publish self-contained, the .NET runtime itself. Microsoft publishes four image repositories on mcr.microsoft.com/dotnet, each building on the previous one:
| Repository | Contains | Use it for |
|---|---|---|
runtime-deps | OS libraries that .NET needs, no runtime | Self-contained and Native AOT apps |
runtime | The .NET runtime | Framework-dependent console apps and workers |
aspnet | The .NET runtime plus ASP.NET Core | Framework-dependent web apps and APIs |
sdk | The full SDK, build tools and a shell | Build stages and CI, never production |
Several defaults changed in recent releases, and many older blog posts no longer apply:
- .NET 8 images introduced a non-root
appuser (UID 1654, exposed asAPP_UID) and moved the default ASP.NET Core port from 80 to 8080 throughASPNETCORE_HTTP_PORTS=8080. - .NET 10 default tags such as
10.0now point to Ubuntu 24.04 "Noble" instead of Debian, and Debian images are no longer published for .NET 10. The .NET 8 and .NET 9 default tags remain on Debian 12. - .NET 10 also lets console apps publish container images without the
EnableSdkContainerSupportproperty. It adds aContainerImageFormatproperty and-aotSDK image variants that include the native toolchain for Native AOT builds. - .NET 11, which is in preview, moves its default tags to Ubuntu 26.04 "Resolute".
How .NET Container Images Are Tagged and Layered#
A tag encodes the version, the OS family and optionally a variant, for example mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra. The main OS families for .NET 10 are full Ubuntu (noble), Alpine (alpine), Azure Linux (azurelinux3.0) and the distroless flavors: Ubuntu Chiseled (noble-chiseled) and Azure Linux distroless (azurelinux3.0-distroless). Variants modify a family:
extraadds ICU and time zone data to Alpine and distroless images, for apps that need full globalization.compositeships ASP.NET Core as a single ReadyToRun composite image. It is smaller on disk with the same startup performance, but it couples your app tightly to the exact framework version in the image.aotexists only for SDK images (.NET 10 and later) and adds the native compiler prerequisites for Native AOT.
Docker images are stacks of cached, shareable layers. If your build restores packages before it copies source code, a code change reuses the cached restore layer, and nodes that already hold the aspnet base layers download only your application layer. Most best practices below exploit that cache or shrink the final layer.
Getting Started: A Production-Ready Multi-Stage Dockerfile#
A multi-stage build compiles in the large SDK image and copies only the published output into a small runtime image. This Dockerfile follows the pattern of Microsoft's official samples, adapted for a solution that uses central package management:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
# Restore in its own layer; it stays cached until a project or props file changes
COPY --link Directory.Build.props Directory.Packages.props ./
COPY --link src/Shop.Api/Shop.Api.csproj src/Shop.Api/
COPY --link src/Shop.Domain/Shop.Domain.csproj src/Shop.Domain/
RUN dotnet restore src/Shop.Api/Shop.Api.csproj -a $TARGETARCH
# Copy the source and publish for the target architecture
COPY --link src/ src/
RUN dotnet publish src/Shop.Api/Shop.Api.csproj -a $TARGETARCH --no-restore -o /app
# Runtime stage: distroless, non-root by default, no shell or package manager
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --link --from=build /app .
EXPOSE 8080
ENTRYPOINT ["./Shop.Api"]--platform=$BUILDPLATFORM runs the SDK stage natively on the build machine, and -a $TARGETARCH cross-compiles for the requested platform, which avoids slow emulation when you build Arm64 images on x64 runners. COPY --link creates layers that do not depend on the layers beneath them, improving cache reuse when the base image changes. The exec-form entry point runs the app's native launcher, so the process receives signals directly.
Pair the Dockerfile with a .dockerignore file, so that local build output and secrets never enter the build context:
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/*.user
**/node_modules/
.git/
**/appsettings.Development.json
**/secrets.jsonBuild and run it locally, then build a multi-platform image for mixed x64 and Arm64 clusters:
docker build -t shop-api:1.4.0 .
docker run --rm -p 8080:8080 shop-api:1.4.0
docker buildx build --platform linux/amd64,linux/arm64 \
-t myregistry.azurecr.io/shop-api:1.4.0 --push .Building Images Without a Dockerfile: SDK Container Publishing#
The .NET SDK can build OCI images directly with dotnet publish, with no Dockerfile and no Docker daemon. It is built into the SDK from 8.0.200 onward. It picks the right base image (aspnet, runtime or runtime-deps) from your project, runs as the non-root user by default and can push straight to a registry. You configure it with MSBuild properties:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
<ContainerRepository>shop/api</ContainerRepository>
<ContainerFamily>noble-chiseled</ContainerFamily>
<ContainerImageTags>1.4.0;latest</ContainerImageTags>
<ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ContainerLabel Include="com.contoso.team" Value="checkout" />
<ContainerEnvironmentVariable Include="ASPNETCORE_FORWARDEDHEADERS_ENABLED" Value="true" />
</ItemGroup>ContainerFamily appends a suffix to the inferred tag, so a .NET 10 web project ends up on aspnet:10.0-noble-chiseled. Setting several ContainerRuntimeIdentifiers, which must be a subset of RuntimeIdentifiers, produces a multi-architecture OCI image index. The publish commands are:
# Build and load into the local Docker or Podman daemon
dotnet publish src/Shop.Api -t:PublishContainer
# Push directly to a registry; no container runtime needed
dotnet publish src/Shop.Api -t:PublishContainer -p ContainerRegistry=myregistry.azurecr.io
# Write a tarball, for example to scan it before pushing
dotnet publish src/Shop.Api -t:PublishContainer --os linux --arch x64 \
-p ContainerArchiveOutputPath=./artifacts/shop-api.tar.gzThe limitation is that the SDK cannot run arbitrary commands, the equivalent of Dockerfile RUN. If you need extra OS packages, fonts or certificates, either build a custom base image once and point ContainerBaseImage at it, or use a Dockerfile.
Choosing a Base Image: Full, Alpine, Chiseled or Azure Linux#
Base image choice drives image size, attack surface and whether globalization works. Microsoft's image size report measured a minimal ASP.NET Core API on .NET 10 in November 2025. The compressed sizes below come from that snapshot and will drift as packages update:
| Base image and publish type | Distroless | ICU and tzdata | Non-root by default | Compressed size |
|---|---|---|---|---|
aspnet:10.0, framework-dependent | No | Yes | No | 92.48 MB |
aspnet:10.0-alpine, framework-dependent | No | No | No | 51.93 MB |
aspnet:10.0-noble-chiseled, framework-dependent | Yes | No | Yes | 52.81 MB |
aspnet:10.0-noble-chiseled-extra, framework-dependent | Yes | Yes | Yes | 67.68 MB |
runtime-deps:10.0-noble-chiseled, self-contained and trimmed | Yes | No | Yes | 21.86 MB |
runtime-deps:10.0-noble-chiseled, Native AOT | Yes | No | Yes | 11.60 MB |
For most production services, the chiseled aspnet images are the best default: no shell, no package manager and a non-root user, with no Dockerfile changes needed when your build does not rely on shell scripts. Choose the -extra variant when you need cultures or time zones. Alpine is similarly small, but it uses musl rather than glibc and runs as root unless you add USER $APP_UID. Azure Linux distroless images suit teams standardized on Microsoft's Linux distribution. Keep full Ubuntu images for cases that genuinely need a shell or apt packages.
Self-contained trimmed builds and Native AOT on runtime-deps produce the smallest images and fastest startup. However, they require trimming-compatible code, and Native AOT does not support every library. The Native AOT and trimming guide covers those constraints.
Running as Non-Root and Hardening the Container#
Running as root inside a container makes any code-execution vulnerability far more dangerous, because the attacker holds root in the container's user namespace. Since .NET 8, every Microsoft image defines the app user with UID 1654. Chiseled and Azure Linux distroless images already switch to it. For full Ubuntu or Alpine images, add USER $APP_UID in the final stage. Use the numeric UID rather than the name, because Kubernetes can verify runAsNonRoot only against a numeric user.
On many platforms an unprivileged process cannot bind to ports below 1024, which is why the images listen on 8080. Map a public port to 8080 at the load balancer, ingress or docker run -p rather than moving the app to port 80.
Further hardening is cheap. Run with a read-only root filesystem (docker run --read-only, or readOnlyRootFilesystem in Kubernetes) plus a small tmpfs for temporary files, drop Linux capabilities you do not need, and never mount the Docker socket into an application container.
ASP.NET Core Data Protection needs attention in containers. It encrypts authentication cookies and antiforgery tokens with a key ring that, by default, lives inside the container's filesystem. When the container is replaced, or when you run several replicas, users are logged out or requests fail validation. Persist the keys to a volume or an external store, and protect them at rest:
using Azure.Identity;
using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential();
builder.Services.AddDataProtection()
.SetApplicationName("shop")
.PersistKeysToAzureBlobStorage(
new Uri(builder.Configuration["DataProtection:BlobUri"]!), credential)
.ProtectKeysWithAzureKeyVault(
new Uri(builder.Configuration["DataProtection:KeyUri"]!), credential);Image Size and Layer Caching#
Most wasted image size and build time comes from layer ordering and oversized build contexts:
- Order instructions from least to most frequently changing. Base image, then project files and restore, then source and publish. Include
Directory.Packages.props,Directory.Build.propsandNuGet.Configin the restore layer, because they affect restore. - Keep the final stage minimal. Copy only
/appfrom the build stage. Never copy the whole repository into the runtime image. - Prefer framework-dependent images when many services share a node. The
aspnetbase layers are shared across every service that uses the same tag, whereas self-contained apps each ship their own runtime copy. - Use cache mounts carefully.
RUN --mount=type=cache,target=/root/.nuget/packagesspeeds up restore in CI. Because BuildKit may evict cache contents between builds, drop--no-restorefrom the publish step when you use it.
Private NuGet feeds need credentials during restore, but a credential copied into a layer or passed as a build argument stays in the image history. BuildKit secret mounts avoid that:
RUN --mount=type=secret,id=nugetconfig \
dotnet restore src/Shop.Api/Shop.Api.csproj -a $TARGETARCH \
--configfile /run/secrets/nugetconfigdocker build --secret id=nugetconfig,src=./nuget.ci.config -t shop-api:1.4.0 .Globalization Invariant Mode and Time Zones#
Alpine, chiseled and Azure Linux distroless images omit ICU and set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true. In invariant mode, culture-specific formatting and sorting behave like the invariant culture, and only the invariant culture can be created. Many APIs never notice, but some libraries do. The classic failure is Microsoft.Data.SqlClient, and therefore EF Core on SQL Server, throwing a CultureNotFoundException on connect. That is by design, because SqlClient requires ICU.
Make the choice explicit in the project with InvariantGlobalization set to true or false, and pick the base image to match. If you need real cultures, use an -extra image, which the SDK selects automatically for some RIDs when invariant mode is off. Time zones behave similarly. Without tzdata, TimeZoneInfo.FindSystemTimeZoneById throws, and DateTime.Now equals UTC. Store and compute in UTC where possible, use -extra images when you need named zones, and pass TZ as an environment variable at run time rather than baking a zone into the image.
Health Checks in Containers#
Expose health endpoints from the app with ASP.NET Core health checks, and let the platform decide how to probe them. Kubernetes ignores the Dockerfile HEALTHCHECK instruction and uses its own liveness, readiness and startup probes. Docker Engine, Docker Compose and Swarm do honor HEALTHCHECK. Distroless images have no shell or curl, so a common trick is to let the app binary probe itself:
if (args is ["--health-check"])
{
// Invoked by the Dockerfile HEALTHCHECK; works without a shell or curl
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
try
{
using var response = await client.GetAsync("http://localhost:8080/healthz");
return response.IsSuccessStatusCode ? 0 : 1;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return 1;
}
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/healthz");
await app.RunAsync();
return 0;HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["./Shop.Api", "--health-check"]Each check starts a new .NET process, which costs some CPU and memory at every interval. Keep the interval reasonable, and consider Native AOT if the check runs very often. For probe design on Kubernetes, see Running .NET on Kubernetes.
Configuration with Environment Variables#
.NET configuration reads environment variables out of the box. A double underscore stands in for the : section separator, so ConnectionStrings__Default populates ConnectionStrings:Default. Variables prefixed ASPNETCORE_ and DOTNET_ configure the host, for example ASPNETCORE_ENVIRONMENT, ASPNETCORE_HTTP_PORTS or DOTNET_gcServer. Build one image and promote it unchanged through every environment, injecting environment-specific values at run time.
Do not put secrets in ENV or ARG instructions. Both are visible to anyone who can pull the image, through docker history or docker inspect. Inject secrets at run time from your orchestrator's secret store, or better, use managed identity and a vault so the container holds no secret at all. The secrets management guide covers Key Vault and managed identity.
Graceful Shutdown in Docker#
When Docker stops a container, it sends SIGTERM to PID 1 and, after a grace period, SIGKILL. The .NET host handles SIGTERM: it stops accepting requests, lets in-flight work finish and runs IHostedService.StopAsync. Two details break this in practice. First, a shell-form ENTRYPOINT runs your app under /bin/sh -c, which does not forward signals, so the app never sees SIGTERM. Always use the exec form. Second, the timeouts disagree. docker stop waits 10 seconds on Linux by default, while the .NET host allows up to 30 seconds through HostOptions.ShutdownTimeout. Align them with docker run --stop-timeout, Compose stop_grace_period or the Kubernetes terminationGracePeriodSeconds.
Local Development with Docker Compose#
Compose runs the app next to its dependencies with one command. The canonical file name is now compose.yaml. Use health checks and depends_on conditions so the API starts only when the database accepts connections:
services:
api:
build: .
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Default: Host=db;Database=shop;Username=shop;Password=${DB_PASSWORD}
ConnectionStrings__Redis: cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
stop_grace_period: 30s
db:
image: postgres:17
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
interval: 5s
timeout: 3s
retries: 10
cache:
image: redis:7
volumes:
pgdata:Put DB_PASSWORD in a .env file that is excluded from source control. For larger .NET systems, .NET Aspire offers a code-first alternative that runs projects as debuggable processes, wires connection strings automatically and can still generate a Compose file for deployment.
Security Scanning and Supply Chain Integrity#
A base image that was clean last month can contain known CVEs today. Treat image security as a continuous process:
- Rebuild regularly, not only when code changes. Microsoft patches the .NET images for runtime and OS fixes, but your image picks up those fixes only when you rebuild it. Pin major and minor tags such as
10.0-noble-chiseled, and let CI rebuild on a schedule. Pin digests only where you also automate digest updates. - Scan in CI and fail the build on serious findings. Docker Scout and Trivy both understand chiseled and Azure Linux images, which ship package metadata for scanners.
- Generate SBOM and provenance attestations so that consumers can see what an image contains and how it was built.
- Verify base images. Microsoft signs .NET container images with Notary Project signatures, which you can verify with the Notation CLI or enforce with an admission policy.
docker scout cves shop-api:1.4.0
trivy image --severity HIGH,CRITICAL --exit-code 1 shop-api:1.4.0
# Scan an SDK-published tarball before it is pushed
trivy image --input ./artifacts/shop-api.tar.gz
# Attach an SBOM and max-level provenance while building
docker buildx build --sbom=true --provenance=mode=max \
-t myregistry.azurecr.io/shop-api:1.4.0 --push .Best Practices#
- Use multi-stage builds or SDK publishing. The SDK image should never reach production.
- Default to chiseled images. Move to
-extra, Alpine or full images only for a concrete reason. - Run as a numeric non-root user on port 8080, adding
USER $APP_UIDwherever the base image does not set it. - Structure layers for caching. Restore before copying source, keep
.dockerignorestrict and useCOPY --link. - Build once, configure at run time. Promote the same image digest from test to production.
- Handle
SIGTERMproperly with exec-form entry points and aligned shutdown timeouts. - Scan, sign and rebuild on a schedule. Freshness is a security control.
Common Pitfalls#
- Assuming port 80. Since .NET 8, images listen on 8080, so old
EXPOSE 80lines and probes that target port 80 fail. - Running as root by accident. Full Ubuntu and Alpine images do not switch users for you.
- Shell scripts in distroless stages.
RUNorENTRYPOINTcommands that need/bin/shfail with "no such file or directory" on chiseled images. CultureNotFoundExceptionafter moving to a small image. Invariant mode is on by default. Use an-extraimage or fix the culture dependency.- Secrets in layers. A file deleted in a later layer still exists in the earlier one. Use secret mounts.
- Stale images. An image deployed unchanged for a year accumulates unpatched vulnerabilities.
- Lost logins after redeploys. An unpersisted Data Protection key ring invalidates every cookie when the container is replaced.
Dockerfile vs SDK Container Publishing: When to Use Each#
| Concern | Dockerfile | dotnet publish -t:PublishContainer |
|---|---|---|
| Needs Docker or BuildKit | Yes | No; it can push straight to a registry |
| Custom OS packages or setup commands | Yes, with RUN | Only through a custom base image |
| Multi-architecture images | docker buildx --platform | ContainerRuntimeIdentifiers |
| Non-root by default | Depends on the base image | Yes |
| Build secrets and cache mounts | Yes | Not applicable; it restores like a normal build |
| Learning curve | Dockerfile syntax and BuildKit | MSBuild properties only |
| Best fit | Complex images, polyglot repositories, shared build pipelines | Standard .NET services and fast CI pipelines |
Frequently Asked Questions#
Should I use a Dockerfile or dotnet publish to build .NET container images?#
Use SDK container publishing for standard ASP.NET Core APIs and workers. It needs no Docker daemon, defaults to secure settings and keeps configuration in the project file. Use a Dockerfile when you must install OS packages, run setup commands or share one build pipeline across several languages.
Which base image should I use for ASP.NET Core in production?#
Start with mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled. It is distroless, runs as non-root and was more than 40 percent smaller than the full Ubuntu image in Microsoft's size report. Switch to the -extra variant if your app needs cultures or named time zones.
Why does my app throw CultureNotFoundException in an Alpine or chiseled image?#
These images run in globalization invariant mode because they omit ICU. Libraries that request specific cultures, most notably Microsoft.Data.SqlClient, fail in that mode. Use an -extra image, which includes ICU and tzdata, and set InvariantGlobalization to false.
How do I run a .NET container as a non-root user?#
Microsoft images since .NET 8 include an app user with UID 1654. Chiseled and distroless images use it automatically, SDK container publishing selects it by default, and for other images you add USER $APP_UID to the final stage. Keep the app on port 8080, because non-root processes cannot bind to ports below 1024.
Does Docker HEALTHCHECK work in Kubernetes?#
No. Kubernetes ignores the HEALTHCHECK instruction and relies on the liveness, readiness and startup probes defined in the pod spec. HEALTHCHECK is still useful for plain Docker hosts and Docker Compose, where it drives depends_on conditions and restart decisions.
Summary#
- Build in the SDK image and run on the smallest runtime image that works, usually
aspnet:10.0-noble-chiseled. - .NET 10 moved default tags to Ubuntu 24.04 and lets console apps use SDK container publishing without extra properties.
- Non-root execution, port 8080 and invariant globalization are distroless defaults; plan for them.
- Order layers for caching, keep secrets out of layers, handle
SIGTERMand persist Data Protection keys. - Scan in CI, attach SBOMs and rebuild regularly to pick up patched base layers.