Writing a good Dockerfile for a .NET service: multi-stage build, chiseled image, non-root user

.NET Microservices, Performance & Docker November 13, 2026 dockerfile

An image is the immutable package (application, runtime, OS layers).

docker
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# ======================================================================
# Q: What makes a good Dockerfile for a .NET service?
#
# An image is the immutable package (application, runtime, OS layers). A
# container is a running instance of the image.
# - Multi-stage build: the SDK (~800 MB) stays in the build stage. The final
#   image contains only the runtime.
# - Chiseled images: no shell and no package manager, so a much smaller attack
#   surface and fewer CVEs.
# - A .dockerignore with bin/, obj/, .git, *.user, so unrelated files or secrets
#   don't end up in the build context.
# - No secrets in the image: not in ENV, not in appsettings.json. The layer
#   history can be read by anyone who has the image.
# - Tag = commit SHA, never :latest. Build once, and deploy the same image to
#   every environment (Q14).
# - Without a Dockerfile: dotnet publish -t:PublishContainer (.NET 8+) builds an
#   image directly from the SDK.
# ======================================================================

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# 1. Project files first: the restore layer stays cached as long as the packages don't change
COPY ["Shop.sln", "Directory.Packages.props", "./"]
COPY ["src/Shop.Api/Shop.Api.csproj", "src/Shop.Api/"]
COPY ["src/Shop.Domain/Shop.Domain.csproj", "src/Shop.Domain/"]
COPY ["src/Shop.Infrastructure/Shop.Infrastructure.csproj", "src/Shop.Infrastructure/"]
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore src/Shop.Api/Shop.Api.csproj

# 2. Then the code
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish src/Shop.Api/Shop.Api.csproj -c Release -o /app --no-restore

# 3. Runtime image: small, no SDK, no shell (chiseled)
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID                    # non-root
EXPOSE 8080                      # since .NET 8 the default port is 8080, not 80
ENTRYPOINT ["dotnet", "Shop.Api.dll"]