Choosing a deployment shape with dotnet publish for .NET 10: runtime identifiers, framework-dependent vs self-contained, single-file, trimming, ReadyToRun, Native AOT and SDK-built container images, with their size, start-up and compatibility trade-offs.
By default dotnet publish gives a framework-dependent folder that needs the .NET runtime on the server. Self-contained (--self-contained -r linux-x64) bundles the runtime, so nothing needs installing, but the output is bigger and you patch the runtime by redeploying. On top of that: single-file packs everything into one executable, trimming removes unused code, ReadyToRun precompiles to speed up start-up, and Native AOT compiles to a native binary with very fast start-up and low memory but no dynamic code generation. The SDK can also build a container image directly. I choose based on where it runs, start-up and memory needs, and whether the libraries are trim- and AOT-safe, and I measure the result.
"By default
dotnet publishgives a framework-dependent folder that needs the matching .NET runtime on the target. Self-contained bundles the runtime for one runtime identifier, so nothing needs installing, but it's bigger and I own runtime patching. Since .NET 8, passing-r linux-x64alone no longer implies self-contained, so I say--self-containedexplicitly.On top of that, single-file bundles the app into one executable, which is a packaging choice, not a performance one. Trimming removes unused code, but it can break reflection-heavy code, so I treat trim warnings seriously. ReadyToRun precompiles IL to improve start-up, at the cost of larger files. Native AOT compiles to a native executable with very fast start-up and low memory, but there's no runtime code generation and not every library supports it, so things like source-generated JSON come first. And the SDK can build a container image directly with
/t:PublishContainer, without a Dockerfile.I choose by where it runs, start-up and memory needs, image size and library compatibility. For a long-running API I'd use framework-dependent on the official base image, because patching becomes a base image rebuild; for scale-to-zero workers or CLI tools I'd look at ReadyToRun or AOT. Then I measure cold start, memory, throughput and image size, because AOT isn't automatically faster at steady state. The mistake I see is shipping self-contained or AOT apps and never redeploying, so security patches never arrive. In the pipeline I publish with
--no-build, build the image once and promote it through every environment. Shipping a library is a different job; that'sdotnet pack."
| Option | Property / switch | Gives you | Costs |
|---|---|---|---|
| Framework-dependent | default | Small output; runtime patched by the host or base image | Target needs the right runtime |
| Self-contained | --self-contained -r <rid> / SelfContained |
No runtime install needed; pin exact runtime | ~70–100 MB more; you must redeploy to patch the runtime |
| Single file | -p:PublishSingleFile=true |
One executable to copy | Same start-up; some APIs (like Assembly.Location) behave differently |
| Trimmed | -p:PublishTrimmed=true (self-contained only) |
Much smaller output | Can remove code used only via reflection; must fix trim warnings |
| ReadyToRun | -p:PublishReadyToRun=true |
Faster start-up (less JIT at start) | Larger files; still JITs hot code later (tiered compilation) |
| Native AOT | <PublishAot>true</PublishAot> |
Native binary, fastest start-up, lowest memory, no JIT | No runtime code generation, limited reflection, not every library supports it, longer builds |
| Container image | dotnet publish /t:PublishContainer |
OCI image without a Dockerfile | Less control than a hand-written Dockerfile for unusual needs |
Since .NET 8, -r <rid> alone produces a framework-dependent, RID-specific app; you must pass --self-contained explicitly. dotnet publish also defaults to the Release configuration since .NET 8.
mcr.microsoft.com/dotnet/aspnet base image. Patching the runtime is then a base image update, which is easy to automate.System.Text.Json).Then measure: image size, cold-start time, steady-state memory and throughput. AOT does not automatically mean faster at steady state; the JIT's dynamic PGO can produce faster code for long-running services.
A queue-triggered worker in Azure Container Apps scaled to zero and paid a 2-second cold start on each burst. Publishing with Native AOT (after switching to source-generated JSON and removing a reflection-based mapper) cut cold start to under 100 ms and memory per replica by about 60%, and the image dropped from 220 MB to about 30 MB on the runtime-deps base.
The interviewer is probing whether you understand deployment trade-offs rather than defaulting to whatever the template did.
Green flags
Red flags
dotnet publish
|
+--------------------------+---------------------------+
| | |
framework-dependent self-contained Native AOT
app.dll (IL) app.dll (IL) + runtime app (native executable)
needs the shared files for ONE RID no IL, no JIT at run time
runtime on the target (+ optional trim, R2R,
single-file)
| | |
v v v
host -> shared runtime host -> bundled runtime OS loader -> native code
-> JIT (tiered, -> JIT (R2R code used everything compiled ahead
dynamic PGO) at start-up, hot code of time, runtime linked
re-JITted later) into the binary
| Model | Code at start-up | Code at steady state |
|---|---|---|
| JIT (default) | Tier 0: quick, unoptimised | Hot methods re-JITted at tier 1 with dynamic PGO |
| ReadyToRun | Precompiled native code, no JIT wait | Hot methods still re-JITted for best performance |
| Native AOT | Precompiled native code | Stays as compiled; no dynamic PGO |
That table is why AOT wins start-up but doesn't automatically win throughput for long-running services.
A RID names the target OS and architecture:
| RID | Target |
|---|---|
linux-x64, linux-arm64 |
glibc Linux (Ubuntu, Debian, Azure Linux) |
linux-musl-x64, linux-musl-arm64 |
musl Linux (Alpine) |
win-x64, win-arm64 |
Windows |
osx-arm64, osx-x64 |
macOS |
Self-contained, single-file, trimmed, ReadyToRun and AOT outputs are all RID-specific. Since .NET 8, passing -r alone gives a framework-dependent, RID-specific app; you must add --self-contained for a self-contained one. That still holds with the .NET 10 SDK.
Framework-dependent (default). The output contains your IL and NuGet dependencies, plus .deps.json and .runtimeconfig.json. The target needs a compatible runtime (by default the same major version, latest patch). Patching the runtime is the host's job: Windows Update, the OS package manager, App Service, or a base image rebuild.
Self-contained. Adds the runtime and the base class library for one RID. Roughly 70 MB or more before trimming. You own runtime patching: a .NET security release means rebuild and redeploy.
Single file. PublishSingleFile bundles assemblies into one executable. Managed assemblies load from the bundle in memory. Native libraries are extracted to a temporary folder unless they can be loaded directly, or bundled with IncludeNativeLibrariesForSelfExtract. Assembly.Location returns an empty string, so use AppContext.BaseDirectory. It can be framework-dependent or self-contained. It is a packaging choice, not a performance one.
Trimming. PublishTrimmed runs the ILLink trimmer over a self-contained app:
entry point --> walk every reachable method and type statically
--> keep what is reachable, remove the rest
invisible to the walk:
Type.GetType("Orders.Plugins." + name)
Activator.CreateInstance(type)
reflection-based JSON serialisation
assembly scanning for DI registration or mapping
--> trim warnings IL2xxx: "this might break"
TrimMode=full (the default) trims every assembly; partial trims only assemblies that opt in. Fix warnings with source generators (System.Text.Json, configuration binding, logging), [DynamicallyAccessedMembers] annotations, or by removing reflection.
ReadyToRun. PublishReadyToRun runs crossgen to add precompiled native code next to the IL. Start-up is faster because less code needs JIT, but files are roughly two to three times bigger. Composite ReadyToRun (PublishReadyToRunComposite) precompiles the framework and app together for even faster start-up in self-contained apps.
Native AOT. PublishAot compiles the whole app, the parts of the runtime it needs, and its dependencies into one native executable:
| Benefit | Restriction |
|---|---|
| Start-up in milliseconds | No runtime code generation: no Reflection.Emit, no dynamic assembly loading |
| Lower memory: no JIT, smaller working set | Reflection only for what the compiler can see; trim and AOT warnings (IL3xxx) must be fixed |
| Small output, no runtime dependency | Build for each OS on that OS; needs the platform's native toolchain (clang on Linux, MSVC tools on Windows) |
| Smaller attack surface | Longer builds; not every library is compatible |
ASP.NET Core supports AOT for minimal APIs, gRPC and worker services (use WebApplication.CreateSlimBuilder and the webapiaot template), but not MVC, Razor Pages or Blazor Server. EF Core's AOT support with precompiled queries is still experimental.
For a small console app on linux-x64 (exact numbers vary by app and version):
| Shape | Output size | Start-up |
|---|---|---|
| Framework-dependent | Under 1 MB (plus the shared runtime on the machine) | Tens of milliseconds |
| Self-contained | About 70 MB | Similar |
| Self-contained + trimmed + single file | Around 10 to 20 MB | Similar |
| ReadyToRun | Bigger than the equivalent IL | Faster than JIT |
| Native AOT | A few MB | A few milliseconds |
For real services, measure your own app: cold start, steady-state memory, throughput and image size.
dotnet publish -r linux-x64 /t:PublishContainer \
-p:ContainerRepository=orders/telemetry-worker \
-p:ContainerImageTag=1.4.0 \
-p:ContainerFamily=noble-chiseled
| Property | Purpose |
|---|---|
ContainerRepository, ContainerImageTag(s) |
Image name and tags |
ContainerBaseImage |
Explicit base image |
ContainerFamily |
Base image variant, such as noble-chiseled (no shell or package manager) |
ContainerRegistry |
Push straight to a registry such as Azure Container Registry |
ContainerPort, ContainerUser |
Exposed port and user |
ContainerArchiveOutputPath |
Write a tarball instead of using a local Docker daemon |
The SDK picks a sensible base: aspnet for web apps, runtime for console apps, runtime-deps for self-contained and AOT apps. Since .NET 8, images run as a non-root user and ASP.NET Core listens on port 8080. The .NET 10 images are based on Ubuntu 24.04 by default.
Is it a long-running web API on App Service or a container platform?
yes --> framework-dependent on the official aspnet base image
(patching = base image rebuild)
Does cold start matter (scale to zero, Functions, CLI tools)?
yes --> try ReadyToRun first (no code changes)
--> Native AOT if the app and its libraries are AOT-compatible
Must it run with no .NET installed (customer machines, locked-down servers)?
yes --> self-contained single file, or Native AOT
Is image size or memory per replica the constraint (high density)?
yes --> trimmed self-contained or Native AOT on runtime-deps/chiseled
Then MEASURE and record the decision.
| Shape | Who patches the runtime | What you must automate |
|---|---|---|
| Framework-dependent on a VM | The OS or your configuration management | Runtime updates on every server |
| Framework-dependent in a container | The base image | Rebuild and redeploy images when the base updates; scan images |
| Self-contained, single file, AOT | You | Rebuild and redeploy on every .NET security release |
Smaller images (chiseled, runtime-deps) and AOT binaries reduce the attack surface, but only help if they are rebuilt when patches ship.
Each release widens AOT and trimming compatibility in the framework and libraries. Re-test the shape you rejected last year when you upgrade, but don't plan production around .NET 11 preview features.
du -sh out/*.time ./out/<shape>/Hello.JsonSerializer.Serialize(reading) and publish trimmed and AOT; read the IL2xxx and IL3xxx warnings.Assembly.GetExecutingAssembly().Location to the single-file build and print it; switch to AppContext.BaseDirectory.-p:PublishReadyToRun=true and compare start-up and size with the plain self-contained build./t:PublishContainer, run it, and check the user with docker run --rm --entrypoint id <image> (or note it fails on chiseled images, which have no shell).Hello.csproj set up for AOT:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
Program.cs, using source-generated JSON so it is trim- and AOT-safe:
using System.Text.Json;
using System.Text.Json.Serialization;
var reading = new Reading("device-42", 90, DateTimeOffset.UtcNow);
Console.WriteLine(JsonSerializer.Serialize(reading, AppJsonContext.Default.Reading));
public sealed record Reading(string DeviceId, int BatteryPercent, DateTimeOffset At);
[JsonSerializable(typeof(Reading))]
internal sealed partial class AppJsonContext : JsonSerializerContext;
Publish variants:
# Framework-dependent (default, Release since .NET 8)
dotnet publish -o out/fdd
# Self-contained for Linux x64
dotnet publish -r linux-x64 --self-contained -o out/sc
# Self-contained, single file, trimmed
dotnet publish -r linux-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/single
# Native AOT (PublishAot is set in the project file)
dotnet publish -r linux-x64 -o out/aot
# Container image built by the SDK, no Dockerfile
dotnet publish -r linux-x64 /t:PublishContainer -p:ContainerRepository=orders/telemetry-worker -p:ContainerImageTag=1.4.0
Mistake: assuming -r linux-x64 makes the app self-contained.
Since .NET 8 (and still with the .NET 10 SDK), -r alone produces a framework-dependent, RID-specific app, so the target still needs the runtime.
Instead: pass --self-contained (or set SelfContained) explicitly when you want the runtime bundled.
Mistake: shipping self-contained or AOT apps and never redeploying. The runtime is baked in, so .NET security patches never reach production. Instead: automate a rebuild and redeploy on every .NET servicing release, or use framework-dependent images where the base image carries the patch.
Mistake: ignoring trim and AOT warnings.
Reflection-based code is removed or can't run, causing MissingMethodException, empty JSON or missing DI registrations in production.
Instead: treat IL2xxx and IL3xxx warnings as errors, use source generators for JSON, configuration and logging, and set IsAotCompatible on libraries.
Mistake: expecting single-file to be faster. It only changes packaging; the same code is loaded and JIT-compiled. Instead: use ReadyToRun or Native AOT when start-up time is the goal.
Mistake: using Assembly.Location in a single-file app.
It returns an empty string, so paths built from it break.
Instead: use AppContext.BaseDirectory.
Mistake: choosing Native AOT for a large ASP.NET Core app without checking support. MVC, Razor Pages, Blazor Server and many reflection-heavy libraries aren't supported, and EF Core's AOT support is experimental. Instead: check every dependency for AOT compatibility first. Consider ReadyToRun if you only need faster start-up.
Mistake: assuming AOT is faster at steady state. The JIT's tiered compilation and dynamic PGO can produce faster code for long-running services. Instead: measure throughput and latency under load, not just start-up.
Mistake: building on Windows for a Linux target and not testing on Linux. Native assets, file paths and case sensitivity differ, and AOT can't cross-compile between operating systems. Instead: build and test RID-specific outputs on the target OS, for example in a Linux CI job or container.
Mistake: carrying container assumptions from older .NET images.
Since .NET 8, the official ASP.NET Core images listen on port 8080 and run as a non-root user, so probes on port 80 and writes to protected folders fail.
Instead: update health probes and ingress to 8080 (or set ASPNETCORE_HTTP_PORTS), and write only to folders the app user owns.
Mistake: building a new image per environment. Test, staging and production run different images, so what you tested isn't what you run. Instead: build once and promote the same image digest through every environment, with configuration from the environment.
Deployment shape is an architecture decision with operational consequences, and a senior engineer makes it explicitly. The default for web APIs is framework-dependent on a patched base image, because runtime patching becomes routine. Self-contained and AOT buy independence and start-up at the price of owning runtime patching in your pipeline. Native AOT is compelling for scale-to-zero workers, CLIs and high-density services, but only after the dependency graph is proven compatible.
In a microservices estate the choice also shapes the platform: services on Azure Container Apps or AKS that scale on queue depth benefit most from fast start-up, while long-running APIs behind App Service gain little. A senior standardises a small set of publish profiles (for example "API", "worker", "CLI") in shared build props or pipeline templates, so every team gets the same base images, non-root defaults and patch rebuild schedule, and the CI/CD pipeline builds the image once and promotes it by digest. They decide with numbers (cold start, memory, image size, throughput), document the choice, and make the build fail on trim and AOT warnings so compatibility does not silently regress.
Restore works out the full NuGet package graph, downloads packages to the global packages folder and writes obj/project.assets.json. Build compiles against those assets into bin/<Configuration>/<TFM>. Test runs the test projects against the built assemblies. Publish produces a deployable folder with the app, its dependencies and runtime config, in Release by default since .NET 8. Each later command restores and builds implicitly, so in CI I restore once and pass --no-restore and --no-build. That way every step uses exactly the same outputs and nothing is repeated. The configuration has to match across steps.
dotnet pack builds the project and produces a .nupkg whose metadata comes from MSBuild properties in the project: PackageId, Version, Authors, Description, a licence expression, a repository URL and a README. I set the version from CI using Semantic Versioning, so a breaking change means a new major version. I publish symbols as a .snupkg with Source Link, so consumers can step into the exact source. I turn on package validation to catch accidental breaking changes. Then dotnet nuget push uploads to nuget.org or an internal feed, and organisations that need it can sign packages with dotnet nuget sign.
The pipeline restores three times, builds in Debug, runs dotnet format in a way that silently fixes files instead of failing, and publishes from a different build than the one it tested. I'd use global.json for the SDK, restore once with --locked-mode, build once in Release with --no-restore, then test and publish with --no-build. I'd run dotnet format --verify-no-changes so formatting drift fails the build, collect test results and coverage, and cache the NuGet folder.
How to tell it (STAR)
**Situation:** Our [telemetry worker] scaled to zero and paid about two seconds of cold start on every burst, which triggered lag alerts each morning. **Task:** I wanted to cut start-up without keeping replicas warm and paying for idle capacity. **Action:** I measured where start-up time went, fixed the reflection-based JSON and mapping code the AOT analysers flagged, published with Native AOT on a minimal base image, and added a patch rebuild job. **Result:** Cold start fell to [under 100 ms], memory per replica dropped by [about 60%], and the lag alerts stopped.
Last updated 1 Nov 2026.