Docker Build Process for .NET Applications

Posted by

It’s painless to get started with Docker in .NET, thanks to the built-in tooling that comes with Visual Studio. At the core of that tooling is the Dockerfile that is auto-generated with the ASP.NET Core and Worker Service templates. Understanding the inner workings of the Dockerfile comes in handy when you’re trying to make changes to it, as I recently found out for myself.

To that end, this article breaks down the ASP.NET Core Dockerfile by looking at how each of the steps works individually and within the whole Docker ecosystem.

The Dockerfile

Here’s what the Dockerfile looks like when you create a new project in Visual Studio:

FROM AS base

FROM AS build
COPY ["DockerExplained/DockerExplained.csproj", "DockerExplained/"]
RUN dotnet restore "DockerExplained/DockerExplained.csproj"
COPY . .
WORKDIR "/src/DockerExplained"
RUN dotnet build "DockerExplained.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "DockerExplained.csproj" -c Release -o /app/publish

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

At first glance, it looks like any other script that restores, builds and publishes an application. Surprisingly, there are a large number of Docker concepts in action within this file. Let’s start looking at them by first talking about images.


Docker is built around the concept of layering images. Each layer builds on the previous layer by adding dependencies, packages, runtimes and the like. Eventually, a final image is produced that contains the compiled application and everything it needs to run.

Two concepts to be aware of are those of the base image and parent image. The parent image of the Dockerfile above is aspnet:3.1-buster-slim, as indicated in the FROM statement.

If you were to go look at aspnet:3.1-buster-slim‘s Dockerfile, you’d find its parent is runtime:3.1-buster-slim, which in turn has runtime-deps:3.1-buster-slim as its parent, which descends from amd64/debian:buster-slim. The Debian image has something called scratch as its parent. Any image that inherits from scratch is called a base image.

Each layer of the inheritence hierarchy above adds something to the mix that’s needed to run an ASP.NET Core application:

  • amd64/debian installs the operating system that the application will run on.
  • runtime-deps sets the ASPNETCORE_URLS variable to http://+:80, which ASP.NET Core uses to determine which port to listen on.
  • runtime installs the .NET Core runtime.
  • aspnet installs ASP.NET Core.

There are two different parent images used in the Dockerfile above: aspnet:3.1-buster-slim and sdk:3.1-buster. The ASP.NET Core image is used to run the application. The SDK image is used only to build and publish the application. It contains more tooling is therefore larger than the ASP.NET Core image.

A Docker image is composed of a name and an optional tag. In the case of the aspnet:3.1-buster-slim image, the name is aspnet and the tag is 3.1-buster-slim.

  • aspnet gives you an indication of what’s in the image. In this case, the runtime for ASP.NET Core.
  • The 3.1-buster-slim tag indicates two things:
    • 3.1 tells you that it’s running .NET Core 3.1.
    • buster-slim refers to the variant of Debian Linux that it’s running.

There is nothing in a Dockerfile to indicate what its name or tags will be once it is succesfully built. The name and tags are specified when you run the docker build command.

Notice how some of the images in the Dockerfile have forward slashes in them (dotnet/core/aspnet, amd64/debian). The slashes are there to create a directory structure, if needed. Microsoft use this feature to logically separate the .NET Core images from the .NET Framework images. The ASP.NET and SDK images for .NET Core are both in the dotnet/core directory, whereas the equivalent images for .NET Framework are in the dotnet/framework directory.


The Dockerfile is split into stages. Each stage is represented with a FROM...AS statement. Stages can be re-used throughout the Dockerfile. You can see this in action in the above Dockerfile:

  • The first stage, base, sets the working directory on the container and exposes port 80 for use by ASP.NET Core.
  • The second stage, build, restores any NuGet packages and builds the application.
  • The third stage, publish, runs the dotnet publish command, creating a publishable version of the application.
  • The final stage copies the published application onto the base stage that was created at the start of the Dockerfile. This way, the final image is kept as small as possible.

Building The Application

There is a lot of insight to be gleaned from the output of the Docker build command. Here’s a partial output of what happens when building the above Dockerfile:

Sending build context to Docker daemon  18.43kB
Step 1/16 : FROM AS base
 ---> e2cd20adb129
Step 2/16 : WORKDIR /app
 ---> Using cache
 ---> 02e10c384850
Step 3/16 : EXPOSE 80
 ---> Using cache
 ---> 537726e12a5b
Step 4/16 : FROM AS build
 ---> cfc38403c6bc
Step 5/16 : WORKDIR /src
 ---> Using cache
 ---> 54acea1f59d6
Step 6/16 : COPY ["DockerExplained/DockerExplained.csproj", "DockerExplained/"]
 ---> 65d5c9704db5
Step 7/16 : RUN dotnet restore "DockerExplained/DockerExplained.csproj"
 ---> Running in 7acad73d1fd7
  Restore completed in 1.52 sec for /src/DockerExplained/DockerExplained.csproj.
Removing intermediate container 7acad73d1fd7
 ---> 988f3f482544
Step 16/16 : ENTRYPOINT ["dotnet", "DockerExplained.dll"]
 ---> Running in 27d7da85d9ec
Removing intermediate container 27d7da85d9ec
 ---> 75bb0acdc7e8
Successfully built 75bb0acdc7e8

Each step in the output refers back to a line of the Dockerfile. There are a few concepts worth pointing out here:

  • Lines starting with ---> <hash> indicate the ID of the intermediate image layer that was created by executing the step. An intermediate image be re-used from one build to the next. You can see that happening in the ---> Using cache statements above.
  • Lines starting with ---> Running in <hash> indicate the ID of the container that’s being used to execute a step that could alter the state of the image. Every step generates an intermediate layer, but not every step needs to be run in an intermediate container. Commands such as COPY and RUN need to execute in a container while WORKDIR, FROM, and EXPOSE do not.
  • Removing intermediate container <hash>: Once a state-altering step has run, the intermediate container that was used to create the layer is no longer needed. It’s cleaned up to save on disk space, but the layer it created is still there.

Visual Studio Fast Mode

I started the previous section by showing the output of the docker build command. Normally, you’d be debugging from Visual Studio. By default, it builds a Dockerfile with something called container fast mode. Fast mode shortcuts the Dockerfile by executing only the base stage, and copying the compiled application into the container, without regard to anything else within the Dockerfile.

Fast mode is good for reducing build cycle time, but be aware of what’s happening. More than a few of my colleagues have been stumped when making a change in the Dockerfile that is frustratingly ignored by Studio. It’s possible to disable fast mode in Visual Studio at the expense of build time.

Wrap Up

Building a .NET Docker image, at the macro level, can seem a bit like magic. Once you start digging, you notice that there are many things going on on under the covers: optimizing the use of parent images, logically layering those images together, and building an application in stages.

The key takeaway is to have a good understanding of what’s going on in the background so that you can solve any potential issues you might encounter.

This was my first post about Docker and .NET. I’ll be building on this post in the near future and covering more about the topic.

Did you find the information you were looking for? Is there something missing from this article that would make it more useful to you? Do you have a follow-up question? Feel free to reach out to me on Twitter or LinkedIn. I’d be glad to help out.


  1. Hi,

    Thanks on a great article.
    I would like to know if you can explain how to do the above steps in a closed environment without internet.
    We have a local docker registry .
    Any help with be appreciate



    1. Hi Eldad, I’ve never tried to do that myself. What issue are you encountering? At minimum, you’d need an Internet connection to download the parent images the first time into your local registry, but I imagine there may be a few other roadblocks.


Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s