ASP.NET 8 Web API Dockerfile breakdown

Jakub Rzepka
5 min readAug 4, 2024

--

Let’s dive into all lines and stages for the example .net app Dokcerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080

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

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

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

Step 1: Setting up the base image

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
  1. FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base:
    - We start by pulling the official ASP.NET Core runtime image for version 8.0. This image is optimized to run our app.
    - We name this stage base for easy reference later.
  2. USER app:
    - Switch to a non-root user-named app for better security. Running containers as a non-root user is a best practice to minimize potential security risks.
  3. WORKDIR /app:
    - Set the working directory inside the container to /app. This is where our application will reside.
  4. EXPOSE 8080:
    - Inform Docker that the container will listen on port 8080 at runtime. This doesn’t actually publish the port, but it’s good documentation.

Step 2: Building the application

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src/aspnetapp
COPY ["./aspnetapp.csproj", "."]
RUN dotnet restore "./aspnetapp.csproj"
COPY . .
RUN dotnet build "./aspnetapp.csproj" -c $BUILD_CONFIGURATION -o /app/build
  1. FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build:
    - Pull the .NET SDK image, which contains tools needed to build and compile our app. This is the build stage.
  2. ARG BUILD_CONFIGURATION=Release:
    - Define a build argument to specify the build configuration. By default, it’s set to Release.
  3. WORKDIR /src/aspnetapp:
    - Set the working directory inside the container to /src/aspnetapp.
  4. COPY [“./aspnetapp.csproj”, “.”]:
    - Copy the project file (aspnetapp.csproj) into the container. This allows us to restore dependencies without copying the entire source code yet.
  5. RUN dotnet restore “./aspnetapp.csproj”:
    - Restore the dependencies specified in the project file. This step leverages Docker’s layer caching, so it only runs when the project file changes.
  6. COPY . .
    -
    Copy the remaining source code into the container. Now, our entire project is available for building.
  7. RUN dotnet build “./aspnetapp.csproj” -c $BUILD_CONFIGURATION -o /app/build:
    - Build the project using the specified configuration (default is Release). The output is placed in /app/build.

Step 3: Publishing the application

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./aspnetapp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
  1. FROM build AS publish:
    - Start a new stage named publish, based on the build stage.
  2. RUN dotnet publish “./aspnetapp.csproj” -c $BUILD_CONFIGURATION -o /app/publish /p=false:
    - Publish the application. This step creates a self-contained set of files in /app/publish optimized for deployment. The UseAppHost=false parameter is used to avoid creating an OS-specific executable, which isn't necessary for our containerized app.

Step 4: Creating the final image

dockerfileCopy code
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]
  1. FROM base AS final:
    -Start the final stage of our Dockerfile, based on the base stage. This will be the image we run.
  2. WORKDIR /app:
    - Ensure the working directory is set to /app.
  3. COPY — from=publish /app/publish .:
    - Copy the published files from the publish stage into the current directory (/app) of the final image.
  4. ENTRYPOINT [“dotnet”, “aspnetapp.dll”]:
    - Define the command to run when the container starts. Here, we’re telling the container to run our app using dotnet.

🔥🔥🔥

Further investigation for the more curious 🤔.
Multistaging and
layer caching

Notice anything? We have two COPY commands. It might seem redundant at first glance, but let’s unpack why this is actually a smart move.

Understanding Docker’s layer caching

Docker builds images in layers. Each instruction in your Dockerfile creates a new layer, and Docker caches these layers to speed up future builds. When you make changes to your code and rebuild your image, Docker only needs to rebuild the layers that changed, reusing the cached layers that didn’t. This caching mechanism is key to making our builds faster and more efficient.

The two-step COPY strategy

Here’s why we split the COPY commands:

  1. First COPY ["./aspnetapp.csproj", "./"] and RUN dotnet restore:
  • This command copies just the project file(s) and restores dependencies.
  • By doing this, we can cache the dependencies separately. If you later change only the source code files and not the project file, Docker can reuse the cached layer for dependencies. This avoids re-running dotnet restore unnecessarily.

2. Second COPY . . and RUN dotnet build -o /app:

  • This command copies the rest of the files, including the source code.
  • This step is necessary to bring in all the source files after dependencies are restored. Changes to these files don’t affect the cached dotnet restore layer, keeping builds efficient.

Why NOT just one COPY?

You might think, why not simplify it to:

COPY . .
RUN dotnet restore

Here’s the catch: Any change in any file will invalidate the cache for the dotnet restore step, making it run again even if you’ve just tweaked a source file. This can be time-consuming, especially in large projects with many dependencies.

The performance boost

By splitting the COPY commands:

  • The dotnet restore step is only re-run if the project files change, which is less frequent than changes to the source code.
  • This makes subsequent builds faster because Docker can skip restoring dependencies if they haven’t changed.

In short, using two COPY commands leverages Docker’s caching mechanism to speed up builds and reduce redundant operations. It’s a small change with a big impact on efficiency.

Multi-stage builds: Taking it further

Now, let’s talk about multi-stage builds. Here’s a more advanced Dockerfile that uses multi-stage builds for a .NET application:

Why use base and build stages?

Multi-stage builds allow us to separate the build environment from the runtime environment, creating leaner final images and ensuring that we only include what’s necessary to run the application.

  • The base stage: Sets up the runtime environment using the .NET runtime image, which is smaller and optimized for running .NET applications.
  • The build stage: Uses the .NET SDK image to build the application, which includes all necessary tools and dependencies for building .NET apps.
  • The publish stage: Publishes the application to a specific directory, creating a self-contained set of files ready to run.
  • The final stage: Creates the final image using the base stage and the published output, ensuring a clean and efficient runtime environment.

Benefits of multi-stage builds

  1. Separation of concerns: Keep build and runtime environments separate.
  2. Smaller final image: Avoid including unnecessary build tools in the final image.
  3. Improved security: Reduce the attack surface by including only what’s needed to run the application.
  4. Caching and efficiency: Leverage Docker’s caching mechanism to improve build times.

It’s all about efficiency and leveraging Docker’s powerful caching mechanism. And if you’re looking to optimize even further, multi-stage builds are a great way to create lean, secure, and efficient Docker images.

🐳🐳🐳

--

--

Jakub Rzepka

.NET • Angular • Azure Certified DevOps/Developer