A Dockerfile for an ASP.NET Core API

Write a multi-stage Dockerfile for an ASP.NET Core 10 API that caches restore, ships no SDK and runs as the non-root app user on port 8080.

SyntaxFROM mcr.microsoft.com/dotnet/sdk:10.0 AS build RUN dotnet publish -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 COPY --from=build /app /app USER $APP_UID ENTRYPOINT ["dotnet", "/app/Orders.Api.dll"]

A production image needs the .NET runtime and your published output, nothing more. The SDK image, mcr.microsoft.com/dotnet/sdk:10.0, carries MSBuild, the compilers and NuGet and is several times larger than mcr.microsoft.com/dotnet/aspnet:10.0. A multi-stage Dockerfile builds in the SDK stage and copies only the publish folder into the runtime stage, so the shipped image has no compiler, no source and no NuGet cache.

Layer caching: restore before copying source

Every instruction produces a cached layer, and a changed input invalidates that layer and every layer after it. Copy only the .csproj files and Directory.Build.props / Directory.Packages.props first, run dotnet restore, then copy the source. A code change then reuses the restore layer; only a package change pays for a full restore. Put COPY . . before the restore and every commit downloads every package again. A BuildKit cache mount on /root/.nuget/packages keeps packages between builds even when the restore layer is invalidated.

Restore and publish must agree on the runtime identifier. Restoring without -a and then publishing with -a $TARGETARCH --no-restore fails with NETSDK1047, because the assets file has no target for that runtime; pass the same -a to both commands. Building the SDK stage on $BUILDPLATFORM and cross-publishing with -a $TARGETARCH produces arm64 and amd64 images without running the SDK under emulation.

What the base image already sets

SettingValue in .NET 10 imagesConsequence
ASPNETCORE_HTTP_PORTS8080Kestrel listens on 8080, all interfaces; no root needed for a port above 1024
APP_UID1654, user appThe user exists, but the default images still start as root until you write USER $APP_UID
OSUbuntu 24.04 (noble)10.0-alpine and 10.0-noble-chiseled tags for smaller variants

Use the numeric UID, not USER app. A pod with runAsNonRoot: true can only verify a numeric user; with a name the kubelet refuses to start it ("image has non-numeric user").

.dockerignore

The build context is the whole folder. Without a .dockerignore, your local bin/ and obj/ are copied in; obj/project.assets.json holds paths and runtime targets from your laptop and produces confusing restore and publish errors. .git, .env files and local secret files also land in the build stage, and from there in the build cache.

Production failure modes

  • Shell-form ENTRYPOINT. ENTRYPOINT dotnet Orders.Api.dll makes /bin/sh PID 1; it does not forward SIGTERM, so the API never drains and is killed when the grace period ends. Use the JSON array form.
  • Writing next to the binaries. Copied files are owned by root, so the app user gets UnauthorizedAccessException writing to /app. Write to /tmp or a mounted volume.
  • A stale base image. 10.0 is re-published for every patch and OS fix, but your image only gets them when you rebuild. Rebuild on a schedule, not only on commits.

Example

Example · dockerfile
# syntax=docker/dockerfile:1
ARG DOTNET_VERSION=10.0

# The SDK stage runs natively on the build machine and cross-publishes for the target.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG TARGETARCH
WORKDIR /src

# Project files only: this layer and the restore stay cached until a package changes.
COPY Directory.Build.props Directory.Packages.props ./
COPY src/Orders.Api/Orders.Api.csproj src/Orders.Api/
COPY src/Orders.Domain/Orders.Domain.csproj src/Orders.Domain/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore src/Orders.Api/Orders.Api.csproj -a $TARGETARCH

COPY src/ src/
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet publish src/Orders.Api/Orders.Api.csproj -a $TARGETARCH --no-restore \
        -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS final
WORKDIR /app
EXPOSE 8080
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "Orders.Api.dll"]

When to use it

  • Rebuild the orders API image in CI after a change to OrderEndpoints.cs and reuse the cached restore layer instead of downloading 180 packages again.
  • Ship an image that runs as UID 1654 so a cluster policy requiring runAsNonRoot admits the pod.
  • Produce linux/amd64 and linux/arm64 images from one Dockerfile on an x64 CI runner without running the SDK under emulation.

More examples

.dockerignore for a .NET solution

Excluding <code>bin/</code> and <code>obj/</code> stops host build output, with its laptop paths and runtime targets, from leaking into the container restore. Excluding <code>.git</code>, <code>.env</code> and certificate files keeps them out of the build context and the build cache, and a smaller context also makes every build start faster.

Example · text
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/.idea/
**/*.user
**/TestResults/
**/node_modules/
.git/
.github/
**/.env
**/*.pfx
**/appsettings.*.local.json
Dockerfile*
docker-compose*.yml

Build for two architectures, run, and check the user

<code>--platform</code> with two targets builds both images from one Dockerfile; because the SDK stage is pinned to <code>$BUILDPLATFORM</code>, only the final runtime stage is per-architecture. The <code>id</code> check is a cheap CI assertion that the <code>USER $APP_UID</code> line survived the last edit.

Example · bash
# One push, a manifest list for amd64 and arm64 nodes
docker buildx build --platform linux/amd64,linux/arm64 \
  -t registry.example.com/shop/orders-api:1.4.0 --push .

# Run locally on the image's port 8080
docker run --rm -p 8080:8080 -e ASPNETCORE_ENVIRONMENT=Staging \
  registry.example.com/shop/orders-api:1.4.0

# Confirm the process will not run as root
docker run --rm --entrypoint id registry.example.com/shop/orders-api:1.4.0
# uid=1654(app) gid=1654(app) groups=1654(app)

# See which layers changed between two builds
docker history registry.example.com/shop/orders-api:1.4.0

Local stack with Docker Compose

Compose runs the same image you ship, with the password read from the developer's shell or an <code>.env</code> file that <code>.dockerignore</code> keeps out of the image. <code>read_only: true</code> with a <code>tmpfs</code> on <code>/tmp</code> catches code that writes into the app folder before Kubernetes does.

Example · yaml
services:
  orders-api:
    build: .
    ports:
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ConnectionStrings__Shop: "Host=db;Database=shop;Username=shop;Password=${SHOP_DB_PASSWORD}"
    read_only: true
    tmpfs:
      - /tmp
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: ${SHOP_DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      retries: 10

Discussion

  • Be the first to comment on this lesson.