.NET & Linux

How an open-source, cross-platform .NET found its home on Linux: containers, systemd, Native AOT, kernel interop and the pitfalls to avoid.

For most of its first decade, .NET was a Windows-only runtime, and Linux was the operating system that ran the rest of the internet. Today the same dotnet binary builds, tests and ships software on both, and for a large share of teams Linux is the primary target. This article looks at how that happened, what it changed for developers, and the practical details that matter when you run .NET on Linux in production.

Contents

  1. From Mono to .NET 10
  2. Why Linux changed .NET
  3. Getting started on Linux
  4. Running as a systemd service
  5. Containers, the natural habitat
  6. Native AOT and startup time
  7. Talking to the kernel
  8. Diagnostics without a GUI
  9. Pitfalls worth knowing
  10. What each side gained

From Mono to .NET 10

The idea of C# on Linux is older than most people remember. The Mono project brought a compatible runtime to Linux as early as the early 2000s, but it was always a reimplementation chasing Microsoft's official framework. The real turning point came in 2014, when Microsoft announced that the core of .NET would be open-sourced under the MIT license and developed in public on GitHub.

YearMilestoneWhy it mattered
2004Mono 1.0First usable C# runtime on Linux
2014.NET Core announced as open sourceDevelopment moves to GitHub, MIT license
2016.NET Core 1.0Official, supported runtime for Linux
2019.NET Core 3.0Container-aware GC, desktop workloads return on Windows
2020.NET 5One platform: the "Core" suffix is dropped
2023.NET 8 (LTS)Mature Native AOT, chiseled container images
2025.NET 10 (LTS)Current long-term support release

In short, the story goes from ".NET Framework, Windows only" to ".NET, wherever your code runs".

Make each program do one thing well.

This line from the Unix philosophy (Doug McIlroy, 1978) describes modern .NET surprisingly well: small, composable services, each doing one job, deployed onto Linux hosts.

Why Linux changed .NET

Moving to Linux was not just a port. It forced the platform to rethink a number of long-standing assumptions:

  • Startup and footprint. On Linux, applications live in containers that are started and stopped constantly. A runtime that takes seconds to warm up is a cost you pay on every scale-out event.
  • The command line comes first. The dotnet CLI became the primary interface. Visual Studio is now one of many front ends, not a requirement.
  • Resource limits are real. Containers run under cgroup limits. The garbage collector learned to read those limits instead of assuming it owned the whole machine.
  • Open development. Issues, design discussions and pull requests for the runtime, the compiler (Roslyn) and ASP.NET Core are public, and many performance fixes come from the community.

The result is a runtime that feels at home on a server: fast to start, predictable under memory pressure, and scriptable end to end.

Getting started on Linux

Most major distributions ship .NET in their own repositories, which is the simplest way to install it. On Ubuntu:

sudo apt update
sudo apt install -y dotnet-sdk-8.0

dotnet --info

If you need a version your distribution does not package yet, or you want several SDKs side by side without root, Microsoft's install script puts everything under your home directory:

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 10.0

export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools"

A minimal web API

With the SDK in place, a complete HTTP service fits on one screen. This is the entire Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => Results.Ok(new
{
    Message = "Hello from .NET on Linux",
    Os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
    Runtime = Environment.Version.ToString()
}));

app.Run();

Create it, run it and test it without leaving the terminal:

dotnet new web -n HelloLinux
cd HelloLinux
dotnet run --urls http://0.0.0.0:5000

# in another terminal
curl -s http://localhost:5000 | jq

Behind that app.Run() call is Kestrel, a cross-platform web server that on Linux sits directly on top of epoll. You do not need IIS, and in many setups you do not even need a reverse proxy, although putting Nginx or Caddy in front for TLS termination is still common practice.

Running as a systemd service

On a plain Linux server, the cleanest way to keep an application alive is to let systemd supervise it. .NET has first-class support for this: add the Microsoft.Extensions.Hosting.Systemd package and one line of code.

var builder = WebApplication.CreateBuilder(args);

// Reports readiness to systemd and routes logs to the journal format.
builder.Services.AddSystemd();

var app = builder.Build();
app.MapGet("/health", () => "ok");
app.Run();

Then describe the service in a unit file, for example /etc/systemd/system/hello.service:

[Unit]
Description=HelloLinux web API
After=network.target

[Service]
Type=notify
WorkingDirectory=/opt/hello
ExecStart=/opt/hello/HelloLinux
User=hello
Restart=on-failure
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_gcServer=1

[Install]
WantedBy=multi-user.target

Type=notify is the important detail. systemd waits until the application says it is ready, so dependent services start only after the API can actually accept requests.

sudo systemctl daemon-reload
sudo systemctl enable --now hello
journalctl -u hello -f

Containers, the natural habitat

