Container Images

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

The .NET SDK builds container images itself: dotnet publish -t:PublishContainer publishes the application, layers it on a Microsoft base image, writes the image configuration and pushes the result — to the local Docker daemon, a registry or a tar file — without a Dockerfile and without Docker being needed to build. Since .NET 10 this works for console applications too, not only web projects. This page builds images of Ledgerline's report sample with .NET SDK 10.0.402 and Docker 29.9. Running ASP.NET Core in containers is covered in ASP.NET Core Deployment — Containers.

The first image

dotnet publish samples/Ledgerline.Report -t:PublishContainer
docker run --rm ledgerline-report:latest
  Ledgerline.Report -> ~/ledgerline/samples/Ledgerline.Report/bin/Release/net10.0/Ledgerline.Report.dll
  Ledgerline.Report -> ~/ledgerline/samples/Ledgerline.Report/bin/Release/net10.0/publish/
  Building image 'ledgerline-report' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:10.0'.
  Pushed image 'ledgerline-report:latest' to local registry via 'docker'.
[
  {
    "Account": "1000-Cash",
    "Debit": 500.00,
…

Everything was derived from the project: the repository name from the assembly name (lower-cased, . replaced by -), the tag latest, and the base image from the target framework and the project type — runtime for a console app, aspnet for a web app, runtime-deps for self-contained and Native AOT apps. The configuration it wrote:

docker inspect ledgerline-report:latest --format '{{json .Config.User}} {{json .Config.WorkingDir}} {{json .Config.Entrypoint}}'
"1654" "/app/" ["dotnet","/app/Ledgerline.Report.dll"]

The image runs as the non-root user app (UID 1654, the APP_UID of the Microsoft images) by default. Its labels come from the package metadata in Directory.Build.props — org.opencontainers.image.authors, licenses, url, version — plus the base image name and digest and org.opencontainers.image.created, the publish time.

The container properties

Property Purpose

ContainerBaseImage

The full base image reference, overriding the inferred one.

ContainerFamily

A suffix for the inferred tag: alpine, noble-chiseled, azurelinux3.0-distroless, …

ContainerImageFormat

Docker or OCI manifests (.NET 10 and later).

ContainerImageTag / ContainerImageTags

One tag, or several separated by ;.

ContainerRepository

The image name (ledgerline/report).

ContainerRegistry

Push to this registry instead of the local daemon.

LocalRegistry

The local tool to push to: docker or podman (detected by default).

ContainerUser

The user to run as (root to opt out of the non-root default).

ContainerArchiveOutputPath

Write a tar file instead of pushing.

Others set ports, environment variables, labels, the entry point and multi-architecture builds (ContainerRuntimeIdentifiers).

Publishing to a registry

A throw-away registry:2 on localhost:5000 stands in for a real registry:

docker run -d --name reg -p 5000:5000 registry:2
dotnet publish samples/Ledgerline.Report -t:PublishContainer \
  -p:ContainerRepository=ledgerline/report \
  -p:ContainerImageTags='"1.0.0;latest"' \
  -p:ContainerFamily=noble-chiseled \
  -p:ContainerRegistry=localhost:5000
curl -s localhost:5000/v2/ledgerline/report/tags/list
  Building image 'ledgerline/report' with tags '1.0.0,latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled-extra'.
  Pushed image 'ledgerline/report:1.0.0, latest' to registry 'localhost:5000'.
{"name":"ledgerline/report","tags":["latest","1.0.0"]}

Three things to notice:

  • The inner quotes in ContainerImageTags. A ; on the command line separates MSBuild properties, so a list value must be quoted twice ('"1.0.0;latest"' in a POSIX shell).

  • -extra was added to the family. Chiseled images have no ICU; because the project does not set InvariantGlobalization, the SDK chose the noble-chiseled-extra variant so that culture-aware formatting keeps working. The chiseled image was 197 MB uncompressed against 300 MB for the default runtime:10.0 image, which since .NET 10 is Ubuntu 24.04 "noble" (Debian images are no longer shipped).

  • No credentials were needed for localhost. For a real registry the SDK reads the credentials that docker login stored, so a CI job logs in to the registry before publishing.

Publishing to a tarball

Without a registry, or to scan an image before pushing it, write an archive:

dotnet publish samples/Ledgerline.Report -t:PublishContainer \
  -p:ContainerArchiveOutputPath=pub/report.tar.gz -p:ContainerImageFormat=OCI
  Building image 'ledgerline-report' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:10.0'.
  Pushed image 'ledgerline-report:latest' to local archive at '~/ledgerline/pub/report.tar.gz'.

Despite the .tar.gz name, file reported a plain POSIX tar archive (83 MB) in OCI layout (blobs/sha256/…): the SDK does not compress the archive. docker load -i pub/report.tar.gz imports it.

Reproducible images

Publishing the same commit twice in a row to the registry produced two different manifests:

Docker-Content-Digest: sha256:9a2ea36a5e696271666796bce1779769b9b2fb8cd0bc4d102c68391a9857a4a4
Docker-Content-Digest: sha256:a21db6498689bf4f69fd7786253eb2dd8ccb9943b952139d01365e0bed624f79

The layers were the same; the image configuration was not, because org.opencontainers.image.created and the created fields hold the publish time. A digest that changes on every build defeats digest pinning and provenance checks. The .NET 11 SDK previews use SOURCE_DATE_EPOCH for those timestamps, so independent publishes of the same inputs produce the same digest (Reproducible and Deterministic Builds).

Dockerfile or SDK?

The SDK route needs no Docker to build, applies the non-root default and the labels, and keeps the image definition in the project. A Dockerfile is still the choice when the image needs operating-system packages or extra files that are not part of the publish output, or a multi-stage build that runs tests inside the image. Ledgerline's CI example on CI Builds and Caching uses a Dockerfile for that reason.

Common mistakes

  • An unquoted ; in ContainerImageTags — MSBuild splits it into separate properties.

  • Choosing noble-chiseled and expecting no ICU — the SDK adds -extra unless InvariantGlobalization is set.

  • Running as root out of habit — the default user is app; write to /tmp or a mounted volume.

  • Expecting identical digests from identical sources on .NET 10 — the creation time differs.

  • Pinning the base image by tag only — use ContainerBaseImage with a digest for repeatable builds.