RE:NODE

App hosting11 min read

dotnet publish options: self-contained, single-file and AOT

What dotnet publish actually produces: framework-dependent vs self-contained, runtime identifiers, single-file, trimming, ReadyToRun and Native AOT, and which to use.

0 readers

For a web app or a bot on a Linux server, the right dotnet publish is almost always the plain one: framework-dependent, Release configuration, an output folder, started with dotnet App.dll. It is the smallest output, it picks up runtime security patches without a rebuild, and it has none of the compatibility traps of the fancier modes. Self-contained is the right answer when you cannot control the runtime on the server. Single-file, trimming, ReadyToRun and Native AOT each solve one specific problem - startup time, file count, size - and each costs something, so turn them on because you have that problem, not because the flag exists.

This post explains what every option actually changes in the output folder, and where each one breaks.

The short answer#

ModeCommand addsNeeds runtime on serverTypical use
Framework-dependentnothingyes, matching majorMost web apps and bots
Self-contained-r linux-x64 --self-containednoServer runtime is wrong or unknown
Single-file-p:PublishSingleFile=truedepends on the aboveOne file to copy around
Trimmed-p:PublishTrimmed=trueno (self-contained only)Size-sensitive, trim-safe code
ReadyToRun-p:PublishReadyToRun=truedependsFaster cold start
Native AOT-p:PublishAot=truenoFast start, low memory, restricted code

The options combine. A self-contained, single-file, ReadyToRun build for linux-x64 is a normal thing to produce. The rest of this post is what each column means in practice.

What dotnet publish produces#

Start with the default and look at the folder:

bash
$ dotnet publish src/Api/Api.csproj -c Release -o out$ ls outApi  Api.deps.json  Api.dll  Api.pdb  Api.runtimeconfig.jsonappsettings.json  appsettings.Development.json  web.config  ...

Each of those files has a job:

  • Api.dll is your code, compiled to IL - the intermediate language that the runtime's JIT compiles to machine code as methods are first called.
  • Api.deps.json lists every dependency and where to find it. Delete it and the host falls back to probing the folder, which mostly works until it does not.
  • Api.runtimeconfig.json says which shared framework to load, such as Microsoft.AspNetCore.App 10.0, and holds runtime settings like the GC mode.
  • Api (or Api.exe on Windows) is the apphost - a small native launcher for the platform you built on. ./Api and dotnet Api.dll do the same thing. Disable it with -p:UseAppHost=false if you only ever start with dotnet.
  • NuGet dependencies are copied in as their own DLLs. The framework itself is not.
  • web.config is for IIS and is ignored everywhere else.

Since .NET 8, dotnet publish uses the Release configuration by default for projects targeting .NET 8 or later. Spelling out -c Release is still a good habit for older projects and for build scripts that someone will read later.

Framework-dependent, and how roll-forward works#

The default output depends on a shared .NET runtime being installed where it runs. The version requirement lives in runtimeconfig.json:

Api.runtimeconfig.json
{  "runtimeOptions": {    "tfm": "net10.0",    "framework": {      "name": "Microsoft.AspNetCore.App",      "version": "10.0.0"    }  }}

The version is a minimum, and the runtime applies a roll-forward policy to find one to use. The default policy, Minor, does two things: it always picks the newest installed patch of the requested version (so 10.0.0 runs on 10.0.7 if that is present), and if no 10.0.x exists at all it accepts a later minor version. It does not cross a major version: an app built for .NET 8 will not start on a machine that only has .NET 10, and fails with "You must install or update .NET to run this application".

You can change that with <RollForward>Major</RollForward> in the project, or the DOTNET_ROLL_FORWARD environment variable, and an app built for 8 will run on 10. It usually works, because breaking changes between majors are documented and mostly small, but you are then running on a runtime you never tested. Retargeting and rebuilding is the honest fix.

The real advantage of framework-dependent output is patching. When the host installs a .NET security update, your app picks it up on the next restart without a rebuild. A self-contained app carries its own copy of the runtime and keeps whatever patch level it was built with until you publish again.

Self-contained and runtime identifiers#

A self-contained build copies the runtime into the output folder, so the server needs no .NET installed at all:

bash
$ dotnet publish -c Release -r linux-x64 --self-contained -o out

