Publishing a Container Without a Dockerfile
dotnet publish /t:PublishContainer builds an image straight from the project, and for most services that is enough.
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRepository=inventory-apiThe .NET SDK can build a container image on its own, with no Dockerfile and no Docker daemon involved in the build. You run dotnet publish with the PublishContainer target and the SDK assembles the image layers itself.
What it produces
The SDK picks a base image from the project type: dotnet/aspnet for an ASP.NET Core project, dotnet/runtime for anything else, and dotnet/runtime-deps for a self-contained one. The tag comes from your TargetFramework, so net10.0 gives 10.0. The image name defaults to the project's AssemblyName and the image tag defaults to latest (it was the project version before .NET 8). Since .NET 8 the image runs as the rootless app user by default.
By default the finished image is pushed to your local container runtime. Set ContainerRegistry to push to a remote registry instead, or ContainerArchiveOutputPath to write a .tar.gz that a scanner can read without a daemon.
When it is enough
Almost always, for a plain service. You configure the image through MSBuild properties rather than Dockerfile instructions, and there is one fewer file to keep in step with the project.
Where it stops
There is no equivalent of RUN. The SDK cannot execute a command inside the image, so it cannot install an OS package or create a user.
Example
// Program.cs of a console project named InventoryTool.
// `dotnet publish /t:PublishContainer` wraps this in an image. Because the
// project is not an ASP.NET Core project, the SDK chooses
// mcr.microsoft.com/dotnet/runtime:10.0 as the base image.
string[] skus = ["A-100", "B-204", "C-7"];
Console.WriteLine($"checking {skus.Length} skus");
foreach (string sku in skus)
{
Console.WriteLine($"ok {sku}");
}
Console.WriteLine("done");
// Output:
// checking 3 skus
// ok A-100
// ok B-204
// ok C-7
// doneWhen to use it
- Containerise an existing service without adding and maintaining a Dockerfile.
- Produce a tar.gz image in CI so a vulnerability scanner runs before the push.
- Build one image name for both linux-x64 and linux-arm64 from a single publish command.
More examples
Container settings in the project file
Every Dockerfile instruction except RUN has an MSBuild equivalent, and this is where they live. Note the split: single values are properties in a PropertyGroup, repeatable ones are items in an ItemGroup with Include and Value attributes.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- The image name. Defaults to AssemblyName, which is often not a
legal image name, so set it explicitly. -->
<ContainerRepository>inventory-api</ContainerRepository>
<!-- The tag. Without this it is "latest". -->
<ContainerImageTag>1.4.0</ContainerImageTag>
<!-- Appended to the version tag to pick a base image variant, so the
base becomes mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled. -->
<ContainerFamily>noble-chiseled</ContainerFamily>
</PropertyGroup>
<ItemGroup>
<!-- Items, not properties: each one is a separate entry in the image. -->
<ContainerEnvironmentVariable Include="ASPNETCORE_HTTP_PORTS" Value="8080" />
<ContainerPort Include="8080" Type="tcp" />
<ContainerLabel Include="org.example.team" Value="inventory" />
</ItemGroup>
</Project>Three destinations for the same image
The archive form is the one worth remembering. It lets a CI pipeline build, scan and only then push, without ever running a container daemon in the build job.
# To your local container runtime. This is the default.
dotnet publish --os linux --arch x64 /t:PublishContainer
# To a tar.gz file. Needs no running daemon, so a security scanner can
# read it before anything is pushed anywhere.
dotnet publish --os linux --arch x64 /t:PublishContainer \
-p ContainerArchiveOutputPath=./images/inventory-api.tar.gz
# Straight to a remote registry. Authenticate first with docker login.
dotnet publish --os linux --arch x64 /t:PublishContainer \
-p ContainerRegistry=ghcr.io
# Load an archive built by the second command into Docker.
docker load -i ./images/inventory-api.tar.gzShorthand and longhand on the command line
Recognise the PublishProfile=DefaultContainer form when you meet it. It predates /t:PublishContainer and still works, but the target is the documented way now. The docker inspect line is how you confirm the non-root default rather than trusting it.
# These two say the same thing. /t: and -t: both mean "run this target",
# and -p is short for --property.
dotnet publish /t:PublishContainer -p ContainerRepository=inventory-api
dotnet publish --target:PublishContainer --property:ContainerRepository=inventory-api
# The older form, still common in blog posts and build scripts. It uses a
# built-in publish profile instead of naming the target.
dotnet publish -p PublishProfile=DefaultContainer
# Check what the SDK actually produced: the user and the exposed ports.
docker inspect inventory-api:1.4.0 --format '{{.Config.User}} {{.Config.ExposedPorts}}'
Discussion