Containerising a .NET Application
By the end of this lesson
Build an efficient production image for an ASP.NET Core app.
Everything the course has covered so far now converges on one file. The employees API is an ASP.NET Core project, and building an image for it means answering two questions: what does the compiler need, and what does the running application need? The answers are different, and that difference is the whole design.
Compiling needs the .NET SDK: the compiler, the build tools, the NuGet package cache and your source code. Running needs the .NET runtime and the compiled output. Nothing more. A reader who has been through the Multi-Stage Builds lesson will recognise the shape — build in one stage, copy the result into a clean second stage, ship only the second.
One term first, because it appears throughout. Publishing is the .NET step that compiles a project and gathers everything needed to run it into one folder: the application assemblies, its dependencies, and the configuration files. It is a separate command from building, and it is the one a container wants.
# ---------------------------------------------------------------------------
# Stage 1: build. This stage is thrown away; nothing here ships.
# ---------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy the project file on its own and restore packages. Because this layer
# depends only on the .csproj, editing a controller does not invalidate it.
COPY Anvi.Employees.Api/Anvi.Employees.Api.csproj Anvi.Employees.Api/
RUN dotnet restore Anvi.Employees.Api/Anvi.Employees.Api.csproj
# Now the source, which changes on almost every commit.
COPY Anvi.Employees.Api/ Anvi.Employees.Api/
RUN dotnet publish Anvi.Employees.Api/Anvi.Employees.Api.csproj \
--configuration Release \
--no-restore \
--output /app/publish
# ---------------------------------------------------------------------------
# Stage 2: runtime. This is the image that gets pushed and run.
# ---------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
# An account with no password, no shell and no home directory.
RUN groupadd --system --gid 64198 appuser \
&& useradd --system --uid 64198 --gid 64198 --no-create-home appuser
# Bring across the published output only, owned by that account.
COPY --from=build --chown=appuser:appuser /app/publish .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER appuser
ENTRYPOINT ["dotnet", "Anvi.Employees.Api.dll"]- Two FROM lines means two stages. Everything in the build stage — the SDK, the NuGet cache, your source — stays behind when the build finishes. Only what stage two contains ends up in the image.
- AS build and AS final name the stages so later instructions can refer to them. The names are yours to choose; these two are conventional.
- The first COPY takes the .csproj and nothing else. That is the layer-caching move: restore depends on the package list, the package list lives in the project file, and a code change leaves that file untouched. Rebuilds after a code edit skip the restore entirely and finish in seconds rather than minutes.
- --no-restore on publish tells .NET the packages are already there. Without it, publish restores again and the cached layer above was pointless.
- --output /app/publish puts the published folder somewhere predictable for the next stage to copy from. Release configuration is what you want for anything you are shipping; the default is Debug.
- groupadd and useradd create a fixed, known account rather than letting one be generated. The fixed UID matters when a volume is mounted, because file ownership on a volume is recorded as a number, not a name.
- COPY --from=build reaches into the first stage. --chown sets ownership as the files land, which is cheaper than copying and then running chown — that would write a second full copy of every file into a new layer.
- ASPNETCORE_HTTP_PORTS sets the port the application listens on. The official .NET images default to 8080 rather than 80, because a non-root process cannot bind to a port below 1024 on Linux. EXPOSE documents the port; it publishes nothing on its own.
- USER appuser comes after the COPY, because the copy needs permission to write into /app. Every instruction after it, and the container's own process, runs as that unprivileged account.
- ENTRYPOINT uses the exec form — a JSON array, not a shell string. The exec form makes your application process 1 inside the container, which is what lets it receive a stop signal directly. The Production Considerations lesson depends on that.
The restore-before-source ordering is worth one more paragraph, because it is the single largest difference between a build that takes 20 seconds and one that takes four minutes. Docker caches each layer and reuses it while the inputs to that layer are unchanged. Copy the whole project in one go and every layer after the copy is invalidated by any edit, including a comment. Copy the project file first and the restore layer survives until a package reference actually changes.
A solution with several projects follows the same rule with more lines: copy every .csproj (and the .sln, if the build uses it) into matching folders, restore once, then copy the source. It looks repetitive. It buys back time on every build for as long as the project exists.
A .dockerignore file next to the Dockerfile is the other half of this. Without it, bin, obj, .git and any local user-secrets file are all sent into the build context, which slows the build and can drop artefacts compiled on your machine into an image built for Linux. That mismatch produces failures that look like corruption rather than configuration.
Sizes here are indicative only. They move with the .NET version, the base image and what your application depends on, so measure your own with docker images rather than quoting these.
| Single stage, built on the SDK image | Multi-stage, runtime image at the end | |
|---|---|---|
| Base image on disk (indicative) | SDK, roughly 800 MB | ASP.NET Core runtime, roughly 220 MB |
| Final image on disk (indicative) | Roughly 1 GB once your build output is added | Roughly 250 MB, or roughly 120 MB on a chiselled base |
| What travels to production | Compiler, build tools, package cache, full source | Runtime plus the published output |
| Pull time on a new host | Slow, and repeated for every deployment to a cold machine | Noticeably faster, and the runtime layer is shared between versions |
| Things in the image that could be attacked | Everything above, plus whatever the SDK pulls in | A much smaller surface, covered in the next lesson |
| Build time | Similar — the compile work is identical | Similar, and rebuilds are faster because restore stays cached |
# Build from the repository root, where the Dockerfile and .dockerignore live
docker build -t anvi/employees-api:1.5.0 .
# Compare what you shipped against the SDK base image
docker images anvi/employees-api
docker images mcr.microsoft.com/dotnet/sdk
# Run it
docker run -d --name employees-api -p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Production \
anvi/employees-api:1.5.0
# The process inside should not be root
docker exec employees-api id
# Change one line of C# and rebuild. Watch which layers say CACHED
docker build -t anvi/employees-api:1.5.1 .- The trailing dot is the build context: the directory Docker sends to the builder. Run this from the folder that holds the Dockerfile and the .dockerignore file.
- docker images gives you the real numbers for your project. Quote those rather than the indicative figures in the table above.
- docker exec ... id prints the user and group the container's processes run as. Expect uid=64198(appuser). Seeing uid=0(root) means the USER instruction is missing or is in the wrong stage.
- The second build is the one that proves the caching worked. The restore layer should report CACHED while the publish layer runs again. If restore re-runs after a code-only change, the COPY ordering has slipped.
Summary
- Compiling needs the SDK; running needs the runtime — two stages let you ship only the second
- Copy the project file and restore before copying source, so a code change does not invalidate the restore layer
- A .dockerignore file keeps bin, obj and .git out of the build context
- Create a fixed non-root account, copy files with --chown, and put USER after the copy
- Use the exec form of ENTRYPOINT so your application is process 1 and receives stop signals directly
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Measure the difference yourself
Write a second Dockerfile for the same project that does everything in one SDK stage: copy the source, restore, publish, and set the entry point. Build it as employees-api:single.
Build the multi-stage version alongside it and compare both with docker images. Then edit one line in a controller and rebuild both, timing each.
Show solution
The multi-stage image will be a fraction of the size, because the SDK, the NuGet cache and your source are all absent from it. The exact ratio depends on your dependencies; the direction never varies.
The rebuild timing is the less obvious win. The single-stage version restores packages again, because the layer that restores sits below a COPY of the whole project. The multi-stage version reuses its restore layer and only republishes.
Both results come from one idea: a layer is reused while its inputs are unchanged, so put the things that rarely change earlier and the things that change constantly later. The size saving and the speed saving are two consequences of the same ordering decision.
# employees-api:single — for comparison only. Do not ship this.
FROM mcr.microsoft.com/dotnet/sdk:9.0
WORKDIR /src
COPY . .
RUN dotnet publish Anvi.Employees.Api/Anvi.Employees.Api.csproj \
--configuration Release --output /app/publish
WORKDIR /app/publish
ENV ASPNETCORE_HTTP_PORTS=8080
ENTRYPOINT ["dotnet", "Anvi.Employees.Api.dll"]Think about it
Think about it
A colleague suggests removing the runtime stage and instead running dotnet run inside the SDK image in production, on the grounds that it works and saves maintaining two stages.
Set out what the team gives up, and name one situation where running on the SDK image is the right call.
Show solution
What they give up: a small image, a fast pull on a cold host, and a runtime with no compiler in it. They also give up build determinism, because dotnet run compiles at start-up, so the thing that runs was produced on the production host rather than in the build pipeline you can inspect. A start-up failure then looks like an outage rather than a failed build.
There is a real cost the other way too, and it is worth naming: two stages mean a Dockerfile that is longer and a base image you have to keep patched separately from the SDK. That is a maintenance cost, not a technical obstacle.
The situation where the SDK image is right is development. A container that mounts your source and runs dotnet watch gives you a rebuild on save without installing .NET on the host. That is a development tool, so it belongs in a Compose override file rather than in the image you push.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.