In Part 1 we saw how to run a simple .NET app on macOS against .NET 101.
Now, let's put this in a container.

First. I'm going to supply the entire Dockerfile. Then we'll walk through it.

FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base USER $APP_UID WORKDIR /app FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["HelloWorld.csproj", "./"] RUN dotnet restore "HelloWorld.csproj" COPY . . WORKDIR "/src/" RUN dotnet build "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "HelloWorld.dll"] 

Overview

Let's start with a brief overview.

We use a total of four containers. The idea is to keep each step of the process as clean as possible for the next step.

base

FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base USER $APP_UID WORKDIR /app 

FROM

FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base 

In this container, we pull in the runtime for .NET 10 via the FROM line2. We name this container base via the AS base at the end of the first line; that's how we'll refer to this container further down in the Dockerfile.

USER

USER $APP_UID 

The next thing we do is set USER to $APP_UID.

$APP_UID is an environment variable supplied by Microsoft that's baked into the .NET runtime containers. This variable holds the numeric value of the user ID (UID) for the built-in, non-root app account provided by Microsoft's .NET base images.

Executing commands inside this container as a less-privileged account is an inexpensive move that pays significant security dividends by limiting the blast radius when running processes in later containers that inherit from base.

WORKDIR

WORKDIR /app 

Finally, we set WORKDIR to /app. WORKDIR does what it says on the tin: it's the directory that will be the current directory (cd) until you tell Docker otherwise or any of your commands (including scripts ran by the Docker RUN command) changes the current directory.

USER and WORKDIR will be inherited wherever base is used.

build

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["HelloWorld.csproj", "./"] RUN dotnet restore "HelloWorld.csproj" COPY . . WORKDIR "/src/" RUN dotnet build "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/build 

FROM

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build 

Note that the FROM line does two things:

  1. It pulls in the Software Development Kit (SDK). Note that runtime is not pulled in.
  2. We name this container build.

ARG

ARG BUILD_CONFIGURATION=Release 

This line sets a Docker build argument. Docker build arguments are different from environment variables ENV in that ARGs cease to exist after the container is built. Said differently, you won't find BUILD_CONFIGURATION inside a container made from this image after the container is built3.

WORKDIR

WORKDIR /src 

Since we're going to build a .NET app inside this container, we'll put all of our files inside a src directory.

COPY

COPY ["HelloWorld.csproj", "./"] 

We only need the project file to pull in NuGet dependencies. Note that if you have a .sln file that contains multiple projects, you'd copy in the .csproj files along with the .sln file.

RUN

RUN dotnet restore "HelloWorld.csproj" 

Thia line executes the docker restore command on the project file. If you had a .sln you'd execute

RUN dotnet restore "HelloWorld.sln" 

This command will read the .csproj or .sln file and will download any NuGet packages needed.

COPY

COPY . . 

This command copies all files in the directory outside of the container into the container that are not excluded via .dockerignore. A partial list of files and directories I've excluded:

  • .vs/
  • .vscode/
  • .idea/
  • .git/
  • bin/
  • obj/

The only file that should get copied into the container is Program.cs.

WORKDIR (the second time)

WORKDIR "/src/" 

This line could have been written WORKDIR /src like it was the first time in this container. In other words. this is a variation on a theme.

For this project, we don't need this line. The reason for having a second WORKDIR line after copying files is if you were going to change into a subdirectory of the files that you just copied.

I left this line in for the "teachable moment".

RUN

RUN dotnet build "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/build 

This is the command that builds the project. Because it was several lines above, I'll remind you that $BUILD_CONFIGURATION's value is Release. The build output will be put in /app/build.

publish

FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 

FROM

FROM build AS publish 

Here is the first time in this Dockerfile that we inherit from an existing container. For us, this means that we'll have a container that already has the .NET SDK in it. We'll also have the HelloWorld.csproj file, the Program.cs file, the Newtonsoft.Json NuGet package and the build output.

For this simple project, we don't need to break up the build and publish steps into their own containers. But if we were working on a larger project or wanted to run tests between these steps, we now have a way to

  • Target specific stages independently without having to run the entire pipeline.
  • Isolate those processes and keep our final image small and secure.
  • Cache those intermediate layers so we don't have to duplicate work on subsequent builds.

