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.
FROM 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
| Setting | Value in .NET 10 images | Consequence |
|---|---|---|
ASPNETCORE_HTTP_PORTS | 8080 | Kestrel listens on 8080, all interfaces; no root needed for a port above 1024 |
APP_UID | 1654, user app | The user exists, but the default images still start as root until you write USER $APP_UID |
| OS | Ubuntu 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.dllmakes/bin/shPID 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
appuser getsUnauthorizedAccessExceptionwriting to/app. Write to/tmpor a mounted volume. - A stale base image.
10.0is 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
# 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.
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/.idea/
**/*.user
**/TestResults/
**/node_modules/
.git/
.github/
**/.env
**/*.pfx
**/appsettings.*.local.json
Dockerfile*
docker-compose*.ymlBuild 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.
# 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.0Local 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.
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