Skip to main content

How to Dockerize an ASP.NET Core App in .NET 9

Learn how to dockerize an ASP.NET Core app in .NET 9 with a step-by-step Dockerfile tutorial, best practices, and deployment tips. Start containerizing today!

If you want to dockerize an ASP.NET Core app, .NET 9 makes it easier than any previous release. Containers have become the default way to ship web applications — every major cloud (Azure, AWS, Google Cloud) runs them natively, and "it works on my machine" bugs largely disappear when your app ships with its entire runtime environment. In this tutorial, you'll learn how to containerize a .NET 9 ASP.NET Core application with a production-ready Dockerfile, understand why each instruction matters, run it locally with Docker Compose, and deploy it to the cloud. We'll also cover the built-in SDK container publishing feature that lets you skip the Dockerfile entirely.

Why Dockerize an ASP.NET Core App?

Before writing any code, it's worth understanding what containers actually buy you, because the "why" shapes how you write your Dockerfile:

  • Consistency across environments. A container image bundles your compiled app, the .NET runtime, and the OS libraries it depends on. The exact same image runs on your laptop, in CI, and in production — no more version drift between environments.
  • Fast, repeatable deployments. Images are immutable. Rolling back a bad release means pointing your orchestrator at the previous image tag, not re-running a deployment script and hoping.
  • Cloud portability. Azure Container Apps, AWS ECS/Fargate, Google Cloud Run, and Kubernetes all consume the same OCI image. Containerizing once opens every door.
  • Density and cost. Containers share the host kernel, so they're far lighter than VMs. A trimmed .NET 9 image can be under 100 MB, which means faster cold starts and lower registry/bandwidth costs.

.NET has been container-friendly for years, but .NET 9 sharpens the story: smaller Ubuntu Chiseled base images, a non-root user baked into the official images, and first-class dotnet publish container support.

Prerequisites

  • .NET 9 SDK — verify with dotnet --version (should print 9.x)
  • Docker Desktop (Windows/macOS) or Docker Engine (Linux) — verify with docker --version
  • Any editor — Visual Studio 2022, VS Code, or Rider

Step 1: Create a Sample ASP.NET Core Web API

Let's create a minimal API so we have something real to containerize:

// Terminal:
// dotnet new webapi -n WeatherApi --no-https
// cd WeatherApi

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddHealthChecks();

var app = builder.Build();

app.MapOpenApi();
app.MapHealthChecks("/health");

var summaries = new[]
{
    "Freezing", "Chilly", "Mild", "Warm", "Balmy", "Hot", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast(
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]))
        .ToArray();
    return forecast;
});

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Note the --no-https flag and the /health endpoint. Inside a container, TLS is almost always terminated by a reverse proxy or the cloud platform's load balancer, so the app itself listens on plain HTTP. The health check endpoint is what orchestrators use to decide whether your container is alive.

Step 2: Write a Production-Ready Dockerfile for ASP.NET Core

Create a file named Dockerfile (no extension) in the project root:

// Dockerfile — multi-stage build for .NET 9
// ---- Build stage ----
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

// Copy csproj first and restore — this layer is cached
// until your package references change
COPY WeatherApi.csproj ./
RUN dotnet restore

// Now copy the rest of the source and publish
COPY . .
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false

// ---- Runtime stage ----
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

// .NET 8+ images ship a non-root 'app' user (UID 1654)
USER app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080

ENTRYPOINT ["dotnet", "WeatherApi.dll"]