Some ideas for future pursuit:

  • If you have tests that are flakey, you could run the entire test suite again without having to re-clone and re-compile your code. You just grab another instance of build and run your tests from there. (I imagine you'd add another container that is currently not part of the Dockerfile at the top of this post that you would run tests inside of.)
  • You could take advantage of pipelining in a larger project with a Continuous Integration (CI) environment4.

ARG

ARG BUILD_CONFIGURATION=Release 

Recall from our discussion earlier that args only live for the building of a given container whereas envs persist. Because we set up BUILD_CONFIGURATION as an ARG inside of build, we have to supply that value again here.

Now, it turns out that we could have written this as our first line of the entire Dockerfile

ARG BUILD_CONFIGURATION=Release 

and then the two lines that currently read

ARG BUILD_CONFIGURATION=Release 

would become

ARG BUILD_CONFIGURATION 

because you still have to opt-in to the argument in each container. If you did this, it would allow you to set/change the value in one place and that value woud propogate to all the places that use the variable/constant.

Because we only use the value twice in our project, it doesn't matter. But if we were working on a larger, more complicated project, this would be a Godsend.

RUN

RUN dotnet publish "./HelloWorld.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 

With .NET, publishing is like doing a pre-flight in a proper desktop publishing software: it puts all the files you need to run your project in one folder. As a reminder of the value of BUILD_CONFIGURATION we're setting up for a Release not a Debug configuration.

The /p:UseAppHost=false flag tells the .NET compiler not to generate a platform-specific executable file (like an .exe on Windows or a native binary on macOS/Linux) alongside your application's .dll file.

By default, .NET creates a small native launcher (the "AppHost") so you can run your app directly by typing its name. When you set this flag to false, you are telling the compiler to only output the cross-platform, portable .dll.

In a Docker container, this is standard practice because you typically start the application by explicitly calling the runtime (e.g., ENTRYPOINT ["dotnet", "HelloWorld.dll"]), making the native executable launcher completely unnecessary and the resulting container smaller.

final

FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "HelloWorld.dll"] 

FROM

FROM base AS final 

Here, we inherit from base. This gives us the .NET runtime.

WORKDIR

WORKDIR /app 

In our simple project, we don't need this line because the vaule didn't change between when it was set in base and now. However, if we were working on a more robust project...

COPY

COPY --from=publish /app/publish . 

Now we're really playing to the strengths of multi-stage container builds: we can copy files from a previous container even if we don't inherit from that container. In this case, we are copying the contents of /app/publish from the publish container and putting it in the /app directory of final.

ENTRYPOINT

ENTRYPOINT ["dotnet", "HelloWorld.dll"] 

Finally, when the final container starts, it will execute dotnet Hellworld.dll from the /app directory.

This is a value for value blog. If you got any value out of this post, please translate it to a number and send it back to me:

  1. This will work with .NET 8 and presumably 9 as well; I tested .NET 8 because that's what I was originally given to work with by Apple Intelligence (the artist formerly known as Siri), but moved to .NET 10 when I realized that I wasn't on the latest and greatest version of .NET. Also, Rider generated the Dockerfile and it was better than Apple Intelligence's two-stage Dockerfile. ↩

  2. In case you're wondering how Docker knows which "type" of container to use, the answer is "heuristics". There is a --platform flag you can pass to the docker build command along with an argument that has a the value of the platform you desire. Examples would be linux/arm64, linux/amd64 or windows/amd64. But if you don't pass the --platform flag, Docker will pull in an image that matches your host OS. ↩

  3. While it is true that you won't find BUILD_CONFIGURATION in a running container, the variable is permanently recorded in the image's build history (which you can view using docker history). Therefore, you should never use ARG to pass sensitive secrets like passwords or API keys. ↩

  4. In fact, there's something called BuildKit that will parse your Dockerfile and will trace through to find the minimal path to building or testing thus reducing clocktime for those operations. Greater efficiency happens at larger scales. ↩