A self-contained app is tied to one platform, named by a runtime identifier (RID):

RIDPlatform
linux-x6464-bit Intel/AMD Linux with glibc - most servers
linux-arm6464-bit ARM Linux with glibc
linux-musl-x64Alpine and other musl-based distributions
win-x6464-bit Windows
osx-arm64Apple silicon macOS

Since .NET 8 the SDK uses portable RIDs like these by default; distribution-specific ones such as ubuntu.22.04-x64 are no longer what you should target. Also since .NET 8, passing -r on its own no longer implies self-contained - you get a framework-dependent build for that platform. Say --self-contained (or --self-contained false) explicitly so nobody has to remember which SDK changed what.

What self-contained does not include is the operating system's native libraries. The runtime still needs glibc (or musl, for the musl RIDs), OpenSSL for TLS, and ICU for culture-aware string handling. On a minimal Linux image without ICU, a .NET app stops at startup with "Couldn't find a valid ICU package installed on the system". If your app genuinely does not need culture-specific formatting - most APIs that speak JSON and store UTC timestamps do not - set invariant globalization:

xml
<PropertyGroup>  <InvariantGlobalization>true</InvariantGlobalization></PropertyGroup>

Then all cultures behave like the invariant culture: ToUpper on Turkish text and date formats in de-DE will not follow local rules. Some database drivers have opinions about this too; older versions of Microsoft.Data.SqlClient refused to open connections in invariant mode, so test it with your actual stack.

The cost of self-contained is size: the runtime and, for a web app, ASP.NET Core add roughly 70 to 100 MB depending on version, against a few megabytes for the framework-dependent build. On a plan with 5 GB of disk that matters less than it sounds, but it matters if you keep several builds around.

Single-file#

PublishSingleFile bundles the app into one executable. It requires a RID and works for both framework-dependent and self-contained builds:

bash
$ dotnet publish -c Release -r linux-x64 --self-contained \    -p:PublishSingleFile=true -o out

Three details catch people.

Some files are still separate. Managed assemblies go into the bundle. Native libraries are left next to the executable unless you add -p:IncludeNativeLibrariesForSelfExtract=true, in which case they are extracted to a temporary directory on first run (controlled by DOTNET_BUNDLE_EXTRACT_BASE_DIR). Content files such as appsettings.json and wwwroot are published alongside the executable rather than inside it unless you set IncludeAllContentForSelfExtract, which is rarely what you want for a config file you intend to edit.

`Assembly.Location` is empty. Code that finds its own folder with typeof(Program).Assembly.Location gets an empty string inside a bundle. Use AppContext.BaseDirectory instead; the compiler warns about this with IL3000 if you have analysers on.

It is not smaller and not faster. A single file is the same bytes in one container. It is convenient for a command-line tool you hand to other people. For a server app deployed from Git, a folder is no harder to start than a file, so single-file solves a problem you do not have.

Trimming#

Trimming removes code the app does not use from the framework and from your dependencies. It only works with self-contained builds, because a shared framework cannot be trimmed for one app.

xml
<PropertyGroup>  <PublishTrimmed>true</PublishTrimmed></PropertyGroup>

The trimmer works by static analysis: it follows calls from your entry point and removes what it cannot reach. Reflection defeats it. Anything that discovers types at runtime - classic MVC controller discovery, Razor views, System.Text.Json serialisation without source generation, most dependency injection by convention, older ORMs - can lose the code it needs, and the failure appears at runtime as a MissingMethodException or a type that quietly deserialises to nothing.

The SDK tells you where the risk is with trim warnings (IL2026, IL2070 and friends). Treat them as errors. If your build produces dozens of them from a library you do not control, trimming is not for that app.

In practice: a minimal API with source-generated JSON trims well. An MVC application with Razor views, or anything heavily using EF Core, is a poor candidate, and Microsoft's own documentation says so for those frameworks. The saving is real - often half the self-contained size or more - but on a server, disk is rarely the constraint that matters.

ReadyToRun and tiered compilation#

Normal .NET code is compiled to machine code by the JIT as it runs. Startup therefore spends time compiling, which on a slow CPU is a large share of the first few seconds. ReadyToRun (R2R) compiles ahead of time and stores native code alongside the IL:

bash
$ dotnet publish -c Release -r linux-x64 -p:PublishReadyToRun=true -o out

