Docker 105 — Building a multilayer Docker image

Nitin Manju
6 min readAug 20, 2023

In the previous chapter, we understood the essential instructions required to build a Dockerfile, it's time we build a Dockerfile using the knowledge we have gained. Let us build a Dockerfile that builds and runs an API application using .NET, ASP.NET Core and C#, the API application will be used to manage a ToDo list.

This Dockerfile will do the following

  1. It should set up the base image for the container
  2. It should build the API application from the source code
  3. It should set up the startup logic for the API application (ENTRYPOINT)

Let's begin

If you have never worked with .NET and ASP.NET Core WebAPI before, that’s fine. Follow along, it’s super simple.

Important Note 1: You will require the dotnet (.NET) 7 SDK installed on your local machine, and I will be using the Visual Studio Code editor in this lecture.

Important Note 2: To keep the lecture short, we will not focus much on the application logic but instead focus more on the Dockerfile. You can get the full application code here. It is recommended that you clone it and move along with the chapter.

We will start by importing the base image which is the runtime for our API application. We will start by creating a blank Dockerfile and progressively updating it throughout the chapter.

Create the ASP.NET Core API application

Run each line in the same directory as the Dockerfile created above.

mkdir src
cd src
dotnet new webapi --name todo-api
cd todo-api

Create a new Dockerfile inside the todo-api directory and we can start adding the instructions. The content of the directory should look something like this:

Let us modify our application a bit to serve the purpose of a to-do application. Delete the Controllers/WeatherForecastController and the WeatherForecast.cs files. Add a new file under the Controllers directory, let's call it TodoController.cs and replace the content with the following:

using Microsoft.AspNetCore.Mvc;
namespace todo_api.Controllers;

[ApiController]
[Route("[controller]")]
public class TodoController : ControllerBase
{
private static readonly List<ToDo> _todoItems = new();

public TodoController() {}

[HttpGet(Name = "GetMyItems")]
public IEnumerable<ToDo> Get()
{
return _todoItems;
}

[HttpPost(Name = "CreateItem")]
public ToDo Post([FromBody] ToDo item)
{
var lastItem = _todoItems.LastOrDefault();
item.Id = lastItem == null ? 1 : lastItem.Id + 1;

_todoItems.Add(item);
return item;
}

[HttpPut("{id:double}", Name = "UpdateItem")]
public IActionResult Put([FromRoute] double id, [FromBody] ToDo item)
{
var found = _todoItems.Find(item => item.Id == id);
if (found == null)
return NotFound();

found.IsCompleted = item.IsCompleted;
return Ok(found);
}

[HttpDelete("{id:double}", Name = "DeleteItem")]
public IActionResult Delete([FromRoute] double id)
{
var found = _todoItems.Find(item => item.Id == id);
if (found == null)
return NotFound();

_todoItems.Remove(found);
return Ok(true);
}
}

public class ToDo
{
public long? Id { get; set; }
public string Title { get; set; } = "";
public bool IsCompleted { get; set; }
}

The new structure should look something like this:

Edit the Program.cs file to the following:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Let’s start working on the Dockerfile. We will create multiple layers for each step.

Step 1: Setup the base image

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base

Notice that we have used the “AS base” this is to set the image “mcr.microsoft.com/dotnet/aspnet:7.0” as the base runtime image which can be referenced later. We will set up a working directory, let's call it “app” and we will be using Port 80 for the endpoints. So let's expose this port.

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /api
EXPOSE 80

Now that we have set a base image, we are going to move the build phase into the Dockerfile.

Step 2: Setup the build phase

Add a new image from “mcr.microsoft.com/dotnet/sdk:7.0” SDK as build

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /api
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /build
COPY . .

Set the WORKDIR as “/build” and copy the source code to this current working directory using the COPY . . instruction. This will copy all the files from the current build context, which is the todo-api directory. Hence all the files and directories in the build context will be copied to the current WORKDIR location which is /build

Notice that we have added a new file called .dockerignore, this is a special file that helps docker identify the files and the directories it should ignore during the build time.

Note: If you have not cloned the project, take a look inside the file and create your own.

We can now run some dotnet commands inside the docker build process using the RUN instruction. Let us first restore the project using the dotnet restore command and then build the project using the dotnet build command. The updated Dockerfile should look like this.

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /api
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /build
COPY . .
RUN dotnet restore "todo-api.csproj"
RUN dotnet build "todo-api.csproj" -o /app/build

After building the project, we are now ready to publish the build, but before we do that, we will alias the build image with a new name as publish and then RUN the dotnet publish command. Let’s update the Dockerfile

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /api
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /build
COPY . .
RUN dotnet restore "todo-api.csproj"
RUN dotnet build "todo-api.csproj" -o /app/build

FROM build AS publish
RUN dotnet publish "todo-api.csproj" -c release -o /app/publish

The output of the publish command has been set to /app/publish within the Docker build layer.

Step 3: Setup the runtime/startup

We will now go back to the base image (set in Step 1) which will be used as the final runtime image. We will copy the published binaries into the /api directory and then execute the application dll using the ENTRYPOINT

This is the final stage, let's update the Dockerfile one last time and then build it.

# Setup
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /api
EXPOSE 80

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /build
COPY . .
RUN dotnet restore "todo-api.csproj"
RUN dotnet build "todo-api.csproj" -o /app/build

FROM build AS publish
RUN dotnet publish "todo-api.csproj" -c release -o /app/publish

# Runtime stage
FROM base AS final
WORKDIR /api
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "todo-api.dll"]

We have successfully created a Dockerfile with multiple layers, it's time to build and test it.

docker build .

The first build should take some time, however, consecutive builds should be much faster. The output should look something like this:

docker build log

We have exposed port 80 within the Dockerfile. Run the built image using docker run and map it to a port on the host machine using the -p flag as shown below.

Note: the image id will differ on your machine (duh!)

docker run -p 8090:80 276d784c925787a6148230f7b5b

We are running the container on port 8090 on the host machine which is mapped to port 80 inside the container. If everything is built right, you can navigate to the URL: http://localhost:8090/swagger/index.html to load the Swagger UI.

Swagger loaded from within the container

If you are familiar with Postman, then you can go ahead and test the API.

You have successfully created a Dockerfile from scratch. In the next chapter, We will learn about running multiple docker images. Hope you have enjoyed this post. Stay tuned for more Docker 101!

--

--