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:
- It pulls in the Software Development Kit (SDK). Note that runtime is not pulled in.
- 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
buildand run your tests from there. (I imagine you'd add another container that is currently not part of theDockerfileat 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:
-
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
Dockerfileand it was better than Apple Intelligence's two-stageDockerfile. ↩ -
In case you're wondering how Docker knows which "type" of container to use, the answer is "heuristics". There is a
--platformflag you can pass to thedocker buildcommand along with an argument that has a the value of the platform you desire. Examples would belinux/arm64,linux/amd64orwindows/amd64. But if you don't pass the--platformflag, Docker will pull in an image that matches your host OS. ↩ -
While it is true that you won't find
BUILD_CONFIGURATIONin a running container, the variable is permanently recorded in the image's build history (which you can view usingdocker history). Therefore, you should never useARGto pass sensitive secrets like passwords or API keys. ↩ -
In fact, there's something called BuildKit that will parse your
Dockerfileand will trace through to find the minimal path to building or testing thus reducing clocktime for those operations. Greater efficiency happens at larger scales. ↩