It needs a RID because the native code is platform-specific. The assemblies grow, often to two or three times their IL size. The JIT is still there: tiered compilation treats R2R code as a starting tier and recompiles hot methods with full optimisation, guided by dynamic profile data (dynamic PGO, on by default since .NET 8), so steady-state performance ends up the same as without R2R.

What R2R buys is cold start. On a server with half a vCPU, where the CPU limit is a hard throttle rather than a suggestion, JIT work at startup is noticeably slow, and R2R can cut seconds off the time between "process started" and "first request answered". If your app restarts on every deploy and you care about that gap, it is the cheapest win on this list and it has no compatibility cost. If the app starts once a week, it is not worth the disk.

Native AOT#

Native AOT compiles the whole app, runtime included, into one native executable with no JIT at all:

xml
<PropertyGroup>  <PublishAot>true</PublishAot></PropertyGroup>

The results are dramatic where they apply: startup in tens of milliseconds, a smaller binary than trimmed self-contained, and lower memory at idle. The restrictions are those of trimming, made absolute: no runtime code generation, no loading assemblies dynamically, reflection only where the compiler can see it. ASP.NET Core supports it for minimal APIs and gRPC, with WebApplication.CreateSlimBuilder and source-generated JSON; the webapiaot template is set up for it. MVC, Razor Pages and Blazor Server are not supported. EF Core's AOT support is still limited and marked experimental in recent versions.

Two practical constraints: Native AOT needs the platform's native toolchain at build time (on Linux, clang and the zlib development headers), and it does not cross-compile between operating systems - build Linux binaries on Linux. Building on the server's own small plan is slow and memory-hungry; this is a job for CI.

Native AOT is genuinely good for small, focused services and command-line tools. For a typical line-of-business web app, the restrictions cost more than the startup time saves.

Choosing for a small server#

Here is how the options land on a real case: an ASP.NET Core API with EF Core, deployed from GitHub to a 1 GB plan with half a vCPU.

  1. Start framework-dependent. dotnet publish -c Release -o out and dotnet out/Api.dll. Smallest output, patched with the runtime, no surprises.
  2. If the server's runtime is the wrong major version, publish self-contained for linux-x64 instead of fighting roll-forward. Remember you now own runtime patching: rebuild when a .NET security update ships.
  3. If restarts are slow, add ReadyToRun before anything else. It is free of compatibility risk.
  4. Leave trimming and AOT off for this app. EF Core and a reflection-heavy stack will produce warnings you cannot safely ignore.
  5. Skip single-file. It adds nothing when the deploy is a Git pull.

Where the build happens matters as much as how. Building on the server means the SDK, MSBuild and the compiler all run inside the plan's memory limit on every start, and on RE:NODE a process that reaches its memory limit is stopped and the container restarts clean rather than swapping - so a large build on the smallest plan can fail half way. Building in GitHub Actions and deploying the output (from a branch the server tracks) moves that work off the server entirely. Deploy an ASP.NET Core app from GitHub walks through both arrangements, and .NET memory and garbage collection explains what the app itself will use once it is running.

The same choices apply outside the web: a worker service or a bot publishes exactly the same way, and .NET worker services and background jobs covers the hosting model for those.

FAQ#

Is self-contained faster than framework-dependent?

No. It is the same runtime and the same JIT; only where the files come from changes. Startup speed comes from ReadyToRun or Native AOT, not from bundling the runtime.

Why does my published app ask for a .NET version I thought I had?

The server has a different major version installed than the one in runtimeconfig.json, and the default roll-forward policy will not cross majors. Retarget, publish self-contained, or set RollForward deliberately.

Can I publish on Windows and run on Linux?

Yes, for everything except Native AOT. Framework-dependent IL is platform-neutral, and a self-contained build for linux-x64 can be produced on Windows. Only the apphost differs, and you can start with dotnet App.dll instead.

Do I need the SDK on the server?

Only if you build there. Running a published app needs only the runtime, or nothing at all if it is self-contained. Keeping the SDK off the run path is one reason to build in CI.

Should I commit the publish output to Git?

Not to your main branch. If you deploy pre-built output, keep it on a separate branch written by CI, so source history stays readable. .NET versions and LTS support covers keeping the target framework current, which is the other half of a stable build.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000