Skip to main content
ANVISoftware Solutions
Lesson 14 of 16Advanced22 min

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.

Dockerfile — the complete production image for the employees API
Dockerfile
# ---------------------------------------------------------------------------
# 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 imageMulti-stage, runtime image at the end
Base image on disk (indicative)SDK, roughly 800 MBASP.NET Core runtime, roughly 220 MB
Final image on disk (indicative)Roughly 1 GB once your build output is addedRoughly 250 MB, or roughly 120 MB on a chiselled base
What travels to productionCompiler, build tools, package cache, full sourceRuntime plus the published output
Pull time on a new hostSlow, and repeated for every deployment to a cold machineNoticeably faster, and the runtime layer is shared between versions
Things in the image that could be attackedEverything above, plus whatever the SDK pulls inA much smaller surface, covered in the next lesson
Build timeSimilar — the compile work is identicalSimilar, and rebuilds are faster because restore stays cached
Build it, check the size, and prove which account it runs as
Shell
# 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.

Dockerfile
# 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.

Why is the .csproj copied and restored before the rest of the source, rather than copying everything at once?
The final stage uses mcr.microsoft.com/dotnet/aspnet rather than the sdk image. What does the application lose by that choice?

Saved in this browser only.