Running .NET on Linux may be easier than you think

Creating .NET solutions on a Linux system has always presented difficulties due to Microsoft Visual Studio’s dependency on the Windows operating system. My experience with numerous .NET projects led me to investigate the feasibility of .NET development on Linux. This straightforward tutorial, centered around an ASP.NET MVC application integrated with SQL Server, aims to illustrate the streamlined and potent nature of .NET development on my preferred operating system.

Setting up the Development Environment

Our initial step involves ensuring that the .NET tools and SDK, tailored to our specific Linux distribution, are properly installed. We can accomplish this using Microsoft’s standard guide.

My ideal development environment comprises a windowed integrated development environment (IDE), a robust database management and query tool, the database itself, and tools for both building and deploying applications. I achieve robust functionality and a pleasant coding experience using the following tools:

Prior to proceeding with our sample application, ensure that these tools are correctly installed.

Project Structure Setup

Our sample application will demonstrate ASP.NET development and functionality by simulating various use cases for a hypothetical shoe store inventory management system. As is standard practice with new .NET applications, we’ll begin by creating a solution and adding a project to it. The .NET SDK CLI tools can be used to scaffold our new solution:

1
2
mkdir Shoestore && cd Shoestore
dotnet new sln

Next, let’s create an ASP.NET project that includes a clearly defined main class for simplicity, as this structure is likely familiar to ASP.NET developers. We’ll use the MVC pattern to create our project:

1
2
mkdir Shoestore.mvc && cd Shoestore.mvc
dotnet new mvc --use-program-main=true

Let’s now integrate the project into the solution:

1
2
3
# Go to the root of the solution
cd ..
dotnet sln add Shoestore.mvc/

We now have a basic solution and its associated ASP.NET project. Before moving on, it’s crucial to verify that everything builds successfully:

1
2
3
cd Shoestore.mvc/
dotnet restore
dotnet build

A good development practice is to encapsulate key services and the application runtime within Docker containers. This approach enhances both deployment ease and portability. To support our application, let’s create a simple Docker container.

Making the Application Portable

Docker images typically reference a parent Docker image as a foundation, inheriting essential components like the operating system and fundamental solutions, such as databases. Adhering to this Docker best practice, we’ll create both a Dockerfile and a Docker Compose file for proper service configuration. We’ll utilize Microsoft-published parent images as our base. Additionally, we’ll employ Docker stages to minimize our image size. Stages enable us to utilize the .NET SDK during application building, ensuring that the ASP.NET runtime is only required during application execution.

