📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are your options with dotnet publish: framework-dependent or self-contained, single-file, trimming, ReadyToRun, Native AOT and container images? How do you choose?

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.

Mid-Level Theory Advanced containers deployment dotnet-publish native-aot trimming

🎯 Key points

  • Framework-dependent output needs the matching .NET runtime on the target; self-contained bundles the runtime for one runtime identifier
  • Specifying a runtime identifier (-r linux-x64) no longer implies self-contained since .NET 8; say --self-contained explicitly
  • PublishSingleFile bundles the app into one executable; it is a packaging choice, not a performance one
  • PublishTrimmed removes unused code; it can break reflection-heavy code, so heed trim warnings
  • PublishReadyToRun precompiles IL to native code to improve start-up, at the cost of larger files
  • PublishAot compiles to a native executable with fast start-up and low memory, but no runtime code generation and limited library support
  • The SDK can build container images directly (dotnet publish with /t:PublishContainer) without a Dockerfile
  • Choose by target platform, start-up and memory needs, image size, and library compatibility; measure

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"By default dotnet publish gives 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-x64 alone no longer implies self-contained, so I say --self-contained explicitly.

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's dotnet pack."


The options

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.

How I choose

  • ASP.NET Core API on App Service or a container: framework-dependent on the official mcr.microsoft.com/dotnet/aspnet base image. Patching the runtime is then a base image update, which is easy to automate.
  • Azure Functions or serverless with cold-start pain: try ReadyToRun first; consider Native AOT if the app and its libraries support it.
  • CLI tools and small worker services: self-contained single-file, or Native AOT for instant start-up and no runtime dependency.
  • High-density containers: Native AOT or trimmed self-contained to cut memory and image size, after testing compatibility (EF Core and some serialisers have limits; use source-generated 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.

Production example

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.

🔍 What the interviewer is checking

The interviewer is probing whether you understand deployment trade-offs rather than defaulting to whatever the template did.

Green flags

  • Explains each option and its cost precisely.
  • Knows the .NET 8 RID change and Release default.
  • Mentions trim/AOT warnings, source-generated JSON and runtime patching.
  • Chooses per workload and measures.

Red flags

  • "Always use self-contained" or "always use AOT".
  • Thinks single-file improves performance.
  • No awareness of security patching for self-contained apps.

📚 Learn in depth

1. From IL to what actually runs

                         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.

2. Runtime identifiers

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.

3. Each option in detail

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.

4. Rough size and start-up comparison

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.

5. Container images from the SDK

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.

6. A decision guide

 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.

7. Security and patching

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.

8. .NET 11 awareness

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.

9. Try it yourself

  1. Publish the code example four ways (framework-dependent, self-contained, single-file trimmed, AOT) and compare folder sizes with du -sh out/*.
  2. Time start-up of each with time ./out/<shape>/Hello.
  3. Replace the source-generated JSON with JsonSerializer.Serialize(reading) and publish trimmed and AOT; read the IL2xxx and IL3xxx warnings.
  4. Add Assembly.GetExecutingAssembly().Location to the single-file build and print it; switch to AppContext.BaseDirectory.
  5. Publish with -p:PublishReadyToRun=true and compare start-up and size with the plain self-contained build.
  6. Build a container image with /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).

💻 Code

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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes publish options without saying how publish relates to build. Why: Checks whether the candidate knows where publish sits in the flow and what it rebuilds.
    How to answer

    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.

  2. When: Candidate confuses publishing an app with publishing a NuGet package. Why: Separates shipping an application from shipping a library.
    How to answer

    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.

  3. When: Candidate picks a RID or self-contained publish. Why: Sees whether the chosen publish options appear correctly in a CI pipeline.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Two-second cold starts on a scale-to-zero worker fixed with Native AOT
Situation
A telemetry worker in Azure Container Apps (Australia East) scaled to zero overnight and between bursts. Each burst paid about a 2-second cold start, which caused message processing lag alerts during morning peaks.
Signals
Queue age alerts at the start of each burst, container start logs showing around 1.8 s before the first message was processed, and a 220 MB image.
Investigation
Measured start-up phases with timestamps and `dotnet-counters`, confirming most time was JIT and assembly loading. Enabled `IsAotCompatible` analysis and found trim/AOT warnings from reflection-based JSON serialisation and a reflection-based object mapper.
Resolution
Switched to source-generated `System.Text.Json`, replaced the mapper with explicit mapping code, set `PublishAot`, and moved to the `runtime-deps` base image. Cold start dropped to under 100 ms, memory per replica fell by about 60%, and the image shrank to about 30 MB.
Prevention
The build now treats IL2xxx and IL3xxx warnings as errors, a start-up time check runs in the pipeline, and a scheduled job rebuilds the image when a .NET patch is released, because AOT embeds the runtime.

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.

↩ Usually asked after

More in .NET CLI

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me