The .NET SDK can publish your application directly as a container image — no Dockerfile required. This capability was introduced in .NET 7 (via the Microsoft.NET.Build.Containers NuGet package) and became fully built into the SDK from .NET 8.0.200 onward — no extra package needed. In .NET 10, a single dotnet publish /t:PublishContainer command can build, tag, and push a container image for your ASP.NET Core Web API. You don’t even need Docker installed — as long as you push directly to a registry or export as a tarball. The SDK handles everything: base image selection, layer creation, tagging, and pushing.
My take: for 90% of .NET projects, this approach is simpler, faster, and more maintainable than writing and maintaining Dockerfiles. We benchmarked 6 different image variants, built a decision matrix for when you still need a Dockerfile, and put together a production hardening checklist — all of which we’ll cover in this guide. Let’s get into it.
What Is .NET Built-In Container Support?
.NET Built-In Container Support is a feature of the .NET SDK that allows you to publish your application as an OCI-compliant container image using the standard dotnet publish command. Instead of writing a multi-stage Dockerfile with FROM, COPY, RUN, and ENTRYPOINT instructions, you configure container properties directly in your .csproj file using MSBuild properties. The SDK then generates the container image during the publish step.
This feature was introduced in .NET 7 as a separate NuGet package (Microsoft.NET.Build.Containers). In .NET 7.0.200, the package was auto-included for Web SDK projects. With .NET 8.0.200, it became fully built into the SDK for all project types — no NuGet reference needed. In .NET 10, console apps can publish containers natively without any opt-in — matching the experience ASP.NET Core and Worker Service apps already had.
The key takeaway: the .NET SDK creates container images without Docker or a Dockerfile. By default, the publish command pushes to your local Docker daemon (which requires Docker/Podman running). But if you push directly to a registry (ContainerRegistry) or export as a tarball (ContainerArchiveOutputPath), no container runtime is needed at all.
Why Ditch the Dockerfile?
If you’ve maintained Dockerfiles for .NET projects, you know the pain points:
- Maintenance overhead — Every time you add a new project reference, you need to update
COPYinstructions to include the new.csprojfile. Miss one, and the restore step fails. - Build context headaches — Dockerfile assumes everything it needs is in the same directory tree. Files like
Directory.Build.props,Directory.Packages.props, orNuGet.configat the solution root are easy to miss, breaking the build. - Base image version drift — You manually specify
mcr.microsoft.com/dotnet/aspnet:10.0in the Dockerfile. When you upgrade to .NET 11, you need to update every Dockerfile. With SDK publishing, the base image tag is inferred from yourTargetFramework. - Duplication in microservices — In a solution with 5 services, you maintain 5 nearly identical Dockerfiles. With SDK publishing, shared configuration lives in
Directory.Build.props. - Context switching — Dockerfile is a separate DSL. SDK publishing uses the same MSBuild properties you already know from
.csprojfiles.
Here’s a real example. This is the Dockerfile from a solution with 10+ projects:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS buildWORKDIR /COPY ["Directory.Build.props", "/"]COPY ["Directory.Build.targets", "/"]COPY ["src/Host/Host.csproj", "src/Host/"]COPY ["src/Core/Application/Application.csproj", "src/Core/Application/"]COPY ["src/Core/Domain/Domain.csproj", "src/Core/Domain/"]COPY ["src/Core/Shared/Shared.csproj", "src/Core/Shared/"]COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"]RUN dotnet restore "src/Host/Host.csproj" --disable-parallelCOPY . .WORKDIR "/src/Host"RUN dotnet publish "Host.csproj" -c Release -o /app/publishFROM mcr.microsoft.com/dotnet/aspnet:10.0WORKDIR /appCOPY --from=build /app/publish .ENTRYPOINT ["dotnet", "Host.dll"]With the SDK approach, all of that becomes a single command. No Dockerfile needed.
Prerequisites
Before we start, make sure you have:
- .NET 10 SDK installed (run
dotnet --versionto verify) - Docker Desktop or Podman installed — needed to run containers locally, and also for the default publish behavior (which pushes to the local daemon)
- A .NET 10 ASP.NET Core Web API project (we’ll create one below)
Note: Docker/Podman is required for the default dotnet publish /t:PublishContainer command, which pushes to your local daemon. To go fully Docker-free, push directly to a remote registry (-p ContainerRegistry=ghcr.io) or export as a tarball (-p ContainerArchiveOutputPath=./image.tar.gz).
Your First Container Without a Dockerfile
Let’s create a simple ASP.NET Core Web API and containerize it with a single command.
Create the Project
dotnet new webapi -n ContainerDemo --framework net10.0cd ContainerDemoThis gives us a standard .NET 10 Web API with the weather forecast endpoint.
Publish as a Container
Run the following command from the project directory:
dotnet publish --os linux --arch x64 /t:PublishContainerThat’s it. The SDK will:
- Build your application in Release mode
- Select the appropriate base image (
mcr.microsoft.com/dotnet/aspnet:10.0) - Create a container image with your published application
- Push it to your local Docker daemon
You should see output like:
Building image 'containerdemo' with tags 'latest' on top of 'mcr.microsoft.com/dotnet/aspnet:10.0'.Pushed image 'containerdemo:latest' to local registry.Run the Container
docker run -d -p 8080:8080 --name my-api containerdemo:latestNavigate to http://localhost:8080/weatherforecast — you’ll see the JSON response from your containerized API.
Let’s break down the publish command arguments:
--os linux— Targets Linux as the container OS--arch x64— Targets the x64 architecture/t:PublishContainer— Tells MSBuild to publish as a container image instead of a regular publish
Customizing the Container Image
The SDK provides sensible defaults, but you’ll almost always want to customize the image. All configuration happens through MSBuild properties in your .csproj file.
Image Name and Tags
<PropertyGroup> <ContainerRepository>my-company/container-demo</ContainerRepository> <ContainerImageTags>1.0.0;latest</ContainerImageTags></PropertyGroup>ContainerRepository— Sets the image name. Defaults to theAssemblyNameif not specified.ContainerImageTags— Semicolon-separated tags. Defaults tolatestsince .NET 8.
Base Image
<PropertyGroup> <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-alpine</ContainerBaseImage></PropertyGroup>The SDK infers the base image from your project type:
- ASP.NET Core →
mcr.microsoft.com/dotnet/aspnet - Worker Service →
mcr.microsoft.com/dotnet/runtime - Self-contained →
mcr.microsoft.com/dotnet/runtime-deps
You can override this to use Alpine, Chiseled, or any custom base image. We’ll benchmark these variants in the next section.
Using ContainerFamily (Recommended)
Instead of specifying the full base image, use ContainerFamily to select a variant while letting the SDK manage the tag:
<PropertyGroup> <ContainerFamily>alpine</ContainerFamily></PropertyGroup>This appends alpine to the TFM-specific tag, resulting in aspnet:10.0-alpine. Other options include noble-chiseled, noble-chiseled-extra, and noble.
Ports and Environment Variables
<ItemGroup> <ContainerPort Include="8080" Type="tcp" /> <ContainerEnvironmentVariable Include="ASPNETCORE_ENVIRONMENT" Value="Production" /> <ContainerEnvironmentVariable Include="DOTNET_EnableDiagnostics" Value="0" /></ItemGroup>Starting with .NET 8, ports are automatically inferred from ASPNETCORE_HTTP_PORTS, ASPNETCORE_HTTPS_PORTS, and ASPNETCORE_URLS in your base image, so you typically don’t need to set ContainerPort explicitly.
Labels
Add OCI-standard metadata labels for security scanning and traceability:
<ItemGroup> <ContainerLabel Include="org.opencontainers.image.authors" Value="Mukesh Murugan" /> <ContainerLabel Include="org.opencontainers.image.vendor" Value="codewithmukesh" /></ItemGroup>Complete MSBuild Property Reference
Here’s every property you can use to customize your container image. This is the most comprehensive reference you’ll find outside Microsoft’s official docs:
| Property | Purpose | Default |
|---|---|---|
ContainerRepository | Image name (repository) | AssemblyName |
ContainerImageTags | Semicolon-separated tags | latest |
ContainerBaseImage | Full base image reference | Inferred from project type |
ContainerFamily | Base image variant (alpine, noble-chiseled) | — |
ContainerRegistry | Target registry URL | Local Docker daemon |
ContainerPort | Exposed ports (TCP/UDP) | Inferred from env vars |
ContainerEnvironmentVariable | Runtime environment variables | — |
ContainerLabel | OCI metadata labels | Auto-generated |
ContainerUser | User the container runs as | app (non-root) on .NET 8+ |
ContainerWorkingDirectory | Working directory | /app |
ContainerArchiveOutputPath | Export as tarball instead of pushing | — |
ContainerRuntimeIdentifier | Target OS/arch for the image | From RuntimeIdentifier |
ContainerRuntimeIdentifiers | Multi-arch targets (semicolon-separated) | — |
ContainerAppCommand | Override the app entry point | AppHost binary |
ContainerDefaultArgs | Default arguments for the entry point | — |
ContainerImageFormat | Image format: Docker or OCI | Inferred from base |
LocalRegistry | Force docker or podman | Auto-detected |
EnableSdkContainerSupport | Opt-in for console apps (.NET 8-9) | Auto in .NET 10 |
For the full details on each property, see the official configuration reference.
Image Size Benchmarks: Choosing the Right Base Image
This is where it gets interesting. We benchmarked 6 different base image configurations for an ASP.NET Core 10 Web API (the same weather forecast API) to measure the real-world impact of your base image choice.
Test setup:
- .NET 10 SDK, ASP.NET Core Web API (default template)
- All images built with
dotnet publish --os linux --arch x64 /t:PublishContainer - Sizes measured with
docker imagesafter publish
| Base Image | Configuration | Image Size | vs Default | Recommendation |
|---|---|---|---|---|
aspnet:10.0 (Ubuntu Noble) | Default — no config needed | ~232 MB | Baseline | Development and general use |
aspnet:10.0-alpine | ContainerFamily=alpine | ~123 MB | -47% | Production recommended |
aspnet:10.0-noble-chiseled | ContainerFamily=noble-chiseled | ~110 MB | -53% | Security-focused production |
aspnet:10.0-noble | ContainerFamily=noble | ~225 MB | -3% | Explicit Ubuntu Noble 24.04 |
runtime-deps:10.0-alpine | Self-contained + Alpine | ~85 MB | -63% | Performance-critical APIs |
runtime-deps:10.0-noble-chiseled | Self-contained + Chiseled + AOT | ~15 MB | -94% | Minimal footprint (AOT only) |
My take: Alpine (ContainerFamily=alpine) is the sweet spot for most production workloads. You get a 47% size reduction with zero code changes. Chiseled images are even smaller and more secure (no shell, no package manager, reduced attack surface), but they don’t include ICU libraries by default — so if your app uses culture-specific formatting, you need the -extra variant (noble-chiseled-extra).
For Native AOT applications, the combination of self-contained publish + Chiseled base image produces containers under 15 MB. That’s 94% smaller than the default. But Native AOT has its own constraints (no reflection-heavy libraries, no dynamic assembly loading), so it’s not for every project.
SDK Publish vs Dockerfile: The Decision Matrix
This is the question every team asks: “Can we drop our Dockerfiles?” Here’s the decision matrix we use:
| Criteria | SDK Publish | Dockerfile | Winner |
|---|---|---|---|
| Simple Web API / Worker | Single command, zero config | Multi-stage boilerplate | SDK Publish |
| Multi-project solution | Directory.Build.props handles it | Must list every .csproj | SDK Publish |
Custom OS packages (e.g., apt-get install) | Not supported — need custom base image | RUN apt-get install works | Dockerfile |
| Multi-stage with npm/frontend | Cannot mix .NET + Node in one publish | Full control over stages | Dockerfile |
| Private NuGet feeds | Handled by nuget.config automatically | Requires COPY nuget.config + ARG | SDK Publish |
| Base image auto-update | Inferred from TargetFramework | Manual tag updates | SDK Publish |
| CI/CD integration | Single dotnet publish step | Separate docker build step | SDK Publish |
| Air-gapped environments | Tarball export supported | Tarball via docker save | Tie |
| Debugging container builds | Limited — MSBuild output | Full docker build logs + layer caching | Dockerfile |
| docker-compose build | Not directly supported | Native support | Dockerfile |
The verdict: Use SDK publish as your default. Switch to Dockerfile only when you need RUN commands (installing OS packages), multi-stage builds with non-.NET components, or docker-compose build integration. For roughly 85-90% of .NET projects, SDK publish is the better choice.
If you need OS-level customizations, there’s a middle ground: build a custom base image once with a Dockerfile, push it to your registry, then reference it via ContainerBaseImage in all your projects. This way you get the customization of Dockerfile for the base + the simplicity of SDK publish for your apps.
Multi-Architecture Images in .NET 10
Starting with .NET SDK 8.0.405+ and 9.0.102+, you can build multi-architecture container images. This is critical if your team deploys to both x64 and ARM64 infrastructure (common with AWS Graviton or Azure Arm VMs).
<PropertyGroup> <ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers></PropertyGroup>Then publish:
dotnet publish /t:PublishContainerThe SDK publishes the app for each specified RID and combines the resulting images into an OCI Image Index. This index allows multiple architecture-specific images to share a single name — so docker pull my-app:latest automatically pulls the right architecture for the host machine.
Important: ContainerRuntimeIdentifiers must be a subset of RuntimeIdentifiers in your project. The resulting image format is always OCI when building multi-arch images.
Publishing to Container Registries
By default, the SDK pushes to your local Docker daemon. To push directly to a remote registry, set ContainerRegistry:
Docker Hub
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRegistry=docker.io -p ContainerRepository=yourusername/container-demoGitHub Container Registry
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRegistry=ghcr.io -p ContainerRepository=yourusername/container-demoAmazon ECR
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRegistry=123456789.dkr.ecr.us-east-1.amazonaws.com -p ContainerRepository=container-demoFor authenticated registries, the SDK uses the same credentials as docker login. Run docker login ghcr.io (or the equivalent for your registry) before publishing, and the SDK picks up the credentials automatically. Alternatively, you can set credentials via environment variables DOTNET_CONTAINER_REGISTRY_UNAME and DOTNET_CONTAINER_REGISTRY_PWORD — useful in CI/CD environments where docker login isn’t available.
The tooling supports any registry that implements the Docker Registry HTTP API V2, including Docker Hub, GitHub Packages, Amazon ECR, Azure Container Registry, Google Artifact Registry, GitLab Container Registry, and Quay.io.
Tarball Export for Air-Gapped Environments
Some organizations require security scanning of container images before deployment. Instead of pushing to a registry, you can export the image as a tarball:
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerArchiveOutputPath=./images/container-demo.tar.gzThis creates a .tar.gz archive containing the full container image. No running Docker daemon is needed for this step.
You can then:
- Run security scanning tools (Trivy, Snyk, Grype) against the tarball
- Transfer it to air-gapped environments via secure channels
- Load it into Docker with
docker load -i ./images/container-demo.tar.gz - Load it into Podman with
podman load -i ./images/container-demo.tar.gz
This workflow is common in regulated industries (finance, healthcare, government) where images must pass security gates before touching production infrastructure.
CI/CD with GitHub Actions
Here’s a complete GitHub Actions workflow that builds a .NET 10 Web API, publishes it as a container, and pushes it to GitHub Container Registry:
name: Build and Push Container
on: push: branches: [main] workflow_dispatch:
env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write
steps: - name: Checkout uses: actions/checkout@v6
- name: Setup .NET 10 uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x
- name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Container run: dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerRegistry=${{ env.REGISTRY }} -p ContainerRepository=${{ env.IMAGE_NAME }} -p ContainerImageTags="${{ github.sha }};latest"
- name: Verify Image run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latestThe beauty of this workflow: there’s no docker build step. The dotnet publish command handles everything — building the app, creating the container image, and pushing it to the registry. One command, one step.
Compare this to a traditional Dockerfile-based workflow where you need separate docker build and docker push steps, plus worry about build context and layer caching configuration.
Multi-Project Solutions with Directory.Build.props
For solutions with multiple projects (the common case in Clean Architecture, Modular Monolith, or Microservices), use Directory.Build.props at the solution root to share container configuration:
<Project> <PropertyGroup> <ContainerFamily>alpine</ContainerFamily> <ContainerImageTags>1.0.0;latest</ContainerImageTags> </PropertyGroup></Project>Then in each project’s .csproj, specify only the project-specific settings:
<PropertyGroup> <ContainerRepository>mycompany/catalog-api</ContainerRepository></PropertyGroup><PropertyGroup> <ContainerRepository>mycompany/orders-api</ContainerRepository></PropertyGroup>Now a simple dotnet publish /t:PublishContainer on each project uses the shared Alpine base, shared tags, and project-specific names. When you upgrade to .NET 11, update TargetFramework once and every project’s container gets the new base image automatically.
This was one of the biggest pain points with Dockerfiles in multi-project solutions — maintaining per-service Dockerfiles with identical COPY and RUN dotnet restore patterns. With Directory.Build.props, shared configuration stays DRY.
Production Hardening Checklist
Before shipping your SDK-published container to production, verify these items:
1. Run as Non-Root User
Starting with .NET 8, Microsoft’s container images default to the app user (UID 1654). SDK publish respects this automatically — your container already runs as non-root. Verify with:
docker run --rm containerdemo:latest whoamiShould return app, not root. If you need to override this (not recommended):
<PropertyGroup> <ContainerUser>root</ContainerUser></PropertyGroup>2. Use Minimal Base Images
Use Alpine or Chiseled images in production. They reduce attack surface by removing shells, package managers, and unnecessary OS components:
<PropertyGroup> <ContainerFamily>noble-chiseled</ContainerFamily></PropertyGroup>Chiseled images have no shell (/bin/sh), no package manager, and no unnecessary OS libraries — making them significantly more secure.
3. Pin Image Tags
Never use latest alone in production. Always include a version tag:
<PropertyGroup> <ContainerImageTags>1.0.0;latest</ContainerImageTags></PropertyGroup>4. Disable Diagnostics in Production
Reduce image surface area by disabling .NET diagnostics:
<ItemGroup> <ContainerEnvironmentVariable Include="DOTNET_EnableDiagnostics" Value="0" /></ItemGroup>5. Health Check Endpoints
Ensure your API exposes /health for container orchestrators (Kubernetes, ECS). The .NET SDK doesn’t add HEALTHCHECK instructions automatically, but your orchestrator should be configured to probe your health endpoint.
6. Scan Images Before Deployment
Use the tarball export workflow to scan images with Trivy, Snyk, or Grype before deploying:
dotnet publish --os linux --arch x64 /t:PublishContainer -p ContainerArchiveOutputPath=./image.tar.gztrivy image --input ./image.tar.gzPodman Support
If you use Podman instead of Docker, the SDK supports it with zero configuration changes. The SDK auto-detects whether Docker or Podman is available:
- If both exist and Docker is aliased to Podman, Podman is used.
- If only Podman exists, Podman is used.
To force a specific runtime:
<PropertyGroup> <LocalRegistry>podman</LocalRegistry></PropertyGroup>All publish commands work identically — dotnet publish /t:PublishContainer pushes to the Podman local store instead of Docker.
Troubleshooting Common Errors
”The CreateNewImage task failed unexpectedly”
Cause: No running container runtime (Docker/Podman) detected.
Fix: Start Docker Desktop or Podman, or use ContainerArchiveOutputPath to export as a tarball without needing a runtime.
”Error: The container base image mcr.microsoft.com/dotnet/aspnet:10.0 does not support the runtime identifier linux-musl-x64”
Cause: You set a musl-based RID but the default Debian base image doesn’t support it.
Fix: Use ContainerFamily=alpine or set ContainerBaseImage explicitly to an Alpine image.
”Unable to push to registry: authentication required”
Cause: Registry credentials not configured.
Fix: Run docker login <registry-url> before publishing. For CI/CD, use the docker/login-action GitHub Action.
”Image name contains invalid characters”
Cause: ContainerRepository must be lowercase and can only contain letters, numbers, periods, underscores, and dashes.
Fix: Set ContainerRepository explicitly to a valid name:
<PropertyGroup> <ContainerRepository>my-app</ContainerRepository></PropertyGroup>“Globalization mode is invariant, but the app requires ICU”
Cause: Alpine and Chiseled images don’t include ICU libraries by default. If your app uses culture-specific formatting (dates, numbers, currency), it will fail.
Fix: Use the -extra variant that includes ICU:
<PropertyGroup> <ContainerFamily>noble-chiseled-extra</ContainerFamily></PropertyGroup>Or enable invariant globalization mode if your app doesn’t need culture-specific formatting:
<PropertyGroup> <InvariantGlobalization>true</InvariantGlobalization></PropertyGroup>“docker-compose build doesn’t work with SDK containers”
Cause: docker-compose build requires a Dockerfile — it doesn’t support the SDK publish approach.
Fix: Use docker-compose with image: instead of build:. First publish the image with SDK, then reference it in your compose file:
services: api: image: my-app:latest ports: - "8080:8080"Key Takeaways
- The .NET SDK can build container images without a Dockerfile — just
dotnet publish /t:PublishContainer. The default command pushes to your local Docker daemon, but you can go fully Docker-free by pushing to a remote registry or exporting as a tarball. - Alpine images cut size by 47% with zero code changes. Use
ContainerFamily=alpineas your production default. - SDK publish beats Dockerfiles for 85-90% of .NET projects. Switch to Dockerfile only when you need
RUNcommands, multi-stage non-.NET builds, ordocker-compose build. - Multi-arch images are supported in .NET 10 via
ContainerRuntimeIdentifiers— ship one image name that works on both x64 and ARM64. - Use
Directory.Build.propsto share container configuration across multi-project solutions. No more maintaining 5 identical Dockerfiles.
What is .NET built-in container support and how does it work?
.NET built-in container support is a feature of the .NET SDK that allows you to publish your application directly as a container image using the dotnet publish command with the /t:PublishContainer target. Introduced in .NET 7 as a NuGet package, it became fully built into the SDK from .NET 8.0.200 onward. The SDK handles base image selection, layer creation, tagging, and pushing — all without requiring a Dockerfile. Configuration is done through MSBuild properties in your .csproj file.
Do I still need Docker installed to use dotnet publish for containers?
It depends on where you push. The default dotnet publish /t:PublishContainer command pushes to your local Docker daemon, which requires Docker or Podman running. However, you can go fully Docker-free by pushing directly to a remote registry (set ContainerRegistry) or exporting as a tarball (set ContainerArchiveOutputPath). In those cases, no container runtime is needed at all.
What is the difference between dotnet publish containers and using a Dockerfile?
With dotnet publish containers, you configure container properties in your .csproj file using MSBuild properties and build the image with a single command. With a Dockerfile, you write a separate file with FROM, COPY, RUN, and ENTRYPOINT instructions. The SDK approach is simpler, automatically updates base images when you change TargetFramework, and handles multi-project solutions cleanly via Directory.Build.props. Dockerfiles give more control for installing OS packages and running arbitrary commands.
How do I reduce .NET container image size with Alpine or Chiseled images?
Add ContainerFamily=alpine to your .csproj PropertyGroup to use Alpine Linux images, which are about 47% smaller than the default Ubuntu images. For even smaller images, use ContainerFamily=noble-chiseled for Ubuntu Chiseled images (53% smaller). If your app uses culture-specific formatting, use noble-chiseled-extra which includes ICU libraries.
Can I push container images directly to a registry without Docker?
Yes. Set the ContainerRegistry property to your registry URL (e.g., ghcr.io, docker.io, or your ECR endpoint) and the SDK pushes the image directly during dotnet publish. You need to authenticate first using docker login or equivalent. You can also export to a tarball with ContainerArchiveOutputPath without any container runtime.
How do I use dotnet publish containers in a CI/CD pipeline?
In your CI/CD pipeline (e.g., GitHub Actions), set up the .NET SDK, authenticate to your container registry, then run dotnet publish --os linux --arch x64 /t:PublishContainer with ContainerRegistry and ContainerImageTags properties. There is no separate docker build step needed. The single dotnet publish command builds, packages, and pushes the image.
What are the limitations of the SDK container approach vs Dockerfile?
The SDK approach cannot execute RUN commands (no apt-get install), does not support docker-compose build directly, and has limited debugging compared to docker build logs. If you need custom OS packages, multi-stage builds with non-.NET components, or docker-compose build integration, you still need a Dockerfile. A hybrid approach works well: build a custom base image once with a Dockerfile, then use SDK publish for all your apps.
How do I containerize a multi-project .NET solution without a Dockerfile?
Place shared container configuration (ContainerFamily, ContainerImageTags) in a Directory.Build.props file at the solution root. Then add project-specific settings (ContainerRepository) in each project .csproj file. Run dotnet publish /t:PublishContainer on each project individually. This eliminates the need for per-service Dockerfiles and keeps shared configuration DRY.
Summary
In this article, we covered the full spectrum of containerizing .NET 10 applications without a Dockerfile. We started with the basics — what built-in container support is and how to publish a container with a single dotnet publish command. We explored image customization through MSBuild properties, benchmarked 6 base image variants (spoiler: Alpine gives you 47% savings for free), and built a decision matrix for when to use SDK publish vs Dockerfile.
We also covered multi-architecture images, registry publishing, tarball exports for security scanning, GitHub Actions CI/CD integration, Directory.Build.props for multi-project solutions, and a complete production hardening checklist. If you’re still writing Dockerfiles for straightforward .NET Web APIs, it’s time to make the switch.
For the complete configuration reference, check the official Microsoft Learn documentation on container publishing and the configuration reference.
If you found this guide useful, share it with your team — and subscribe to the codewithmukesh newsletter for weekly deep dives on .NET, architecture, and DevOps. We send exclusive benchmarks and judgment calls that don’t make it into the blog articles.
Happy Coding :)


