.NET on Linux: Runtime, Kestrel, and Containers
.NET-specific primer: what C# compiles to, how the CoreCLR runtime works on Linux, what Kestrel does instead of IIS, and how multi-stage container builds produce minimal production images.
This appendix is .NET-specific. It exists for developers who know C# but have only ever run it on Windows with Visual Studio. Every other chapter is multi-language β this one answers the questions that come up when the deployment target changes from IIS on Windows Server to a container on Linux.
The move from Windows to Linux changes where .NET runs, not what it is. The same language, the same compiler, the same runtime, the same NuGet packages. What disappears is the Windows-only substrate: IIS, the Windows registry, COM interop, Windows Services. What replaces them is the same thing that replaced them for Java and Python years ago β a container runtime, a process supervisor (Kubernetes), and a standards-based web server (Kestrel).
This appendix walks through four layers, from source code to running container:
- C# and .NET β the language vs. the platform, and why the distinction matters.
- CIL and the CoreCLR runtime β what
dotnet buildproduces and how it executes. - Kestrel β the web server built into ASP.NET Core.
- Containers β multi-stage builds, Red Hat UBI images, and what ships to production.
C# is a language. .NET is a platform.
The distinction matters because conflating them causes architectural confusion. C# is a statically-typed, garbage-collected language β one of several that target .NET. F# and VB.NET are the others. All three compile to the same intermediate representation and run on the same runtime.
.NET (since .NET 5, unified; previously split into .NET Framework, .NET Core, and
Mono) is the platform: a runtime (CoreCLR), a base class library (BCL β System.*,
Microsoft.Extensions.*), a compiler toolchain (Roslyn for C#, the F# compiler), a
package manager (NuGet), and a CLI (dotnet). The platform is cross-platform β the
same dotnet publish output runs on Linux x86-64, Linux ARM64, macOS, and Windows
without recompilation, because the output is not native machine code.
The practical consequence: when this book says β.NET 10,β it means the platform β the
runtime, the BCL, and the ASP.NET Core framework that sits on top. When it says βC#,β it
means the language syntax. A .csproj file targets a .NET version (net10.0), not a C#
version, because the platform version determines what APIs are available.
CIL: what dotnet build actually produces
When Roslyn compiles a C# source file, the output is not x86 machine code. It is
Common Intermediate Language (CIL, historically called MSIL or just IL) β a
CPU-agnostic bytecode stored in .dll files. A .dll built on macOS runs unmodified on
Linux because the bytes inside are CIL, not native instructions.
The runtime β CoreCLR β loads these CIL assemblies and compiles them to native code on the fly using RyuJIT, the just-in-time compiler. RyuJIT compiles one method at a time, the first time it is called. After that first call, the method runs at native speed β there is no interpreter step and no re-compilation on subsequent calls.
CoreCLR on Linux is libcoreclr.so, a shared library. It provides:
- JIT compilation (RyuJIT) β CIL to native x86-64 or ARM64.
- Garbage collection β a generational, concurrent GC that runs on its own threads. Server GC mode (the default in ASP.NET Core) allocates one heap per CPU core.
- Type system β reflection, generics, interface dispatch.
- Threading β maps .NET threads to pthreads;
Taskandasync/awaitrun on the thread pool. - Exception handling β uses platform-specific unwinding (libunwind on Linux).
- P/Invoke β call native C libraries directly (
libpq,librdkafka,openssl).
NativeAOT: the ahead-of-time alternative
.NET also offers NativeAOT compilation, which runs Roslyn and the AOT compiler at
build time to produce a single native binary β no JIT, no libcoreclr.so, no .dll
files at runtime. The trade-off is real: NativeAOT binaries start faster and use less
memory, but they cannot use runtime code generation (no Reflection.Emit, limited
reflection). For the examples in this book, the JIT path is the default because it
supports the full library ecosystem without restrictions.
What the runtime is not
CoreCLR is not a virtual machine in the JVM sense. There is no bytecode interpreter running continuously β CIL is compiled to native code before it executes. After JIT compilation, a .NET method is indistinguishable from a method compiled by GCC or Clang. The runtime is still present (for GC, exception handling, and type loading), but the hot path is native machine code.
Kestrel: the web server that replaced IIS
On Windows, .NET web applications historically ran inside IIS (Internet Information Services). IIS managed the process lifecycle, handled TLS, parsed HTTP, and forwarded requests to the .NET application via a proprietary hosting model.
On Linux (and on modern Windows), Kestrel replaces all of that. Kestrel is the web server built into ASP.NET Core β it is not a separate process, not a reverse proxy, and not optional. When a .NET web application starts, Kestrel binds to a port and handles HTTP directly.
Key things Kestrel does:
- Listens on TCP sockets β using
epollon Linux (orio_uringon newer kernels). No external web server needed. - Handles TLS β via OpenSSL on Linux. In Kubernetes, TLS is typically terminated by the service mesh (Istio) or ingress controller, so Kestrel often runs plain HTTP inside the Pod.
- Parses HTTP/1.1, HTTP/2, and HTTP/3 β using zero-allocation
Span<byte>parsers. gRPC requires HTTP/2, which Kestrel supports natively. - Runs the middleware pipeline β each request passes through a configurable chain: routing, authentication, CORS, diagnostics, exception handling.
- Dispatches to endpoints β minimal APIs, gRPC services, GraphQL servers, WebSocket handlers, and health check endpoints all share the same Kestrel host.
Why not nginx or Apache in front?
In production Kubernetes, the answer is usually: you do not need them. The service mesh handles TLS termination, load balancing, and retries. Kestrel handles everything else. Adding nginx as a sidecar adds latency, memory, and operational complexity for features Kestrel already provides. The one exception is when you need nginx-specific features like rate limiting at the edge β in that case, it sits in front as an ingress controller, not as a per-Pod sidecar.
Kestrel configuration in practice
Every .NET example in this book configures Kestrel the same way:
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://+:8080");
For gRPC services that need HTTP/2-only:
builder.WebHost.ConfigureKestrel(k =>
k.ListenAnyIP(50051, o => o.Protocols = HttpProtocols.Http2));
The + binds to all interfaces (0.0.0.0). Port 8080 is the convention for non-root
containers β ports below 1024 require root, which containers should not run as.
Containers: what ships to production
A .NET application in a container is a Linux process. There is no VM, no app pool, no
IIS β just dotnet YourApp.dll running as PID 1 (or behind a container init process
like tini). The container image must contain the .NET runtime and your applicationβs
published DLLs; it does not need the SDK, Roslyn, NuGet, or MSBuild.
Multi-stage Containerfiles
Every example in this book uses a two-stage Containerfile:
FROM registry.access.redhat.com/ubi9/dotnet-100:9.8-1791421718 AS build
ENV NUGET_PACKAGES=/nuget-cache
WORKDIR /opt/app-root/src
COPY --chown=1001:0 OrderService/ OrderService/
RUN --mount=type=cache,target=/nuget-cache,uid=1001,sharing=locked \
dotnet publish OrderService/OrderService.csproj -c Release -o /opt/app-root/app
FROM registry.access.redhat.com/ubi9/dotnet-100-aspnet:9.8-1791363520
COPY --from=build --chown=1001:0 --chmod=g=u /opt/app-root/app /opt/app-root/app
WORKDIR /opt/app-root/app
EXPOSE 8080
USER 1001
ENTRYPOINT ["dotnet", "OrderService.dll"]
Stage 1 (build) uses the SDK image (ubi9/dotnet-100, .NET 10.0.12, ~780 MB). It
copies the source and publishes. The publish restores the NuGet packages in the same
RUN that mounts the package cache, as the imageβs non-root user (UID 1001, group 0); the
BuildKit cache mount keeps the package cache across builds, so a rebuild restores from disk
without the network (sharing=locked serializes images built in parallel, which would otherwise
extract into the same package directory at once). Restore and publish stay in one step because a separate restore layer
can be reused after BuildKit has dropped the cache mount, and a later publish with
--no-restore then fails with NETSDK1064 (packages not found). Every package in the
.csproj files is pinned to an exact version. Red Hat does not publish a ubi10 .NET
image, so the examples stay on ubi9 and pin the exact tags.
Stage 2 (runtime) uses the ASP.NET runtime image (ubi9/dotnet-100-aspnet,
~170 MB). It copies only the published output from stage 1, owned by UID 1001 with group-0 permissions, and runs as that user. The SDK, NuGet cache, source
code, and intermediate build artifacts are all discarded. The final image is typically
~200 MB.
Why Red Hat UBI?
The examples use Red Hat Universal Base Images (UBI) instead of the
mcr.microsoft.com/dotnet/* images from Microsoft. UBI images are:
- Freely redistributable β no subscription required to pull, run, or ship them.
- Based on RHEL 9 β same packages, same security patches, same lifecycle.
- Supported by Red Hat β when running on OpenShift or RHEL, UBI images get full commercial support.
- Minimal by default β
ubi9-minimalvariants use microdnf instead of yum, producing smaller images.
The .NET UBI images (ubi9/dotnet-100 for SDK, ubi9/dotnet-100-aspnet for runtime)
are built by Red Hat and published to registry.access.redhat.com. Red Hat
builds .NET from source for RHEL (the SDK uses feature band 1xx, for example 10.0.112
alongside runtime 10.0.12) rather than repackaging Microsoftβs binaries, so versions track
Microsoftβs releases but the build, base OS layer and support lifecycle are Red Hatβs.
What is inside the final image
Layer by layer, from bottom to top:
- UBI9 base (~70 MB) β glibc, openssl, ca-certificates, tzdata, libicu.
- ASP.NET Core runtime (~100 MB) β
libcoreclr.so,libclrjit.so, the BCL assemblies (System.*.dll), and the ASP.NET Core shared framework. - Your application (~5β80 MB) β your compiled
.dllfiles, third-party NuGet package DLLs, appsettings.json.
Things that are not in the final image: the SDK, MSBuild, Roslyn, NuGet, your source
code, obj/ and bin/ intermediates, test projects.
Container runtime behavior
When Kubernetes starts the container, dotnet OrderService.dll runs:
- CoreCLR initializes β loads
libcoreclr.so, starts the GC, initializes the thread pool. - The host builder runs β registers services (DI), configures logging, sets up OpenTelemetry.
- Kestrel binds to port 8080 β begins accepting HTTP connections.
- The application is ready β Kubernetes starts sending liveness and readiness probes.
- On shutdown, Kubernetes sends SIGTERM β the .NET host catches it and begins graceful shutdown: stops accepting new requests, drains in-flight requests, flushes telemetry, disposes services.
The entire lifecycle is the same as any Linux process. There is no IIS app pool recycle, no w3wp.exe, no application domain. The container is the process boundary.
Cross-check it yourself
Verify the compilation pipeline by inspecting a published applicationβs output. Run
dotnet publish -c Release on any example and list the output directory β you will see
.dll files (CIL bytecode, not native code), a .deps.json (dependency graph), and a
.runtimeconfig.json (runtime version selector). Open one of the .dll files with
dotnet-ildasm or ILSpy and you will see CIL opcodes, not x86 instructions β proof
that the JIT does its work at runtime, not at build time.
Then build a container image and inspect its layers. Run
docker image inspect <image> | jq '.[0].RootFS.Layers | length' β you should see the UBI
base layer, the runtime layer, and your application layer. Compare the image size to the
SDK image: the runtime image should be roughly 4x smaller.
Verification status: unverified β the three diagrams (P.1 compilation pipeline, P.2 Kestrel pipeline, P.3 container layers) are structurally correct based on the .NET 10 architecture, and the Containerfile pattern matches the verified examples in this book. The UBI image sizes (~780 MB SDK, ~170 MB runtime) are approximate and will vary by .NET version. The NativeAOT section describes general availability behavior as of .NET 8+.