Create the Shoestore.mvc Dockerfile with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Shoestore\Shoestore.mvc\Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /shoestore
COPY Shoestore.mvc/*.csproj ./
# Restore project packages
RUN dotnet restore
COPY Shoestore.mvc/* ./
# Create a release build
RUN dotnet build -c Release -o /app/build

# Run the application and make it available on port 80
FROM mcr.microsoft.com/dotnet/aspnet:6.0
WORKDIR /app
EXPOSE 80
# Assets and views
COPY Shoestore.mvc/Views ./Views
COPY Shoestore.mvc/wwwroot ./wwwroot
COPY --from=build /app/build ./
ENTRYPOINT [ "dotnet", "Shoestore.mvc.dll" ]

Next, we’ll create the docker-compose.yml file within our solution’s root directory. Initially, it will only reference our application service’s .Dockerfile:

1
2
3
4
5
6
7
8
9
# Shoestore/docker-compose.yml
version: "3.9"
services:
  web:
    build:
      context: .
      dockerfile: Shoestore.mvc/Dockerfile
    ports:
      - "8080:80"

Let’s also configure our environment with a .dockerignore file to guarantee that only the build artifacts are copied to our image.

With our application service stubbed in and its execution environment ready, our next task is to create our database service and integrate it into our Docker configuration.

Setting up the Database Service

Integrating Microsoft SQL Server into our Docker configuration is straightforward, especially because we’re using a Microsoft-provided Docker image without modifications. Append the following configuration block to the bottom of the docker-compose.yml file to configure the database:

1
2
3
4
5
6
7
  db:
    image: "mcr.microsoft.com/mssql/server"
    environment:
      SA_PASSWORD: "custom_password_123"
      ACCEPT_EULA: "Y"
    ports:
      - "1433:1433"

In this configuration, ACCEPT_EULA prevents installation interruptions, and the ports setting allows the default SQL Server port to pass through without any translation. Our Compose file now encompasses both our application and database services.

Before we customize the application code, let’s ensure that our Docker environment is functioning as expected:

1
2
# From the root of the solution
docker compose up --build

If no errors occur during startup, our basic sample application should be accessible through a web browser at the local address http://localhost:8080.

Utilizing Code Generation Tools

Now comes the engaging part: customizing the application code and ensuring that application data is persistently stored in the Microsoft SQL Server database. We’ll utilize both the Entity Framework (EF) and .NET SDK tools to establish a connection between the application and the database. These tools will also help us scaffold the application’s model, view, controller, and EF-required configuration.

Before we can specify the necessary tools, we need to create a tool-manifest file:

1
2
3
# From the root of the solution

dotnet new tool-manifest

Let’s add the EF and SDK tools to this file using these simple commands:

1
2
dotnet tool install dotnet-ef
dotnet tool install dotnet-aspnet-codegenerator

To confirm that these tools are installed correctly, run dotnet ef. The appearance of a unicorn indicates a successful installation. Next, run dotnet aspnet-codegenerator to test the ASP.NET tools; the output should display a general CLI usage block.

With our tools ready, we can start building our application.

MVC: The Model

The first step in building our application is creating the model. Since this model will be incorporated into the database later, we’ll add the MS SQL Server and EF packages to our project:

1
2
3
4
5
cd Shoestore.mvc/
dotnet add package Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet restore

Next, we’ll create an EF database context object. This object defines which models are added to the database and provides our code with easy access to query data from the database.

Create a Data directory to store the EF-specific code and create the ApplicationDBContext.cs file with the following content:

1
2
3
4
5
6
7
8
9
// Shoestore/Shoestore.mvc/Data/ApplicationDBContext.cs
using Microsoft.EntityFrameworkCore;

namespace Shoestore.mvc.Data;

public class ApplicationDBContext : DbContext
{
  public ApplicationDBContext(DbContextOptions<ApplicationDBContext> options):base(options){}
}

Next, we’ll configure the database connection string, ensuring it matches the credentials defined in our Dockerfile. Set the content of Shoestore/Shoestore.mvc/appsettings.json to the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Shoestore": "Server=db;Database=master;User=sa;Password=custom_password_123;"
  }
}

With the database connection string configured and the database context coded, we can now write our application’s Main function. We’ll include database exception handling to simplify system debugging. Additionally, due to a .NET bug in the generated code that causes the Docker container to serve views incorrectly, we need to add specific code to our view service configuration. This code will explicitly define the file paths to our view location within the Docker image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using Microsoft.EntityFrameworkCore;
using Shoestore.mvc.Data;

namespace Shoestore.mvc;

// ...

    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        // Associate our EF database context and configure it with our connection string
        var connectionString = builder.Configuration.GetConnectionString("Shoestore");
        builder.Services.AddDbContext<ApplicationDBContext>(
            options => options.UseSqlServer(connectionString));
        // Middleware to catch unhandled exceptions and display a stack trace
        builder.Services.AddDatabaseDeveloperPageExceptionFilter();

        // Add services to the container.
        // ASP.NET has a known issue where the final built app doesn't know where the view
        // files are (in the Docker container). 
        // The fix is to specifically add view locations.
        builder.Services
            .AddControllersWithViews()
            .AddRazorOptions(options => {
                options.ViewLocationFormats.Add("/{1}/{0}.cshtml");
                options.ViewLocationFormats.Add("/Shared/{0}.cshtml");
            });

Within the same file, navigate to the IsDevelopment if statement and add an else statement with the following code to include a database migration endpoint in our system when it’s in development mode:

1
2
3
4
5
6
7
        // Configure the HTTP request pipeline.
        if (!app.Environment.IsDevelopment())
        {
            // Leave the contents of the if block alone. These are hidden for clarity.
        } else {
            app.UseMigrationsEndPoint();
        }

Let’s run a quick test to ensure that the newly added packages and source code edits compile without errors:

1
2
3
4
// Go to mvc directory
cd Shoestore.mvc
dotnet restore
dotnet build

Now, let’s populate the model with the necessary fields by creating the Shoestore.mvc\Models\Shoe.cs file:

1
2
3
4
5
6
7
8
namespace Shoestore.mvc.Models;

public class Shoe {
    public int ID { get; set; }
    public string? Name { get; set; }
    public int? Price { get; set; }
    public DateTime CreatedDate { get; set; }
}

EF generates SQL queries based on the associated model, its context file, and any EF code within our application. SQL results are then translated and returned to our code as needed. By adding our Shoe model to our database context, EF will know how to translate data between MS SQL Server and our application. Let’s do this in the database context file, Shoestore/Shoestore.mvc/Data/ApplicationDBContext.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using Microsoft.EntityFrameworkCore;
using Shoestore.mvc.Models;

namespace Shoestore.mvc.Data;

public class ApplicationDBContext : DbContext
{
  public ApplicationDBContext(DbContextOptions<ApplicationDBContext> options) : base(options) { }

    private DbSet<Shoe>? _shoe { get; set; }
    public DbSet<Shoe> Shoe {
        set => _shoe = value;
        get => _shoe ?? throw new InvalidOperationException("Uninitialized property" + nameof(Shoe));
    }
}

Finally, we’ll use a database migration file to integrate our model into the database. The EF tool generates a migration file tailored to MS SQL Server based on the database context and its associated model (i.e., Shoe):

1
2
cd Shoestore.mvc/
dotnet ef migrations add InitialCreate

We’ll hold off on running our migration until we have a controller and view in place.

MVC: The Controller and View

We’ll create our controller using the ASP.NET code generation tool. This powerful tool requires specific helper classes. We’ll use the Design style packages for the basic controller structure and its integration with EF. Let’s add these packages:

1
2
3
4
cd Shoestore.mvc\
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design && \
dotnet add package Microsoft.EntityFrameworkCore.Design && \
dotnet restore

Creating our default controller is now as simple as invoking the following command:

1
2
3
4
5
6
7
8
cd Shoestore.mvc\
dotnet dotnet-aspnet-codegenerator controller \
        -name ShoesController \
        -m Shoe \
        -dc ApplicationDBContext \
        --relativeFolderPath Controllers \
        --useDefaultLayout \
        --referenceScriptLibraries

When the code generator creates our controller, it also generates a simple view for that controller. With the foundation of our MVC structure in place, we’re ready to test everything.

Migration and Application Testing

EF migrations are usually straightforward, but Docker integration adds complexity. As a bonus exercise, you can delve into the intricacies of making these migrations work within our Docker solution. For now, our focus is on running the migration.

The repository contains all the necessary configuration and migration files. Clone the complete project to your local machine and execute the migration:

1
2
3
git clone https://github.com/theZetrax/dot-net-on-linux.git
cd ./dot-net-on-linux
docker composer up

The docker composer operation builds our application, executes the migration, and launches our ASP.NET application with the .NET runtime. Access the running solution by visiting http://localhost:8080/Shoes.

While our application interface is basic, it demonstrates functionality across all tiers, from the view down to the database. Refer to the full repository for a comprehensive overview of our solution.

.NET on Linux: A Practical Reality

.NET on Linux is more than just a possibility; it’s a highly viable combination of language, runtime, and operating system. While developers accustomed to Visual Studio might be less familiar with using the full potential of the .NET CLI, these tools are both effective and powerful.

The Toptal Engineering Blog thanks Henok Tsegaye for his review of the code samples presented in this article.

Licensed under CC BY-NC-SA 4.0