Cloud & DevOps

Dockerizing an ASP.NET Core API the Right Way

Dockerizing an ASP.NET Core API the Right Way

Most ASP.NET Core Dockerfiles I come across started as a copy-paste from somewhere and never got revisited. They work, but they’re slow to build, the images are bigger than they need to be, they run as root, and nothing tells the orchestrator whether the app is actually healthy. In this post I build a production-ready Dockerfile for an ASP.NET Core API step by step and explain each decision. Everything targets .NET 10, the current LTS release.

The naive Dockerfile and what’s wrong with it

Here’s the kind of Dockerfile that shows up in a lot of repositories:

# Don't do this
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /app
COPY . .
RUN dotnet publish -c Release -o out
ENTRYPOINT ["dotnet", "out/Api.dll"]

It builds and runs. The problems:

  • It ships the SDK. The final image contains the compilers, NuGet and all the build tooling, none of which the app needs at runtime. That’s extra size and extra attack surface.
  • It has no layer caching. COPY . . before restoring means any source change invalidates the cache, so every build downloads every NuGet package again.
  • It copies everything. Without a .dockerignore, local bin/, obj/, .git/ and sometimes secrets end up in the build context.
  • It runs as root.
  • There’s no health signal. Docker and your orchestrator only know whether the process is running, not whether it can serve requests.

Multi-stage builds: SDK to build, runtime-only image to run

A multi-stage build uses the large SDK image to compile and publish, then copies only the published output into a much smaller runtime image. For ASP.NET Core that’s mcr.microsoft.com/dotnet/aspnet, which contains the ASP.NET Core and .NET runtimes but no SDK.

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Api/Api.csproj -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Api.dll"]

Only the last stage ends up in the image you push. Everything in the build stage is discarded.

Layer caching: restore before copy, plus a .dockerignore

Docker caches each instruction as a layer and reuses it until its inputs change. Project files change rarely and source files change constantly, so copy the project files first, restore, and only then copy the rest:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# 1. Only project files: this layer is reused until dependencies change
COPY Api.sln ./
COPY src/Api/Api.csproj src/Api/
RUN dotnet restore src/Api/Api.csproj

# 2. Now the source: changes here don't invalidate the restore layer
COPY . .
RUN dotnet publish src/Api/Api.csproj -c Release -o /app/publish \
    --no-restore -p:UseAppHost=false

--no-restore avoids a second restore, and UseAppHost=false skips the native launcher executable, which you don’t need because the container starts the app with dotnet Api.dll. If you use Directory.Build.props or Directory.Packages.props (central package management), copy those before the restore too, since they affect it.

Then add a .dockerignore next to the Dockerfile:

**/bin/
**/obj/
**/.vs/
**/.vscode/
.git/
**/*.user
**/appsettings.Development.json
**/secrets*.json
Dockerfile*
docker-compose*.yml

This keeps the build context small and avoids leaking local build output or development settings into the image.

Smaller images: aspnet vs chiseled vs alpine, and the trade-offs

The aspnet image comes in several flavours. Microsoft’s documentation lists Alpine, Azure Linux distroless and Ubuntu chiseled as the variants optimised for size. Chiseled Ubuntu images are a distroless style of image: they contain only what .NET needs, with no package manager and no shell, and they run as a non-root user by default.

The trade-offs to know about:

  • Globalization. The size-optimised images don’t include ICU or tzdata. They only work with apps configured for globalization invariant mode, unless you choose an -extra variant that includes those dependencies. If your API formats dates, currencies or culture-specific strings, test this carefully.
  • No shell. On chiseled images you can’t docker exec into a shell, and any RUN instruction or entrypoint script that relies on sh won’t work. That’s the point for security, but it changes how you debug.
  • Alpine uses musl instead of glibc. Most pure .NET apps are fine, but native dependencies can behave differently.

To use chiseled with invariant globalization, add this to the project file:

<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

and change the final stage to the chiseled tag:

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final

Rather than trusting anyone’s numbers, compare the variants for your own app with docker images after building each one.

Security basics: non-root user, no secrets, pinned tags

