Skip to main content
ANVISoftware Solutions
Lesson 7 of 16Intermediate16 min

Multi-Stage Builds

By the end of this lesson

Build in one stage and ship a minimal runtime image from another.

Look at what a single-stage image actually contains: the .NET SDK, the C# compiler, your source code, the NuGet package cache, and the compiled application. Only the last of those runs in production.

A multi-stage build separates the two jobs. One stage compiles, using an image that has the tooling. A second stage starts from a small runtime image, copies the compiled output across, and becomes the image you ship. Everything left in the first stage is discarded.

This is the largest single reduction in image size available to you, and it costs a handful of extra lines.

Dockerfile — build with the SDK, ship with the runtime
Dockerfile
# syntax=docker/dockerfile:1

# ---------- build stage ----------
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

COPY EmployeesApi.csproj ./
RUN dotnet restore

COPY . ./
RUN dotnet publish -c Release -o /app/publish --no-restore

# ---------- runtime stage ----------
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app

# The only thing that crosses the boundary
COPY --from=build /app/publish ./

ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER $APP_UID

ENTRYPOINT ["dotnet", "EmployeesApi.dll"]
  • AS build names the first stage so later instructions can refer to it. The name is yours to choose.
  • The build stage is the ordering from the previous lesson, unchanged. Caching still works the same way within a stage.
  • The second FROM begins a new image from scratch. Nothing from the build stage exists here — not the source, not the SDK, not the package cache.
  • COPY --from=build reaches into the finished build stage and takes one directory. The path is a path inside that stage, so it has to match where publish wrote its output.
  • The aspnet image contains the ASP.NET Core runtime and no compiler. It can run the application and cannot build one, which is the point.
  • The official .NET runtime images define APP_UID as a ready-made non-root account, so USER $APP_UID switches to it without creating a user first.
  • ENTRYPOINT is relative to WORKDIR now, so the path is shorter than in the single-stage version.

The same application, packaged both ways:

 Single stageMulti-stage
Indicative sizeAbout 800 MBAbout 220 MB
Contains the SDK and compilerYesNo
Contains your source codeYes, readable by anyone who can pull itNo, only the compiled output
Software to keep patchedEverything the SDK image installsThe runtime image and your dependencies
Build timeSimilar — the same compilation happensSimilar, plus a copy between stages
Dockerfile complexityShorter and easier to followTwo stages and one cross-stage path to get right

Those sizes are indicative. They depend on the base images you pick, your dependencies and the platform, and they change as Microsoft publishes new images. Measure your own with docker images. The ratio is the durable part: shipping a build environment roughly quadruples what you deploy.

Smaller options exist below this. Alpine-based runtime images cut further, and self-contained or trimmed publishes on a runtime-dependencies base go smaller still. Each trades something away — a different C library, no shell for debugging, occasional trouble with native dependencies or reflection-heavy libraries. The SDK-to-runtime split is the change worth making first, because it is large and carries almost no risk.

Summary

  • A multi-stage build compiles in one image and ships from another
  • Only files copied with COPY --from cross the stage boundary
  • The final image holds the runtime and your compiled output, not the SDK or source
  • Indicative sizes for the employees API: roughly 800 MB single-stage against roughly 220 MB multi-stage
  • Deleting files in a later layer does not shrink an image, because layers are additive

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Convert and measure

Take the single-stage Dockerfile for the employees API and split it into a build stage on the SDK image and a runtime stage on the ASP.NET Core runtime image.

Build both versions with different tags and compare the SIZE column from docker images. Then start a container from the multi-stage image and confirm the API still answers.

Show solution

The runtime image should be a fraction of the single-stage one, and the application behaves identically, because the compiled output is byte-for-byte the same.

Running it afterwards is the part not to skip. The usual failure is a path mismatch between where publish wrote its output and what COPY --from asked for, and that only shows up at start-up.

Shell
docker build -t employees-api:single -f Dockerfile.single .
docker build -t employees-api:multi -f Dockerfile .

docker images employees-api

docker run -d --name api-check -p 8080:8080 employees-api:multi
curl http://localhost:8080/health
docker rm -f api-check

Think about it

Think about it

Someone suggests keeping one stage and adding a final instruction that deletes the SDK and the source code. Why does that not reduce the image size?

Show solution

Layers are additive and permanent. A layer that deletes a file records the deletion; it does not remove the earlier layer holding the file. The image still carries both, so the size stays roughly the same and the files remain retrievable from the image history.

A multi-stage build works because the second stage never contained those files. Nothing is deleted — the layers are simply not part of the final image.

This has a direct security consequence, covered later in the course: a credential added in an early layer and deleted in a later one is still in the image.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

In a multi-stage build, what from the build stage ends up in the final image?
Why is a runtime-based final image so much smaller than an SDK-based one?

Saved in this browser only.