
Most .NET Dockerfiles repeat the same five steps: restore, publish, select the matching Microsoft runtime image, copy the output and start the application. That works, but it rarely expresses application-specific intent.
The .NET SDK already understands those steps. It can build an OCI image, load it into Docker or Podman, write it to an archive or push it directly to a registry. An archive or registry push does not need a local container daemon.
A practical rule in 2026 is straightforward:
- A conventional ASP.NET Core API or worker uses SDK container publishing.
- Shared operating-system requirements belong in a centrally maintained base image.
- An application keeps a Dockerfile only when it genuinely needs shell steps, native compilation or a multi-tool build.
The choice is not about disliking Dockerfiles. It is about removing the same 15 to 20 lines from every service when the SDK can produce the same standard image more consistently.
Release status: SDK container publishing has been part of the .NET SDK since 7.0.200. Archive output arrived with SDK 8.0.200 and multi-architecture publishing with SDK 8.0.405 and 9.0.102. The .NET 11 local-runtime details in this article reflect Preview 7 from August 2026 and require final-release verification.
The smallest useful build
An ASP.NET Core project using Microsoft.NET.Sdk.Web already has container support:
dotnet publish --configuration Release --os linux --arch x64 /t:PublishContainerThe SDK selects a compatible Microsoft base image, publishes the application, creates the image configuration and OCI metadata and adds one application layer on top of the reused base layers.
It does not create a temporary Dockerfile and does not execute arbitrary shell commands. That is both the advantage and the boundary of the feature.
/t:PublishContainer is preferable to the older -p:PublishProfile=DefaultContainer form because the target states the build operation directly.
Keep the image contract in MSBuild
Repository-wide image behavior should not hide in a long CI command. Stable settings belong in the project or a shared props file:
1<Project Sdk="Microsoft.NET.Sdk.Web">
2 <PropertyGroup>
3 <TargetFramework>net11.0</TargetFramework>
4 <Nullable>enable</Nullable>
5 <ImplicitUsings>enable</ImplicitUsings>
6
7 <ContainerRepository>orders-api</ContainerRepository>
8 <ContainerImageFormat>OCI</ContainerImageFormat>
9 <ContainerWorkingDirectory>/app</ContainerWorkingDirectory>
10 <ContainerImageTag Condition="'$(ContainerImageTags)' == ''">dev</ContainerImageTag>
11 </PropertyGroup>
12
13 <ItemGroup>
14 <ContainerPort Include="8080" Type="tcp" />
15 <ContainerLabel Include="com.example.service" Value="orders-api" />
16 <ContainerLabel Include="com.example.owner" Value="commerce-platform" />
17 </ItemGroup>
18</Project>CI supplies values that change per build: version, registry, source revision and output path. The explicit dev tag avoids pretending that the default latest tag is a release identity. The condition matters: the SDK rejects a build that sets both ContainerImageTag and ContainerImageTags with error CONTAINER2008, so the local default must step aside when CI supplies a tag list.
ContainerPort documents image metadata. It does not publish a host port or create a network policy. Current ASP.NET Core images already use port 8080, so the item is optional unless a shared image contract benefits from stating it explicitly.
Let the SDK infer the normal base image
Without ContainerBaseImage, the SDK maps project shape to the Microsoft image catalog:
| Project output | Inferred image |
|---|---|
| Framework-dependent ASP.NET Core | mcr.microsoft.com/dotnet/aspnet |
| Framework-dependent worker or console | mcr.microsoft.com/dotnet/runtime |
| Self-contained | mcr.microsoft.com/dotnet/runtime-deps |
| Native AOT | Compatible runtime-deps variant |
The target framework, runtime identifier, globalization settings and AOT settings select the compatible tag and variant. This avoids a common Dockerfile defect: building with one .NET version and running on another.
An image family can be selected without replacing the complete reference:
1<PropertyGroup>
2 <ContainerFamily>alpine</ContainerFamily>
3</PropertyGroup>Alpine is not a free size optimization. It uses musl instead of glibc and requires compatible RIDs such as linux-musl-x64. Use --runtime linux-musl-x64 instead of --os linux --arch x64 for that publish; setting ContainerFamily alone does not change the application RID. With a musl RID, the SDK selects the Alpine variant automatically. Native libraries, globalization, DNS behavior and diagnostics need tests. Chiseled images intentionally omit a shell and package manager.
The image family should follow compatibility and security requirements, not a screenshot comparing compressed megabytes.
Put shared OS changes in a shared base
Some organizations need trusted certificate authorities, approved native packages or security tooling in every service. That does not require a separate Dockerfile in every application.
A platform-owned base image keeps those mutations in one reviewed place:
1<PropertyGroup>
2 <ContainerBaseImage>registry.example.com/platform/dotnet-aspnet:11.0-2026.09</ContainerBaseImage>
3</PropertyGroup>Application repositories still use /t:PublishContainer and add only their application layer. The platform repository owns package installation, hardening, updates and the base-image digest.
The custom image must provide the runtime and platforms required by the application’s publish mode. The SDK validates the image manifest against the requested RID.
Non-root is the baseline
With current Microsoft Linux runtime images, SDK publishing configures the built-in app user through its numeric APP_UID. That removes the easy-to-miss USER instruction from every application repository.
Non-root alone is not a complete container security model. A complete baseline also tests a read-only root filesystem, allows writes only on declared mounted paths, avoids privileged ports and capabilities and uses workload identity for external dependencies.
1docker run --rm \
2 --publish 8080:8080 \
3 --read-only \
4 --tmpfs /tmp \
5 orders-api:1.4.0This test catches applications that write logs, caches or generated files into their installation directory. Changing ContainerUser to root so that such writes succeed on a writable filesystem would hide the bug instead of fixing it.
Local runtime, archive or registry
The same publish target supports three useful delivery paths.
Local runtime
Without another destination, the SDK loads the image into a supported local runtime:
1dotnet publish \
2 --configuration Release \
3 --os linux \
4 --arch x64 \
5 /t:PublishContainer \
6 -p:ContainerImageTag=1.4.0.NET 11 can select wslc on Windows, Apple’s container CLI on macOS, Docker or Podman. Automatic detection is useful for development but can make CI depend on installed tools. LocalRegistry pins the intended runtime when local loading is required:
1<PropertyGroup>
2 <LocalRegistry>Podman</LocalRegistry>
3</PropertyGroup>CI should normally avoid local-runtime integration and produce an archive or push to a registry.
OCI archive
1dotnet publish \
2 --configuration Release \
3 --os linux \
4 --arch x64 \
5 /t:PublishContainer \
6 -p:ContainerImageTag=1.4.0 \
7 -p:ContainerArchiveOutputPath=./artifacts/orders-api.tar.gzThis path needs the SDK and access to the base-image registry, but no Docker daemon. It is preferable when build, scan, policy validation, signing and publication are separate stages.
The archive contains the complete image filesystem. It needs the same access controls, retention policy, checksum and provenance as any other release artifact.
Remote registry
1dotnet publish \
2 --configuration Release \
3 --os linux \
4 --arch x64 \
5 /t:PublishContainer \
6 -p:ContainerRegistry=ghcr.io \
7 -p:ContainerRepository=example/orders-api \
8 -p:ContainerImageTag=1.4.0The SDK uses the Docker Registry HTTP API V2 and Docker credential conventions. Workload federation or short-lived tokens through a credential helper are preferable. Passwords on the command line or in persistent Docker configuration are unnecessary exposure.
Direct push is convenient, but an archive-first or quarantine-registry flow prevents an unscanned image from appearing under a trusted production repository name.
A tag is not an immutable release
Tags are mutable pointers. A robust publication creates a human-readable SemVer tag and a source-revision tag, then deploys the resolved digest.
Several tags use ContainerImageTags. Because its value is semicolon-delimited, an environment variable avoids the shell-specific escaping that -p:ContainerImageTags=... requires:
1$env:ContainerImageTags = '1.4.0;sha-a1b2c3d'
2
3dotnet publish `
4 --configuration Release `
5 --os linux `
6 --arch x64 `
7 /t:PublishContainer `
8 -p:ContainerRegistry=registry.example.com `
9 -p:ContainerRepository=commerce/orders-api1release reference: orders-api:1.4.0
2deployment input: orders-api@sha256:4fd8...Reusing latest, a branch tag or even an existing SemVer tag destroys artifact identity. A rollback should select a known digest, not whatever a mutable name points to today.
Multi-architecture publishing is one SDK build
The project declares every RID and the subset included in the image index:
1<PropertyGroup>
2 <RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
3 <ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers>
4</PropertyGroup>Publishing without one explicit RID builds both and creates an OCI index. Multi-architecture output is always OCI, independent of ContainerImageFormat:
1dotnet publish \
2 --configuration Release \
3 /t:PublishContainer \
4 -p:ContainerRegistry=registry.example.com \
5 -p:ContainerRepository=commerce/orders-api \
6 -p:ContainerImageTag=1.4.01orders-api:1.4.0
2 -> OCI index
3 -> linux/amd64 manifest
4 -> linux/arm64 manifestThe registry is the most interoperable destination because local runtimes differ in multi-platform support. .NET 11 creates a proper image index when loading into Podman, while wslc does not support multi-architecture local loads and fails with an explicit error.
Creating both manifests proves that both builds completed. It does not prove that the application works on Arm64. Startup and integration checks must run on every advertised architecture.
Choose publish mode deliberately
Framework-dependent publishing is the normal ASP.NET Core choice. Runtime layers remain in the base image and can be shared across services on a node.
Self-contained output carries its runtime and uses runtime-deps. That decouples runtime availability in the base but increases the application layer and requires rebuilding each service for runtime security updates.
1<PropertyGroup>
2 <SelfContained>true</SelfContained>
3 <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
4</PropertyGroup>Native AOT can improve startup and memory for compatible workloads:
1<PropertyGroup>
2 <PublishAot>true</PublishAot>
3 <InvariantGlobalization>true</InvariantGlobalization>
4</PropertyGroup>It also adds trimming, reflection, native dependency and build-time constraints. Native AOT does not cross-compile between operating systems: a Linux image needs a Linux build host or build container with the native toolchain, and an Arm64 image from an x64 host additionally needs a cross-compilation sysroot. InvariantGlobalization changes culture-sensitive behavior; it is not merely a size switch.
The smallest image is not automatically the cheapest system. Build duration, patching, memory, diagnostics, startup and compatibility all count.
Runtime configuration stays outside the image
ContainerEnvironmentVariable writes values into inspectable image metadata and should contain only non-secret defaults:
1<ItemGroup>
2 <ContainerEnvironmentVariable Include="DOTNET_EnableDiagnostics" Value="0" />
3</ItemGroup>Passwords, tokens, connection strings, private keys and tenant configuration arrive at runtime through workload identity, a secret store, protected environment variables or mounted secrets.
The same digest moves through development, staging and production. Configuration changes; application bytes do not.
The SDK creates an image, not trust
The SDK adds standard OCI labels and can include source and base-image metadata. Standard annotations such as the vendor have dedicated properties; additional bounded labels are useful for ownership and policy:
1<PropertyGroup>
2 <ContainerVendor>Example Corp</ContainerVendor>
3</PropertyGroup>
4
5<ItemGroup>
6 <ContainerLabel Include="com.example.data-classification" Value="internal" />
7</ItemGroup>Labels do not prove provenance. A release pipeline should still restore in locked mode, record the SDK and resolved base digest, scan the final image, attach an SBOM, sign the final digest, publish build provenance and deploy by digest.
Scanning only NuGet packages misses vulnerable operating-system packages and image configuration. Signing a mutable tag signs the wrong abstraction. Controls apply to the final image digest.
Where the SDK stops
The SDK image assembler has no equivalent to Dockerfile RUN. It does not execute commands inside the base image to install OS packages, compile native libraries, update certificate stores or use BuildKit mounts. Normal MSBuild targets can still run build steps and include generated or downloaded files in the publish output; those steps run on the build host.
Three reasonable responses exist:
- Remove the mutation when the application does not need it.
- Put a common mutation into a platform-owned base image.
- Keep a Dockerfile for an application-specific build.
A Dockerfile remains the right choice for a frontend built with Node and packaged with .NET, native compilation, complex file ownership, several non-.NET artifacts or valuable BuildKit mounts.
That is a technical boundary, not a style preference. A Dockerfile expressing a real multi-stage build is useful. One that only reimplements dotnet publish is duplicated maintenance.
CI without a daemon
1restore --locked-mode
2 -> test
3 -> PublishContainer to OCI archive
4 -> inspect user, command, port, labels and base digest
5 -> vulnerability and secret scan
6 -> runtime smoke test
7 -> push immutable image
8 -> sign digest and publish provenanceThe SDK is pinned through global.json; NuGet uses a lock file; the base-image policy is explicit. Image construction and publication require no container daemon. The runtime smoke-test stage still needs a compatible container runtime, which can run in a separate validation job.
Test the image contract
Application tests are not enough. Deployment validation should verify:
- the process runs as the expected non-root user;
- the application binds to the declared port;
- liveness and readiness have the intended behavior;
- the root filesystem remains read-only;
- writable paths are declared mounts;
SIGTERMproduces bounded graceful shutdown;- required globalization and native libraries exist;
- every architecture in the OCI index runs;
- layers and metadata contain no secrets; and
- the deployed digest matches the tested digest.
These checks catch the container-specific failures that dotnet test and a successful publish cannot see.
Recommended baseline
For a conventional ASP.NET Core service, the baseline is small:
1<PropertyGroup>
2 <TargetFramework>net11.0</TargetFramework>
3 <ContainerRepository>orders-api</ContainerRepository>
4 <ContainerImageFormat>OCI</ContainerImageFormat>
5</PropertyGroup>CI supplies the immutable version and archive output, then scans, runs, publishes, signs and deploys the digest.
SDK container publishing removes repeated build instructions. It does not remove container engineering. Base-image compatibility, non-root execution, read-only filesystems, secrets, multi-architecture tests, scanning, signing and provenance still belong to the delivery process.
The result is the intended outcome of good tooling: less repository boilerplate without hiding the artifact or weakening its production contract.
Choosing that production contract starts with the base image; Distroless, Chiseled or Alpine: Choosing a .NET Base Image compares those options in detail.
Further details are available in the .NET SDK container publishing tutorial , the container publishing configuration reference and the .NET 11 Preview 7 SDK release notes .
Related articles

Oct 05, 2026 - 12 min read
Problem Details in ASP.NET Core and .NET 11
builder.Services.AddProblemDetails() registers infrastructure. It does not create an error contract. An ASP.NET Core request can fail during …

Sep 21, 2026 - 11 min read
Resilience in .NET with Microsoft.Extensions.Resilience
Retries are the most visible resilience strategy and the easiest one to misuse. Repeating a failing call can recover from a short network …

Sep 14, 2026 - 10 min read
What Matters in ASP.NET Core 11
ASP.NET Core 11 has no single feature that fundamentally changes how web applications are built. That is not a criticism. Mature frameworks …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