(Comments above use // for readability; in a real Dockerfile use #.) Every line here exists for a reason:

Why a Multi-Stage Build?

The SDK image (dotnet/sdk:9.0) is roughly 800 MB — it contains the compiler, NuGet, and build tooling. The ASP.NET runtime image (dotnet/aspnet:9.0) is around 220 MB. A multi-stage build compiles in the fat image, then copies only the published output into the slim one. Your production image never contains your source code or the SDK, which shrinks both attack surface and pull time.

Why Copy the .csproj Before the Source?

Docker caches each layer. Because dotnet restore runs right after copying only the project file, that expensive NuGet restore layer is reused on every build where your package references haven't changed. Change a .cs file and rebuild — only the publish layer re-runs. This single trick often cuts CI build times from minutes to seconds.

Why Port 8080 and a Non-Root User?

Since .NET 8, the official images default to port 8080 (not 80) and include a non-root app user. Ports below 1024 require root on Linux, and running containers as root is the classic security mistake — if an attacker escapes your app, they're root inside the container. USER app makes the container comply out of the box with Kubernetes runAsNonRoot policies and Azure Container Apps security baselines.

Step 3: Build and Run the Container

Add a .dockerignore file first — without it, Docker copies your bin, obj, and .git folders into the build context, slowing builds and occasionally causing stale-DLL bugs:

// .dockerignore
bin/
obj/
.git/
.vs/
**/*.user
Dockerfile
.dockerignore

Now build and run:

// Build the image
docker build -t weatherapi:1.0 .

// Run it, mapping host port 5000 to container port 8080
docker run --rm -p 5000:8080 weatherapi:1.0

// Test in another terminal
curl http://localhost:5000/weatherforecast
curl http://localhost:5000/health

If you see JSON weather data, congratulations — you've containerized your first .NET 9 app.

The .NET 9 Shortcut: Publish a Container Without a Dockerfile

Here's something many developers still don't know: the .NET SDK can build an OCI image directly, no Dockerfile required. This is my recommended default for straightforward apps:

// One command — builds the app AND the container image
dotnet publish -c Release /t:PublishContainer

// Customize via the .csproj:
<PropertyGroup>
  <ContainerRepository>weatherapi</ContainerRepository>
  <ContainerImageTags>1.0;latest</ContainerImageTags>
  <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled</ContainerBaseImage>
</PropertyGroup>

The SDK handles layering, base image selection, and even pushing to a registry (ContainerRegistry property) — and it's often faster than docker build because it reuses MSBuild's incremental compilation. Use a hand-written Dockerfile when you need custom OS packages, multi-project orchestration, or fine-grained control; use PublishContainer for everything else.

Notice the Ubuntu Chiseled base image in that snippet. Chiseled images strip out the shell, package manager, and everything else your app doesn't need at runtime — the ASP.NET chiseled image is around 110 MB, and there's no bash for an attacker to use. The trade-off: you can't docker exec into a shell for debugging, so many teams use standard images in dev and chiseled in production.

Step 4: Docker Compose for Local Development

Real apps rarely run alone. Docker Compose lets you run your API plus its dependencies — a database, Redis, a message queue — with one command. Create compose.yaml:

// compose.yaml
services:
  weatherapi:
    build: .
    ports:
      - "5000:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__Default=Host=db;Database=weather;Username=app;Password=devpass
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=weather
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=devpass
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5

Run docker compose up --build and both services start together. Two details worth internalizing:

  • The double underscore in ConnectionStrings__Default is how ASP.NET Core's configuration system maps environment variables to nested JSON keys (ConnectionStrings:Default). This is the standard way to configure containerized .NET apps — never bake appsettings.Production.json secrets into the image.
  • Service names are DNS names. The connection string points at Host=db, not localhost. Inside the Compose network, each service resolves by its name — a classic gotcha for developers new to Docker.

Step 5: Deploy Your ASP.NET Core Docker Image to the Cloud

Once your image works locally, deployment is: push to a registry, point a container service at it. Here's the Azure Container Apps flow, which gives you HTTPS, autoscaling, and scale-to-zero with three commands:

// Push to Azure Container Registry
az acr create -g my-rg -n myregistry --sku Basic
az acr login -n myregistry
docker tag weatherapi:1.0 myregistry.azurecr.io/weatherapi:1.0
docker push myregistry.azurecr.io/weatherapi:1.0

// Deploy to Azure Container Apps
az containerapp up \
  --name weatherapi \
  --resource-group my-rg \
  --image myregistry.azurecr.io/weatherapi:1.0 \
  --target-port 8080 \
  --ingress external

The same image pushes unchanged to AWS (ECR + App Runner/Fargate) or Google Cloud (Artifact Registry + Cloud Run). That portability is the whole point of containerizing.

Best Practices and Common Pitfalls

Best Practices

  • Pin image tags in production. aspnet:9.0 moves as patches ship. For reproducible builds, pin the digest (aspnet:9.0@sha256:...) and update deliberately via Dependabot or Renovate.
  • Configure via environment variables, and pull secrets from a vault (Azure Key Vault, AWS Secrets Manager) — never from files baked into the image. Anyone who can pull the image can read its layers.
  • Add health endpoints with AddHealthChecks() and wire them into your orchestrator's liveness/readiness probes. Without them, the platform can't tell a hung app from a healthy one.
  • Respect container memory limits. .NET's GC is container-aware and reads cgroup limits, but always set a limit (--memory, or resource requests in Kubernetes) so the GC can plan around it instead of getting OOM-killed.
  • Log to stdout. The default ASP.NET Core console logger is correct for containers — Docker and every cloud platform collect stdout. Don't write log files inside the container; they vanish with it.

Common Pitfalls

  • Expecting port 80. .NET 8+ images listen on 8080. If docker run -p 5000:80 gives you connection refused, this is why.
  • Forgetting .dockerignore, which bloats the build context and can copy a locally built bin/ over your fresh publish output.
  • Handling SIGTERM badly. When an orchestrator stops your container it sends SIGTERM and waits (default 10–30 s) before SIGKILL. ASP.NET Core's host handles graceful shutdown automatically — but only if dotnet is PID 1. Use the exec-form ENTRYPOINT ["dotnet", "app.dll"], never shell form, or signals won't reach your app and in-flight requests get dropped.
  • Running ef database update at container startup. Ten replicas starting at once means ten concurrent migrations. Run migrations as a separate one-shot job or init container instead.
  • Assuming a writable filesystem. Hardened platforms mount the root filesystem read-only. Write only to mounted volumes or /tmp, and configure Data Protection keys to persist outside the container if you use cookies or antiforgery tokens across replicas.

Conclusion: Key Takeaways

Learning how to dockerize an ASP.NET Core app in .NET 9 comes down to a handful of principles rather than memorized commands:

  • Use a multi-stage Dockerfile — build with the SDK image, run on the slim ASP.NET runtime image — and copy the .csproj first to maximize layer caching.
  • For simple apps, skip the Dockerfile entirely with dotnet publish /t:PublishContainer, and consider Ubuntu Chiseled base images for the smallest, most secure production footprint.
  • Remember the .NET 9 defaults: port 8080 and a non-root user. Configure everything through environment variables, log to stdout, and expose a /health endpoint.
  • Use Docker Compose locally to run your app with its real dependencies, then push the exact same image to Azure Container Apps, AWS, or Google Cloud Run.

The best next step is to take an existing project — not a demo — and containerize it today. You'll hit one or two of the pitfalls above, fix them in twenty minutes, and come away with a deployment artifact that runs identically everywhere. That reliability is what makes Docker with .NET 9 worth the small learning curve.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...