Since .NET 8, the official Linux images include a non-root user called app, and they expose its UID (1654) through the APP_UID environment variable. The regular aspnet images still start as root unless you opt in; chiseled images are non-root by default. Opt in explicitly anyway. It documents intent, and Kubernetes’ runAsNonRoot check needs a numeric UID:

USER $APP_UID

Don’t create your own user called app. Microsoft documents that this conflicts with the built-in one.

Running as non-root is also why the default ASP.NET Core port in these images changed from 80 to 8080 in .NET 8: port 80 is privileged, 8080 isn’t. The images set ASPNETCORE_HTTP_PORTS=8080, so map host ports to 8080 (-p 5000:8080), not 80.

Two more rules:

  • No secrets in images. Connection strings, API keys and certificates belong in environment variables, mounted files or a secret store at runtime, never in COPY or ENV instructions. Anything baked into a layer can be extracted from the image.
  • Pin your tags. 10.0 moves forward with each patch release, which is usually what you want for security fixes. For fully reproducible builds, pin a specific patch tag or an image digest, and update it deliberately (Dependabot or Renovate can automate this).

Health checks: /health in ASP.NET Core and Docker HEALTHCHECK

ASP.NET Core has built-in health checks middleware. Register the services and map an endpoint:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks();

var app = builder.Build();

app.MapHealthChecks("/health");
app.MapGet("/", () => "API is running");

app.Run();

Health checks can also test dependencies such as your database, and you can expose separate liveness (is the process alive?) and readiness (can it take traffic yet?) endpoints by filtering checks with tags. Orchestrators like Kubernetes use those directly as probes.

Docker’s own HEALTHCHECK instruction needs a command to run inside the container. Microsoft’s docs show curl, but curl isn’t included in the .NET Linux images, and chiseled images don’t even have a shell to install it with. My workaround is to let the app check itself: if it’s started with a --healthcheck argument, it calls its own endpoint and exits with 0 or 1.

// At the very top of Program.cs, before building the web app
if (args.Contains("--healthcheck"))
{
    using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
    try
    {
        var port = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS")?.Split(';')[0] ?? "8080";
        var response = await http.GetAsync($"http://localhost:{port}/health");
        return response.IsSuccessStatusCode ? 0 : 1;
    }
    catch
    {
        return 1;
    }
}

With top-level statements, returning an int anywhere makes it the process exit code. Just make sure the normal path ends with await app.RunAsync(); return 0; so every code path returns a value. In the Dockerfile, use the exec form so no shell is needed:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD ["dotnet", "Api.dll", "--healthcheck"]

Here’s the complete Dockerfile:

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Api.sln ./
COPY src/Api/Api.csproj src/Api/
RUN dotnet restore src/Api/Api.csproj
COPY . .
RUN dotnet publish src/Api/Api.csproj -c Release -o /app/publish \
    --no-restore -p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD ["dotnet", "Api.dll", "--healthcheck"]
ENTRYPOINT ["dotnet", "Api.dll"]

Running locally with docker compose

For local development and integration tests, compose brings up the API and its database together, and depends_on with condition: service_healthy starts the API only once the database is ready:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - db-data:/var/lib/postgresql/data

  api:
    build: .
    ports:
      - "5000:8080"
    environment:
      ConnectionStrings__Default: "Host=db;Database=appdb;Username=app;Password=${DB_PASSWORD}"
      ASPNETCORE_ENVIRONMENT: Development
    depends_on:
      db:
        condition: service_healthy

volumes:
  db-data:

The password comes from a local .env file that’s excluded from Git. The double underscore in ConnectionStrings__Default is how ASP.NET Core configuration maps environment variables to nested keys. Run it with:

docker compose up --build
curl http://localhost:5000/health
docker inspect --format "{{.State.Health.Status}}" $(docker compose ps -q api)

The last command should report healthy once the start period has passed.

A few lines of discipline make the difference: a multi-stage build, restore-first caching, a small non-root runtime image, no secrets, and a real health signal. The Dockerfile above is the template I’d start any new ASP.NET Core API from.

Leave a Reply

Your email address will not be published. Required fields are marked *

Keep reading

Related articles