Mert Özen Further With Every Line
DevOps Docker — #09

.NET Containers Without a Dockerfile: dotnet publish /t:PublishContainer and Container MSBuild Properties

Mert Özen Nov 14, 2026 11 min 18 views
.NET Containers Without a Dockerfile: dotnet publish /t:PublishContainer and Container MSBuild Properties

With dotnet publish /t:PublishContainer the .NET SDK produces an image without a Dockerfile or a Docker build: it puts the publish output on the base image as one layer, sets the app user and port 8080 itself, and sends the result to the local daemon, a tar file or straight to a registry. We produce Bookstore.Api this way, try ContainerFamily, a multi-architecture index and ContainerImageFormat, and compare it with the Dockerfile path by size, layers and time.

Version note: The examples in this post were run with .NET SDK 10.0.404 (the SDK's container support, Microsoft.NET.Build.Containers), the ASP.NET Core Runtime 10.0.15 images, Docker Engine 29.9.3 (containerd image store) and registry:3.

Guess First

The project has no Dockerfile. You run dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer. Three questions: which base image is the image built on? Does the container run as root? If Docker Desktop is not running, can the command still produce an image?

How the SDK Produces an Image

The .NET SDK has been able to produce container images on its own since .NET 7. Its logic differs from a Dockerfile: the SDK runs no command inside a container. It publishes the app on the machine as usual, packs the publish folder as a single layer, reads the base image's manifest and config from the registry, writes its own config on top (entrypoint, user, port, environment variables, labels) and sends the result to a destination. The destination can be the local Docker daemon, a registry or a tar file. That is why it needs neither the build cache from post 6, nor the stages from post 7, nor BuildKit.

We first tried Bookstore.Api, the API of Bookstore, the online bookstore used as the shared example across this site's series, without any settings:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer
  Bookstore.Api -> /work/src/Bookstore.Api/bin/Release/net10.0/publish/
  Building image 'bookstore-api' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0'.
  Pushed image 'bookstore-api:latest' to local registry via 'docker'.

The answer to the first question: since it is an ASP.NET Core project, aspnet, with the tag taken from TargetFramework (net10.0 → 10.0). A console app gets runtime, a self-contained app runtime-deps. The image name is the lower-cased assembly name and the tag is latest. "local registry via 'docker'" says the image was handed to the local daemon with docker load. The image config answers the second question too:

docker image inspect bookstore-api:latest \
    --format 'User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} WorkingDir={{.Config.WorkingDir}} Ports={{json .Config.ExposedPorts}}'
User=1654 Entrypoint=["dotnet","/app/Bookstore.Api.dll"] WorkingDir=/app/ Ports={"8080/tcp":{}}

Not root. The base image is the Ubuntu-based aspnet:10.0, and as we saw in post 8 its User field is empty, but for projects targeting .NET 8 and later the SDK sets ContainerUser to the app user (uid 1654) by default. It also infers the port: it reads ASPNETCORE_HTTP_PORTS=8080 from the base image and exposes 8080/tcp. The USER $APP_UID and EXPOSE 8080 we wrote by hand in the Dockerfile in post 8 come for free here.

The defaults have one gap: the base image is the full Ubuntu image (342 MB). It has ICU and tzdata, so Bookstore.Api's Turkish sorting works, but a shell and a package manager come along too. We have to make the choice from post 8 here as well.

Container Properties

The SDK's settings are MSBuild properties. We added a group to Bookstore.Api's csproj:

<PropertyGroup Label="Container">
  <ContainerRepository>dockerseries-bookstore-api</ContainerRepository>
  <ContainerImageTags>1.0.0-sdk</ContainerImageTags>
  <ContainerFamily>noble-chiseled-extra</ContainerFamily>
</PropertyGroup>
dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer
docker run -d --name dockerseries-api -p 8080:8080 dockerseries-bookstore-api:1.0.0-sdk
curl -s localhost:8080/books | jq -r '.[0:2][] | .shelfLabel'
curl -s localhost:8080/store/clock
  Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
  Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to local registry via 'docker'.
ÇALIKUŞU
DUNE
{"zone":"Europe/Istanbul","localTime":"2026-11-14 14:37 +03:00"}

ContainerFamily is appended to the chosen tag: 10.0 + noble-chiseled-extra = 10.0-noble-chiseled-extra. If you want to pick the base image entirely yourself, write the full name (for example with a digest) into ContainerBaseImage; ContainerFamily is then ignored. Other common ones:

  • ContainerRepository and ContainerImageTags: the image name and tags; several tags are separated by semicolons. In a pipeline they usually come from the command line: -p ContainerImageTags=1.0.0.
  • ContainerUser: app by default; if you need root, write root explicitly.
  • ContainerPort and ContainerEnvironmentVariable: written as items (<ContainerPort Include="8080" Type="tcp" />); the port is usually inferred from the base image as above.
  • ContainerLabel: extra labels. The SDK already writes labels such as org.opencontainers.image.base.name and org.opencontainers.image.base.digest itself, so the base image digest each image came from is recorded.
  • ContainerRuntimeIdentifiers and ContainerImageFormat: shortly.

Three Destinations: Daemon, Tarball and Registry

The local daemon is the default destination, and publish fails if Docker Desktop is not running. The answer to the third question is still yes: the other two destinations do not need Docker at all. ContainerArchiveOutputPath writes the image to a .tar.gz file:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerArchiveOutputPath="$PWD/out/images/"
docker load -i out/images/dockerseries-bookstore-api.tar.gz
  Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to local archive at '/work/out/images//dockerseries-bookstore-api.tar.gz'.
Loaded image: dockerseries-bookstore-api:1.0.0-sdk

We gave an absolute path because a relative one is resolved against the project's folder, not the folder you run the command in: on the first try with out/images/ the file was written to src/Bookstore.Api/out/images/. The file is 70 MB: the whole image, including the base image's layers. A tarball is used to run the image through a scanner before pushing it to a registry, or to produce it in a build environment without Docker and load it somewhere else.

With ContainerRegistry the SDK sends the image straight to a registry, and the Docker daemon is out of the picture. We started a local registry (dockerseries-registry) as in post 4 and tried it:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerRegistry=localhost:5000
  Pushed image 'dockerseries-bookstore-api:1.0.0-sdk' to registry 'localhost:5000'.

The SDK connected to the registry on localhost over HTTP without asking for extra settings. For a real registry (GHCR, ACR, Docker Hub) it reads credentials from where docker login writes them; there is no separate login mechanism. That is why you can produce an image and push it to a registry even on a CI runner without Docker installed. Image build and push in a pipeline are covered in posts 11 and 15 of the GitHub Actions series.

Multi-Architecture Images and ContainerImageFormat

The SDK produces the two-architecture image we built with a Dockerfile in post 7 in a single command. When ContainerRuntimeIdentifiers gets several RIDs (for example linux-x64), the SDK publishes the app separately for each RID, produces an image for each, and combines them into a single index. This property must be a subset of RuntimeIdentifiers; we passed both. To escape the semicolon in Bash, the value is written in single quotes that contain double quotes:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer \
    -p ContainerRegistry=localhost:5000 \
    -p RuntimeIdentifiers='"linux-x64;linux-arm64"' \
    -p ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"'
docker buildx imagetools inspect localhost:5000/dockerseries-bookstore-api:1.0.0-sdk
  Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk-linux-x64' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
  Pushed image 'dockerseries-bookstore-api:1.0.0-sdk-linux-x64' to registry 'localhost:5000'.
  Building image 'dockerseries-bookstore-api' with tags '1.0.0-sdk-linux-arm64' on top of base image 'mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra'.
  Pushed image 'dockerseries-bookstore-api:1.0.0-sdk-linux-arm64' to registry 'localhost:5000'.
  Pushed image index 'dockerseries-bookstore-api:1.0.0-sdk' to registry 'localhost:5000'.
Name:      localhost:5000/dockerseries-bookstore-api:1.0.0-sdk
MediaType: application/vnd.docker.distribution.manifest.list.v2+json
Manifests:
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/amd64
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/arm64

Two details stand out. First, the SDK also pushed each architecture's image under separate tags such as 1.0.0-sdk-linux-x64 and 1.0.0-sdk-linux-arm64; the index points to those two. Do not be surprised if the tag list in the registry gets crowded. Second, the format: .NET's base images use the Docker format, so the SDK produced a Docker manifest list too; the SDK documentation says multi-architecture images are always OCI, but this is what we saw with this version. ContainerImageFormat, new in .NET 10, lets you choose the format explicitly:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer \
    -p ContainerRegistry=localhost:5000 \
    -p RuntimeIdentifiers='"linux-x64;linux-arm64"' \
    -p ContainerRuntimeIdentifiers='"linux-x64;linux-arm64"' \
    -p ContainerImageFormat=OCI
MediaType: application/vnd.oci.image.index.v1+json
...
  MediaType: application/vnd.oci.image.manifest.v1+json
  Platform:  linux/amd64

The two formats can replace each other without losing information, and Docker runs both. The choice of format matters for tools that sign the image, attach an SBOM or expect OCI artifacts; the DevSecOps series covers those topics.

Console Apps and the Counterpart of the Dockerfile COPY

Before .NET 10, a console app needed <EnableSdkContainerSupport>true</EnableSdkContainerSupport> in its csproj to produce a container with the SDK. In .NET 10 that line is unnecessary; console projects support PublishContainer directly, like projects using the Web and Worker SDKs. We tried it on Bookstore.Tools:

dotnet publish src/Bookstore.Tools/Bookstore.Tools.csproj -t:PublishContainer \
    -p ContainerRepository=dockerseries-bookstore-tools -p ContainerImageTags=1.0.0-sdk
docker run --rm dockerseries-bookstore-tools:1.0.0-sdk
  Building image 'dockerseries-bookstore-tools' with tags '1.0.0-sdk' on top of base image 'mcr.microsoft.com/dotnet/runtime:10.0'.
  Pushed image 'dockerseries-bookstore-tools:1.0.0-sdk' to local registry via 'docker'.
11:39:34 fail: Bookstore.Tools.ImportWorker[0] Cannot read import file: Could not find a part of the path '/app/data/books.csv'.

The image was produced and ran, but it could not do its job. The Dockerfile from post 7 had a COPY --chown=app:app data/books.csv data/books.csv line; the SDK only packs the publish folder, and data/books.csv sits outside the project folder and is not in the publish output. On the SDK path, the only way to get a file into the image is to bring it into the publish output: a Content item in the csproj (with Link and CopyToPublishDirectory). We chose to keep the Dockerfile for Bookstore.Tools; a console tool carries its data file and ENV defaults more readably in a Dockerfile.

Dockerfile or SDK? Size, Layers and Time

The same Bookstore.Api, the same base image (aspnet:10.0-noble-chiseled-extra), two paths. The 1.0.0 we built with the Dockerfile from post 8 and the 1.0.0-sdk produced by the SDK, linux/amd64:

docker image ls --tree
docker history dockerseries-bookstore-api:1.0.0-sdk
dockerseries-bookstore-api:1.0.0                                d6c04f6b9901        240MB           73MB
└─ linux/amd64                                                  cb5eb143de1f        240MB           73MB
dockerseries-bookstore-api:1.0.0-sdk                            1ba8cbce21ca        240MB           73MB
└─ linux/amd64                                                  1ba8cbce21ca        240MB           73MB

IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
1ba8cbce21ca   3 minutes ago   .NET SDK Container Tooling, version 10.0.404…   987kB
<missing>      3 days ago      COPY /dotnet /usr/share/dotnet # buildkit       27.4MB    buildkit.dockerfile.v0
<missing>      3 days ago      ENV ASPNET_VERSION=10.0.15                      0B        buildkit.dockerfile.v0
Dockerfile (post 8)SDK PublishContainer
Size (DISK USAGE / CONTENT SIZE)240MB / 73MB240MB / 73MB
Added on top of the base imageWORKDIR (8.19 kB) + COPY /app (979 kB)one layer (987 kB)
Rebuild after a code change (on this machine)about 2.1 s6–8 s
HEALTHCHECKyesno (no property for it)
Needed to buildDocker + BuildKit.NET SDK (the daemon only for the local destination)

The size is the same because both put the same publish output on the same base image. The time difference needs explaining: we measured both paths three times after a .cs change, with warm caches. BuildKit took restore from the cache and ran only the publish step. The SDK redid the MSBuild evaluation and the restore check every time, then handed the image to Docker with docker load. With a registry or tarball destination that last step changes; the absolute numbers vary by machine, but saying "the SDK path is faster in every case" would not be right.

When Do You Still Need a Dockerfile?

What the SDK cannot do fits in one sentence: run commands inside the image. There is no counterpart of RUN. Accordingly, the cases where you still need a Dockerfile:

  • Installing a package for a native dependency (a font package with apt-get install, libgdiplus, a database client). Alternative: build your own base image with those packages using a Dockerfile and give it to the SDK through ContainerBaseImage.
  • A custom entrypoint script, extra files in the image or directory permissions (COPY --chown, chmod).
  • Image settings with no SDK property, such as the Dockerfile HEALTHCHECK.
  • Non-.NET parts: building the Angular app with Node and putting it into nginx (the next post).
  • A team standard: building every service with the same Dockerfile and Bake structure, the same build cache and the same scan steps.

In the opposite case, a plain ASP.NET Core or Worker service, the SDK path means fewer files, fewer rules and the right defaults (non-root, port, labels). In Bookstore we keep both paths open for the Api: Bake and the Dockerfile stay as in post 8, and the SDK settings live in the csproj.

In Production

Pin the base image. ContainerFamily only selects the tag; the digest that tag points to at publish time is used. If you want reproducible builds, write the full name with a digest into ContainerBaseImage; the org.opencontainers.image.base.digest label the SDK writes already records which base each image came from.

Pass tags from the pipeline. ContainerImageTags in the csproj is for local experiments; in CI apply the semver plus SHA strategy from post 4 with -p ContainerImageTags=$(version).

A multi-RID publish compiles the app once more per RID; build time grows with the number of architectures. The SDK does these in parallel by default; if some project types (for example Blazor WebAssembly) hit race conditions, you can make them sequential with ContainerPublishInParallel=false.

The choice of local destination changed in the .NET 11 SDK: on Windows, if WSL containers (wslc) are running they are preferred over Docker; you can pin it with LocalRegistry=Docker. We will see the details in post 15.

Common Pitfalls

Publishing without a RID and shipping the image to a server with another architecture. Without a RID, the SDK uses the architecture of the machine doing the publish. On an x64 Windows machine that is linux-x64 and matches the server; but the same command on a developer machine with an ARM processor (a Snapdragon Windows laptop or an Apple Silicon Mac) produces a linux-arm64 image. We tried it on an ARM machine:

dotnet publish src/Bookstore.Api/Bookstore.Api.csproj -t:PublishContainer -p ContainerImageTags=arm-dev
docker image inspect dockerseries-bookstore-api:arm-dev --format '{{.Os}}/{{.Architecture}}'
linux/arm64

This image never starts on an x64 server; it fails with exec format error, and we will see how to diagnose that in post 14. Always produce the production image in CI, naming the target explicitly with -r linux-x64 or ContainerRuntimeIdentifiers.

A relative ContainerArchiveOutputPath. As we saw above, the path is resolved against the project folder. If the archive lands in the project folder and is not in .dockerignore, a 70 MB file enters the context of the next docker build.

Assuming every Dockerfile setting has an SDK counterpart. HEALTHCHECK, RUN and copying files from outside the project do not exist on the SDK path. When moving a Dockerfile to the SDK, list those lines first.

Settling for the default base image. For an ASP.NET Core project the default is the full Ubuntu image; without ContainerFamily, a shell and a package manager end up in the image too. If the app needs globalization pick noble-chiseled-extra, otherwise noble-chiseled.

A Small Experiment

★☆☆ Set ContainerFamily to noble-chiseled, produce the image and call /books. Do you see the error from post 8 on the SDK path too?

★★☆ Add <ContainerEnvironmentVariable Include="Store__TimeZone" Value="UTC" /> to the csproj, produce the image and look at the Env list in the config with docker image inspect. If you pass the same variable with docker run -e, which one wins?

★★★ Write a Content item that brings data/books.csv into the publish output of Bookstore.Tools, produce the image with the SDK, and compare it with the Dockerfile image from post 7 by size, user (file owner with ls -ln) and ENV.

The .NET side is done: Bookstore.Api can be containerized both with a Dockerfile and with the SDK. In the next post we move on to bookstore-web, the Angular application: a Node build stage, non-root nginx, SPA fallback, and runtime configuration that gives each environment a different API address without rebuilding the image.