Publishing a Container Without a Dockerfile
Build and push an OCI image of an ASP.NET Core API with dotnet publish /t:PublishContainer, choosing base image, tags and registry in MSBuild.
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer \
-p ContainerRegistry=ghcr.io -p ContainerRepository=acme-shop/orders-api \
-p ContainerImageTags=1.4.0The .NET SDK can build a container image itself. dotnet publish /t:PublishContainer publishes the app, picks a matching Microsoft base image, adds the publish output as one layer, sets the entrypoint, user, working directory, port and environment, and then either loads the image into the local Docker or Podman daemon or pushes it straight to a registry. There is no Dockerfile, and pushing needs no daemon at all.
How the image is put together
- Base image:
aspnetfor a web project,runtimefor a console app,runtime-depsfor self-contained or Native AOT output. The tag follows the target framework (10.0), plusContainerFamilyif set, sonoble-chiseledgivesaspnet:10.0-noble-chiseled. - Architecture: from
--os/--archor-r. Leave it out and the SDK targets Linux on the build machine's CPU, so an image published on an Apple-silicon laptop is arm64. - User and port: taken from the base image, so the app runs as the non-root
appuser and exposes 8080.
| Property | Default | Typical value |
|---|---|---|
ContainerRegistry | none: local daemon | ghcr.io |
ContainerRepository | assembly name, lower-cased | acme-shop/orders-api |
ContainerImageTag / ContainerImageTags | latest | 1.4.0, a commit SHA; set one or the other, never both (CONTAINER2008) |
ContainerFamily | none (noble) | noble-chiseled-extra |
ContainerBaseImage | inferred | a full reference pinned by digest |
ContainerArchiveOutputPath | none | a .tar.gz for scanners |
Registry credentials come from the Docker credential store (~/.docker/config.json and credential helpers), so run docker login or your CI's login action first. Web SDK projects support the target out of the box; a plain console project needs <EnableSdkContainerSupport>true</EnableSdkContainerSupport>.
SDK target or Dockerfile?
Choose the SDK target for an ordinary API or worker: nothing to maintain, correct layering every time, the same result on every machine and no Docker-in-Docker in CI. Keep a Dockerfile when the image needs OS packages, extra files outside the publish output, or build steps MSBuild cannot express. The SDK cannot run RUN instructions.
Production failure modes
- Wrong CPU. An arm64 image from a laptop scheduled on amd64 nodes crash-loops with
exec format error. Always pass--archin scripts. - Mutable tags. Deploying
latestmakes Kubernetes pull on every start and makes a rollback point at whateverlatestis now. Tag with the version or commit SHA. - A stale explicit base. The inferred base follows
TargetFramework. A hard-codedContainerBaseImageofaspnet:8.0left behind after an upgrade tonet10.0builds fine and fails at start with "You must install or update .NET to run this application".
Example
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.4.0</Version>
<!-- dotnet publish /t:PublishContainer reads these -->
<ContainerRepository>acme-shop/orders-api</ContainerRepository>
<ContainerFamily>noble-chiseled-extra</ContainerFamily>
<ContainerImageTags>$(Version);$(Version)-chiseled</ContainerImageTags>
</PropertyGroup>
<ItemGroup>
<!-- The same in every environment: GC heap limit 70% of the container memory (hex 0x46) -->
<ContainerEnvironmentVariable Include="DOTNET_GCHeapHardLimitPercent" Value="0x46" />
<ContainerLabel Include="org.opencontainers.image.vendor" Value="Acme Shop" />
</ItemGroup>
</Project>When to use it
- Push the orders API to ghcr.io from a CI runner that has the .NET SDK but no Docker daemon.
- Build a chiseled image for an invoice worker by setting ContainerFamily in the project instead of maintaining a Dockerfile per service.
- Write a .tar.gz of the image for a vulnerability scanner in an air-gapped pipeline before anything is pushed.
More examples
Local daemon, registry push and tarball
<code>dotnet publish</code> builds Release by default, so no <code>-c</code> is needed. Without <code>ContainerRegistry</code> the image goes to the local daemon; with it the SDK talks to the registry API directly, which is why a CI job can push from a runner with no Docker installed. The project sets <code>ContainerImageTags</code>, so the command line overrides that plural property; passing the singular <code>ContainerImageTag</code> as well fails with CONTAINER2008.
# Into the local Docker daemon as acme-shop/orders-api:1.4.0
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer
# Straight to a registry; credentials come from docker login
docker login ghcr.io
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer \
-p ContainerRegistry=ghcr.io \
-p ContainerRepository=acme-shop/orders-api \
-p ContainerImageTags=1.4.0
# A tarball for a scanner or an air-gapped copy, no daemon needed
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer \
-p ContainerArchiveOutputPath=./artifacts/orders-api.tar.gz
trivy image --input ./artifacts/orders-api.tar.gzPublish from GitHub Actions to GHCR
The login action writes the registry credentials where the SDK looks for them, and the workflow token only needs <code>packages: write</code>. Tagging with the commit SHA gives every build an immutable tag that a deployment can reference and roll back to. The global property replaces the project's <code>ContainerImageTags</code>; to push several tags, separate them with <code>%3B</code>, the escaped semicolon.
name: orders-api-image
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish container
run: >
dotnet publish src/Orders.Api --os linux --arch x64 /t:PublishContainer
-p ContainerRegistry=ghcr.io
-p ContainerRepository=acme-shop/orders-api
-p ContainerImageTags=${{ github.sha }}A worker with an explicit base image
A queue worker has no HTTP endpoint, so it uses the smaller <code>runtime</code> image. Setting <code>ContainerBaseImage</code> overrides inference completely; that is how you pin a digest, but it also means a framework upgrade must update this line or the container fails at start.
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableSdkContainerSupport>true</EnableSdkContainerSupport>
<ContainerRepository>acme-shop/invoice-worker</ContainerRepository>
<!-- Explicit base: must be bumped by hand when TargetFramework changes -->
<ContainerBaseImage>mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled</ContainerBaseImage>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
</ItemGroup>
</Project>
Discussion