Most .NET code running on Linux today runs in a container. Microsoft publishes official images for the SDK, the ASP.NET Core runtime and the bare runtime. A multi-stage build keeps the SDK out of the final image:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
USER app
ENTRYPOINT ["./HelloLinux"]

The chiseled images are built from Ubuntu with everything the application does not need removed: no shell and no package manager. They run as a non-root user by default. That means a much smaller attack surface and far fewer CVEs showing up in your security scanner.

No Dockerfile at all

Since .NET 8, the SDK can build an OCI image by itself. No Dockerfile or Docker daemon is needed for the build step:

dotnet publish -c Release /t:PublishContainer \
  -p ContainerRepository=hello-linux \
  -p ContainerImageTag=1.0.0

Memory limits and the GC

When a container is started with a memory limit, the .NET garbage collector reads it from cgroups and sizes its heap accordingly. You can tighten that further with an environment variable, for example DOTNET_GCHeapHardPercent=0x4B to cap the heap at 75% of the container limit. The value is hexadecimal, which is a classic trap.

Native AOT and startup time

The traditional .NET model compiles IL to machine code just in time, while the program runs. That gives excellent peak performance, but costs startup time and memory. Native AOT compiles the whole application ahead of time into a single native Linux executable with no runtime to install.

Enabling it is one property in the project file:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>
dotnet publish -c Release -r linux-x64
ls -lh bin/Release/net10.0/linux-x64/publish/

There are three ways to ship the same code, and each makes a different trade-off:

ModeStartupPeak throughputOutput sizeRuntime requiredReflection
JIT (default)SlowerHighestSmallYesFull
ReadyToRunFasterHighLargerYesFull
Native AOTFastestHighMediumNoLimited

Native AOT is not free. Code that relies on unrestricted reflection or runtime code generation needs to be adapted, typically by switching to source generators (for example System.Text.Json with a JsonSerializerContext). For CLI tools, serverless functions and small microservices, the trade-off is usually worth it.

Talking to the kernel

Sometimes you need something the base library does not expose. .NET 7 introduced LibraryImport, a source-generated replacement for the old DllImport. The generator produces the marshalling code at compile time, which also makes it compatible with Native AOT.

using System.Runtime.InteropServices;

internal static partial class Libc
{
    [LibraryImport("libc", SetLastError = true)]
    internal static partial int getuid();

    [LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)]
    internal static partial int chmod(string path, uint mode);
}

// Usage
if (Libc.getuid() == 0)
{
    Console.WriteLine("Running as root, dropping privileges is recommended.");
}

Note that LibraryImport requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the project file, because the generated code uses pointers. Also check the base library first: for this particular case, File.SetUnixFileMode already does the chmod for you in a type-safe way.

Diagnostics without a GUI

On a server you rarely have a debugger UI. The .NET diagnostic tools are designed for exactly that situation. They are global tools that attach to a running process by its PID:

dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-trace
dotnet tool install -g dotnet-dump

# live metrics: CPU, GC, allocation rate, thread pool, exceptions
dotnet-counters monitor -p $(pidof HelloLinux)

# a CPU trace you can open in Visual Studio, PerfView or speedscope
dotnet-trace collect -p $(pidof HelloLinux) --format speedscope

Because the runtime also emits perf maps when you ask it to, standard Linux tooling such as perf can resolve JIT-compiled .NET frames. Enable it with DOTNET_PerfMapEnabled=1.

Pitfalls worth knowing

Most problems when moving an existing codebase to Linux fall into a handful of categories:

  1. Case-sensitive file systems. Config.json and config.json are two different files on ext4. Code that "worked on Windows" fails with a FileNotFoundException.
  2. Path separators. Never concatenate paths with "\\". Use Path.Combine or Path.Join.
  3. Line endings. Scripts committed with CRLF endings break with confusing errors. Add a .gitattributes file.
  4. Globalization. .NET uses ICU on Linux. Minimal images may not include libicu, so either install it or enable invariant globalization.
  5. Permissions. Binding to ports below 1024 requires privileges. Listen on a high port and let a reverse proxy or the container runtime map it.

A small example of the path problem:

// Wrong: only works on Windows
var bad = baseDir + "\\data\\" + fileName;

// Right: works everywhere
var good = Path.Combine(baseDir, "data", fileName);

What each side gained

It is tempting to describe this as .NET "coming to" Linux, but the benefits flowed in both directions.

.NET gained a huge server market, a culture of small and fast processes, and first-class container support. The engineering pressure to start fast and stay within tight memory limits made the runtime better on every operating system.

Linux gained one of the most productive, statically typed application platforms available, with strong tooling and long-term support releases. Distributions such as Ubuntu, Fedora and Red Hat Enterprise Linux package .NET themselves. Tools like PowerShell, which runs on .NET, are now just another package on a Linux box.

Developers gained the freedom to choose. You can write code on Windows, macOS or Linux, run it in a container on a Linux cluster, and use the same SDK, the same CLI and the same language everywhere.


Further reading: