In this post, we will understand what Docker is, how it works and how to create a complete environment to deploy ASP.NET Core applications in Docker containers.
Keeping a web application running with all its dependencies involves several configurations and adjustments, which can be challenging. To help with this process, we can use Docker—a formidable tool that encapsulates the application, its dependencies and configurations in standardized containers.
In this post, we will understand how Docker works and how to start integrating its functionalities into an ASP.NET Core application.
Docker is an open-source platform designed to make it easier to create, test and deploy applications in isolated environments called containers.
Docker containers are isolated environments that allow you to run applications and their dependencies independently of the operating system. They act as “boxes” that contain everything an application needs to function, such as libraries, environment variables, configurations and the code itself. This allows the application to run in any environment, be it on a desktop, on production servers or in the cloud, without complications or complex configurations, which is its main advantage.
Developers can use Docker to create application images, which are ready-to-run snapshots and easily share them with other developers or operations teams. This improves consistency, reduces compatibility issues and simplifies the process of deploying and maintaining complex applications.
In addition, Docker integrates well with CI/CD (Continuous Integration and Delivery) systems, making it a popular tool among teams working with microservices and highly scalable environments. For these reasons, it has become an important choice for developers and DevOps professionals looking for agility and efficiency in software development and delivery.
Containers and virtual machines (VMs) are both technologies that isolate applications and their dependencies. The two provide similar benefits, but they do so in different ways.
Containers form an abstraction at the application layer that packages code and its dependencies together. A single machine can contain multiple containers and share the operating system kernel, allowing each to run as an isolated process. Unlike VMs, containers take up very little disk space (usually tens of MBs in size) and can handle more applications at the same time.
In contrast, virtual machines provide an abstraction of physical hardware. The hypervisor allows multiple VMs to run on a single machine. Each VM is composed of a complete copy of an operating system, including the application, binaries and libraries, taking up tens of GBs. This entire process can slow down VM startup.
As can be seen in the image above, unlike virtual machines, containers do not need a hypervisor to virtualize the operating system, as they directly share the host operating system kernel.
The absence of a hypervisor reduces overhead, as there is no need to simulate hardware or load a full operating system inside each instance. This lighter structure allows containers to start faster and take up less disk space and memory compared to virtual machines.
A Docker image, also known as a container image, is a “package” that contains everything an application needs to run inside a container.
They include the application’s source code, libraries, dependencies, environment variables and any configurations needed for the application to function properly. These images are created through a standalone executable file called a Dockerfile. In simple terms, a Docker image is a template or blueprint that defines what the container should execute.
Each Docker image is built through layers. These layers are made up of instructions written in the Dockerfile file, which describe each step required to build the application. Each command in the Dockerfile creates a new layer.
Layers allow images to be built and run efficiently because Docker only stores the differences between each layer. Thus, if several images share the same layer, such as a base library for example, Docker only stores that layer once, avoiding unnecessary overhead.
Below is an example of a Dockerfile for an ASP.NET Core application. Note that each command creates a new layer.
# Step 1: Build image
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
# Sets the working directory
WORKDIR /app
# Copies project files and restores dependencies
COPY *.csproj ./
RUN dotnet restore
# Copy all remaining files and build the application
COPY . ./
RUN dotnet publish -c Release -o /app/out
# Step 2: Run Image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
# Sets the working directory
WORKDIR /app
# Copies files from the build step to the final image
COPY --from=build-env /app/out .
# Exposes the port used by the application
EXPOSE 80
# Defines the input command to start the application
ENTRYPOINT ["dotnet", "ApplicationName.dll"]
A Docker image is a static blueprint, while a container is a live instance of that image. In simple terms, the container is the execution of the image. In this sense, multiple containers can be created from the same image.
For example, an image could contain a web application such as an API or worker service, and you can launch as many containers as you need from that image, with each container operating as an independent instance of the server.
To put Docker into practice, we will create a web application in ASP.NET Core and then we will configure Docker images and containers. So the first step is to install Docker on your computer.
You can use the Docker Desktop tool that installs and configures what you need to have the local Docker server on your machine. To do this, simply access the Docker Desktop download page and install the version compatible with your operating system. Currently available for Windows, Mac (macOS) and Linux.
Important: Docker Desktop has restrictions for commercial use. Therefore, before installing it, check whether or not you need a license to use it.
To practice using Docker, we will create an ASP.NET Core application using the MVC (Model, View, Controller) template to manage a task schedule.
To keep the focus on the Docker configurations, in this first part we will use an in-memory database, so no extra configurations will be necessary for the application to work.
You can access the project source code in this GitHub repository: Reminder List.
To create the base application, you can use the following .NET command:
dotnet new mvc -o ReminderList -au none
This command will create an application using the MVC template. In addition, the -au none
command prevents authentication resources from being added, as they are not relevant in this context.
Now open a terminal at the root of the application and run the commands below to download the NuGet packages that will be needed.
dotnet add package Microsoft.EntityFrameworkCore. InMemory
dotnet add package Microsoft. EntityFrameworkCore
Next, inside the folder “Models” create the following class:
namespace ReminderList.Models;
public class TodoItem
{
public int Id { get; set; }
public string Title { get; set; }
public bool IsCompleted { get; set; } = false;
}
Then, create a new folder called “Data” and, inside it, create the following class:
using Microsoft.EntityFrameworkCore;
using ReminderList.Models;
namespace ReminderList.Data;
public class TodoContext : DbContext
{
public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }
public DbSet<TodoItem> TodoItems { get; set; }
}
Inside the folder “Controller” add the following controller:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ReminderList.Data;
using ReminderList.Models;
namespace ReminderList.Controllers
{
public class TodoController : Controller
{
private readonly TodoContext _context;
public TodoController(TodoContext context)
{
_context = context;
}
public async Task<IActionResult> Index()
{
return View(await _context.TodoItems.ToListAsync());
}
public IActionResult Create()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(TodoItem todoItem)
{
if (ModelState.IsValid)
{
_context.Add(todoItem);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(todoItem);
}
public async Task<IActionResult> Edit(int? id)
{
if (id == null)
{
return NotFound();
}
var todoItem = await _context.TodoItems.FindAsync(id);
if (todoItem == null)
{
return NotFound();
}
return View(todoItem);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, TodoItem todoItem)
{
if (id != todoItem.Id)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(todoItem);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!TodoItemExists(todoItem.Id))
{
return NotFound();
}
else
{
throw;
}
}
return RedirectToAction(nameof(Index));
}
return View(todoItem);
}
public async Task<IActionResult> Delete(int? id)
{
if (id == null)
{
return NotFound();
}
var todoItem = await _context.TodoItems
.FirstOrDefaultAsync(m => m.Id == id);
if (todoItem == null)
{
return NotFound();
}
return View(todoItem);
}
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var todoItem = await _context.TodoItems.FindAsync(id);
_context.TodoItems.Remove(todoItem);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
private bool TodoItemExists(int id)
{
return _context.TodoItems.Any(e => e.Id == id);
}
}
}
Now, let’s create the views files. Inside the “Views” folder, create a new folder called “Todo” and, inside that, add the following views:
@model IEnumerable<ReminderList.Models.TodoItem>
<h2>Todo List</h2>
<p>
<a asp-action="Create">Add New Task</a>
</p>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Is Completed</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Title</td>
<td>@item.IsCompleted</td>
<td>
<a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
<a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
</td>
</tr>
}
</tbody>
</table>
@model ReminderList.Models.TodoItem
<h2>Create New Task</h2>
<form asp-action="Create">
<div class="form-group">
<label asp-for="Title"></label>
<input asp-for="Title" class="form-control" />
</div>
<div class="form-group">
<label asp-for="IsCompleted"></label>
<input asp-for="IsCompleted" class="form-check-input" type="checkbox" />
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
@model ReminderList.Models.TodoItem
<h2>Edit Task</h2>
<form asp-action="Edit">
<input type="hidden" asp-for="Id" />
<div class="form-group">
<label asp-for="Title"></label>
<input asp-for="Title" class="form-control" />
</div>
<div class="form-group">
<label asp-for="IsCompleted"></label>
<input asp-for="IsCompleted" class="form-check-input" type="checkbox" />
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
@model ReminderList.Models.TodoItem
<h2>Delete Task</h2>
<div>
<h4>Are you sure you want to delete this task?</h4>
<div>
<p>
<strong>Title:</strong> @Model.Title
</p>
<p>
<strong>Is Completed:</strong> @Model.IsCompleted
</p>
</div>
<form asp-action="DeleteConfirmed">
<input type="hidden" asp-for="Id" />
<button type="submit" class="btn btn-danger">Delete</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
</div>
Then, in the Program.cs file, replace the code by following:
using Microsoft.EntityFrameworkCore;
using ReminderList.Data;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://*:80");
// Add services to the container.
builder.Services.AddControllersWithViews();
// Configure the in-memory database
builder.Services.AddDbContext<TodoContext>(options =>
options.UseInMemoryDatabase("TodoList"));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
The code builder.WebHost.UseUrls("http://*:80");
defines the URL and port on which the ASP.NET Core server will listen for HTTP requests when running in the Docker container.
The next step is to create the file that will contain the steps for creating the Docker image.
In the application root directory, create a file called Dockerfile
. It doesn’t need an extension, just use exactly that name.
Then, open the file with a text editor, and add the following code to it:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /app
COPY *.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
ENV ASPNETCORE_ENVIRONMENT=Development
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 80
ENTRYPOINT ["dotnet", "ReminderList.dll"]
Now let’s analyze each part of this code.
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
: Sets the base image (.NET 9.0 SDK) for the build step, including compilation tools, dependency restoration and application publishing.
WORKDIR /app
: Sets the working directory to /app inside the container, where all the commands below will be executed.
COPY *.csproj ./
: Copies the application’s .csproj (project) file to the container’s /app
working directory. This allows only the files needed to restore dependencies to be copied initially, avoiding the need to rebuild dependencies every time a code file changes.
RUN dotnet restore
: Restores the project dependencies, downloading the NuGet packages required for the application. This process helps to take advantage of the Docker cache for restoration.
COPY . ./
: Copies the entire contents of the current directory to the container, including the source code.
RUN dotnet publish -c Release -o /app/publish
: Compiles and publishes the application in Release mode to the /app/publish directory, creating an optimized, production-ready version.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
: Sets the base image for the final step, which uses the ASP.NET Core 9.0 runtime.
ENV ASPNETCORE_ENVIRONMENT=Development
: Sets the ASPNETCORE_ENVIRONMENT environment variable to Development, indicating that the application will run in development mode.
WORKDIR /app
: Sets the working directory of the final step to /app
.
COPY --from=build /app/publish .
: Copies the published folder (/app/publish
) from the build step to the current /app
directory in the final step.
EXPOSE 80
: Exposes port 80 of the container so that it can receive HTTP connections.
ENTRYPOINT ["dotnet", "ReminderList.dll"]
: Sets the entry point for the application. This specifies that when the container starts, it will execute the dotnet ReminderList.dll
command, which will launch the ASP.NET Core application executable.
Now that we understand what each step of the Dockerfile means, we can run the command that will use that file and create the docker image.
So, in the root of the application, open a terminal and run the following command:
docker build -t todoapp .
docker build
: This command builds a Docker image based on the Dockerfile found in the current directory represented by the dot (.).
The -t flag
(short for “tag”) allows you to give a name to the image that will be built, which in this case is todoapp. This helps identify the image in the local Docker repository.
Below you can see the result of the execution and if everything went well.
Now you can check the newly created image in Docker Desktop.
With the image ready, we can create the docker container that will use the image to create an instance of our application.
So, in the application root directory, run the following command:
docker run -d -p 8080:80 --name todoapp-container todoapp
Let’s analyze each of these commands in detail:
docker run
: Starts a new container based on a Docker image.
-d
: Stands for detached mode. The container will run in the background, allowing you to continue using the terminal while the container runs.
-p 8080:80
: Maps a host port to the container port, where 8080 is the port on your computer (host) that will be used to access the application, while 80 is the port exposed by the container (as specified in the Dockerfile by the EXPOSE 80 command).
--name todoapp-container
: Gives a custom name to the container.
todoapp
: This is the name of the Docker image created previously. This image will be used to create the container.
The result of the commands can be seen below, as well as the container in Docker Desktop:
Note that this command outputs an ID. This is the ID of the container that was created and started by the docker run
command. It is a unique identifier generated by Docker for the newly created container.
Now that the container has been created, we can access the address http://localhost:8080/Todo to see the application online.
Docker is a tool that makes it easier to deploy applications and has many advantages when compared to traditional virtual machines since each Docker container can share the same operating system as the host. Furthermore, Docker’s mechanisms are extremely efficient, requiring only what is necessary to get the applications up and running, which makes the deployment process extremely agile.
In this post, we created a simple application to manage a list of tasks using the ASP.NET Core MVC template. Then, we created a Dockerfile and ran the commands to get the application running in a Docker container.
In the next part, we will add a database to the application, create an individual container for it and see how to verify that everything works correctly.