At the end of lesson 2.9.6, there was an uncomfortable number: your image was more than five times as big as the one from dotnet publish. In it were the whole SDK, all NuGet packages, the source code and tools like git, curl and PowerShell. The running Kladde needs none of that. In this lesson, you separate building and running. The result is an image that is smaller than the one from dotnet publish, contains no shell and can still handle cultures like en-US and the office's time zone.

Two stages in one Dockerfile

A Dockerfile may contain several FROM instructions. Each starts a new stage with a base image of its own. A later stage can fetch files from an earlier one with COPY --from=. Only the last stage goes into the finished image; all the others are workbenches that are cleared away after the build.

For the Kladde, there are two stages. In the branch feature/dockerfile-multistage, replace the Dockerfile from lesson 2.9.6:

dockerfile
# Stage 1: build with the full SDK.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY global.json Directory.Build.props ./
COPY src/Kladde.Core/Kladde.Core.csproj src/Kladde.Core/
COPY src/Kladde.Data/Kladde.Data.csproj src/Kladde.Data/
COPY src/Kladde.Ted/Kladde.Ted.csproj src/Kladde.Ted/
COPY src/Kladde.ServiceDefaults/Kladde.ServiceDefaults.csproj src/Kladde.ServiceDefaults/
COPY src/Kladde.Web/Kladde.Web.csproj src/Kladde.Web/
RUN dotnet restore src/Kladde.Web/Kladde.Web.csproj
COPY src/ src/
RUN dotnet publish src/Kladde.Web/Kladde.Web.csproj -c Release --no-restore -o /app

# Stage 2: run on the chiseled ASP.NET Core image with ICU and time zones.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Kladde.Web.dll"]
  • AS build gives the first stage a name. Up to the publish, it's the same as in lesson 2.9.6, with the same order for the cache.
  • The second FROM starts from scratch, on an image that contains only the ASP.NET Core runtime and no SDK.
  • COPY --from=build /app . fetches exactly one folder from the first stage: the published Kladde. Packages, source code and compiler stay behind.
  • USER $APP_UID: the .NET images set the environment variable APP_UID to 1654, the number of the user app. A number instead of a name has an advantage: platforms like Kubernetes can use it to verify that the container doesn't run as root, without looking into the image. The chiseled images run as app anyway; the line makes it visible and also holds if someone changes the base image.
  • EXPOSE 8080 is the note from lesson 2.9.6 for people and tools. The port itself is set by the base image with ASPNETCORE_HTTP_PORTS=8080. Port 8080 instead of 80 has a reason: on Linux, only root may open ports below 1024.

Build as usual from the Kladde's main folder:

PowerShell
docker build -f src/Kladde.Web/Dockerfile -t kladde-web:multistage --progress=plain .
docker image ls --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" kladde-web
REPOSITORY   TAG          SIZE
kladde-web   multistage   245MB
kladde-web   dockerfile   1.72GB
kladde-web   0.9.0        314MB

From 1.72 gigabytes down to 245 megabytes, and also smaller than the image from dotnet publish, which builds on the full Ubuntu image aspnet:10.0. The numbers come from an example Kladde; yours differ.

If you want to build only the first stage, for example to look for an error in it, say so with --target build. Exercise 2 makes use of that.