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.
# 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 stage | Multi-stage | |
|---|---|---|
| Indicative size | About 800 MB | About 220 MB |
| Contains the SDK and compiler | Yes | No |
| Contains your source code | Yes, readable by anyone who can pull it | No, only the compiled output |
| Software to keep patched | Everything the SDK image installs | The runtime image and your dependencies |
| Build time | Similar — the same compilation happens | Similar, plus a copy between stages |
| Dockerfile complexity | Shorter and easier to follow | Two 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.
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-checkThink 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.
Saved in this browser only.