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.

Syntaxdotnet 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

The .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: aspnet for a web project, runtime for a console app, runtime-deps for self-contained or Native AOT output. The tag follows the target framework (10.0), plus ContainerFamily if set, so noble-chiseled gives aspnet:10.0-noble-chiseled.
  • Architecture: from --os/--arch or -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 app user and exposes 8080.
PropertyDefaultTypical value
ContainerRegistrynone: local daemonghcr.io
ContainerRepositoryassembly name, lower-casedacme-shop/orders-api
ContainerImageTag / ContainerImageTagslatest1.4.0, a commit SHA; set one or the other, never both (CONTAINER2008)
ContainerFamilynone (noble)noble-chiseled-extra
ContainerBaseImageinferreda full reference pinned by digest
ContainerArchiveOutputPathnonea .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 --arch in scripts.
  • Mutable tags. Deploying latest makes Kubernetes pull on every start and makes a rollback point at whatever latest is now. Tag with the version or commit SHA.
  • A stale explicit base. The inferred base follows TargetFramework. A hard-coded ContainerBaseImage of aspnet:8.0 left behind after an upgrade to net10.0 builds fine and fails at start with "You must install or update .NET to run this application".

Example

Example · xml
<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.

Example · bash
# 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.gz

Publish 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.

Example · yaml
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.

Example · xml
<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

  • Be the first to comment on this lesson.