Start Debugging

Framework-dependent vs self-contained vs Native AOT for a .NET 11 container image

Framework-dependent on a chiseled aspnet image is the right default for an ASP.NET Core service on .NET 11, because the runtime layer is shared across services and a runtime CVE is fixed by a base image bump. Self-contained trimmed and Native AOT buy a 2x to 5x smaller image and a much faster cold start, and cost you that. Real published sizes, the layer-sharing math, and the .NET 11 base image inference bug that breaks the AOT path.

For an ordinary long-running ASP.NET Core service on .NET 11, publish framework-dependent onto a chiseled aspnet image. It is the smallest thing you actually ship (a few megabytes of app on top of a runtime layer your other services already pulled), and a runtime CVE is fixed by rebuilding on a new base image tag rather than by rebuilding, retesting, and redeploying the app. Switch to self-contained plus trimming when the app must pin a specific runtime patch or run on a base image with no .NET at all. Reach for Native AOT only when cold start or per-pod memory is the dominating constraint and dotnet publish reports zero AOT warnings across your whole dependency tree. The size numbers people quote for AOT are real, but for a fleet they measure the wrong thing: framework-dependent images share one runtime layer across every service on a node, and self-contained and AOT images do not.

Everything here targets <TargetFramework>net11.0</TargetFramework>. .NET 11 is at Preview 7 (11.0.100-preview.7.26381.103, released August 11, 2026) as I write this, with GA expected in November 2026. Preview image tags carry a -preview qualifier that GA drops, so 11.0-preview-resolute-chiseled today becomes 11.0-resolute-chiseled in November. The mechanics below have been stable since .NET 8, so almost all of it applies unchanged on .NET 9 and .NET 10.

The three modes as container images

PropertyFramework-dependentSelf-contained + trimmedNative AOT
Base image repositorydotnet/aspnet or dotnet/runtimedotnet/runtime-depsdotnet/runtime-deps
Runtime lives inthe base image layeryour app layercompiled into the binary
Runtime layer shared across servicesYesNoNo
Runtime CVE fixed bypulling a new base tag, rebuildnew SDK, rebuild, retest, redeploynew SDK, rebuild, retest, redeploy
Rolls forward to installed patchYesNoNo
Enabled bynothing (the default)--self-contained -p:PublishTrimmed=true-p:PublishAot=true
Needs a RIDNoYesYes
Build host needs a C toolchainNoNoYes (clang, zlib1g-dev)
Reflection, Reflection.Emit, plugin loadingFullTrim warnings, runtime failures possibleRestricted or unavailable
Sample image, compressed52.81 MB21.86 MB11.60 MB

Those last three numbers are from the .NET container image size report in dotnet/dotnet-docker, measured on the releasesapi sample against .NET 10.0 with noble-chiseled base images. Full details in a moment, because that row is the one that misleads people.

What each mode actually puts in the image

The SDK’s container tooling infers the base image from your project, and the rule is short. Per the containerization reference, a self-contained project gets mcr.microsoft.com/dotnet/runtime-deps, an ASP.NET Core project gets mcr.microsoft.com/dotnet/aspnet, and anything else gets mcr.microsoft.com/dotnet/runtime. The tag is the numeric part of your TFM, with ContainerFamily appended as a suffix.

That inference is the whole story:

All three inherit the same hardening from the Microsoft images: the rootless app user at UID 1654, exposed through $APP_UID, and port 8080 rather than 80, both of which landed in .NET 8. Chiseled images additionally ship no shell, no package manager, and no curl, so docker exec debugging and shell-based health checks do not work in any of the three modes if you pick a chiseled family.

Publishing each of the three

Framework-dependent, no RID needed, straight to a chiseled ASP.NET Core base:

# .NET 11 SDK 11.0.100-preview.7. Framework-dependent onto aspnet:11.0-preview-resolute-chiseled.
dotnet publish --os linux --arch x64 /t:PublishContainer \
  -p ContainerFamily=resolute-chiseled \
  -p ContainerRepository=orders-api

Self-contained with trimming. PublishTrimmed implies SelfContained, but spell both out so a future reader does not have to remember that:

# .NET 11 SDK 11.0.100-preview.7. Self-contained + trimmed onto runtime-deps:11.0-preview-resolute-chiseled.
dotnet publish --os linux --arch x64 /t:PublishContainer \
  --self-contained \
  -p PublishTrimmed=true \
  -p ContainerFamily=resolute-chiseled \
  -p ContainerRepository=orders-api

Native AOT. PublishAot implies self-contained, and needs the platform C toolchain on the build machine:

# .NET 11 SDK 11.0.100-preview.7. Native AOT onto runtime-deps:11.0-preview-resolute-chiseled.
# Requires clang and zlib1g-dev locally, or build inside sdk:11.0-preview-aot.
dotnet publish --os linux --arch x64 /t:PublishContainer \
  -p PublishAot=true \
  -p ContainerFamily=resolute-chiseled \
  -p ContainerRepository=orders-api

If you would rather do this from CI without installing clang on the agent, the SDK AOT image is the reason those tags exist:

# .NET 11 preview. Multi-stage AOT build.
FROM mcr.microsoft.com/dotnet/sdk:11.0-preview-resolute-aot AS build
WORKDIR /src
COPY . .
RUN dotnet publish OrdersApi/OrdersApi.csproj -c Release -r linux-x64 -p:PublishAot=true -o /app

FROM mcr.microsoft.com/dotnet/runtime-deps:11.0-preview-resolute-chiseled
WORKDIR /app
COPY --from=build /app/OrdersApi .
USER $APP_UID
ENTRYPOINT ["./OrdersApi"]

For the full set of Container* properties, tag control, and registry authentication, see the walkthrough on publishing a .NET 11 app as a container image without a Dockerfile.

The published size numbers

Microsoft publishes measured sizes for a sample minimal web API across every base image variant, so there is no need to hand-wave. These are the compressed sizes for the releasesapi sample on .NET 10.0:

Base imageFramework-dependentSelf-contained + trimmedNative AOT
Full Ubuntu (10.0)92.48 MB61.53 MB51.27 MB
10.0-noble-chiseled52.81 MB21.86 MB11.60 MB
10.0-noble-chiseled-extra67.68 MB36.82 MB26.56 MB
10.0-alpine51.93 MB20.95 MB10.69 MB
10.0-alpine-extra66.50 MB35.52 MB25.25 MB

Two things fall out of that table immediately. First, the base image family is a bigger lever than the deployment mode. Moving a framework-dependent app from the full Ubuntu image to noble-chiseled saves 39.67 MB, which is more than switching that same app from framework-dependent to Native AOT on the full image saves (41.21 MB) and requires none of the compatibility work. If you have not chiseled yet, do that first and re-measure before you consider anything else.

Second, chiseled Native AOT really is roughly 4.5x smaller than chiseled framework-dependent. That is a genuine win, and for a scale-to-zero function or a very high-density node it is decisive.

The layer-sharing math that flips the size argument

Here is the part the size report cannot show you, because it measures one image in isolation.

Container images are content-addressed layers. If ten of your services all build FROM mcr.microsoft.com/dotnet/aspnet:11.0-preview-resolute-chiseled, every node that runs them pulls and stores that runtime layer exactly once. The marginal cost of the eleventh service is its own app layer, which for a framework-dependent ASP.NET Core service is a few megabytes of IL.

Do the arithmetic for ten services on one node, using the chiseled column above:

Self-contained is the worst of the three at fleet scale even though it beats framework-dependent 2.4x on a single image, because trimming is per-app and cannot dedupe across apps. Native AOT is small enough that it stays ahead, but its lead shrinks from 4.5x to well under 2x. Registry storage, cross-AZ pull bandwidth, and node disk pressure all follow this second calculation, not the first one. Measure your own fleet before you migrate anything on size grounds.

Patching: who fixes a runtime CVE

This is the argument that should actually decide it for most teams, and it is the one the publishing overview states plainly. A framework-dependent app “automatically rolls forward to the latest .NET security patch available on the environment,” while a self-contained deployment “doesn’t roll forward” and “the .NET Runtime can only be upgraded by releasing a new version of the app.”

In container terms:

If your organization has a “patch critical CVEs within N days” control, that difference is not a footnote. It is the reason to stay framework-dependent unless something forces you off it.

Globalization is the hidden switch between chiseled and chiseled-extra

Plain -chiseled, -alpine, and Azure Linux -distroless images ship without ICU and tzdata, so they only work for apps in globalization invariant mode. The -extra variants add ICU, tzdata, and libstdc++ back, which is what those 15 MB deltas in the size table are.

For self-contained and AOT publishes the SDK tries to help: if InvariantGlobalization is false it steers you to an -extra variant. For framework-dependent publishes you choose the family yourself, so it is on you to set the property to match:

<!-- .NET 11, net11.0. Required if you target a plain -chiseled or -alpine base. -->
<PropertyGroup>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

Get this wrong and the container dies at startup with Couldn't find a valid ICU package installed on the system, which has its own fix post. And invariant mode is not free: culture-sensitive string comparison, ToUpper and ToLower for non-ASCII, and TimeZoneInfo lookups all change behaviour. If you localize anything or format currency, pay the 15 MB for -extra.

The .NET 11 gotcha: base image inference still says noble

The container tooling computes the Ubuntu codename for the inferred tag from the SDK version, and as of the .NET 11 previews that lookup only knows jammy (SDK below 8.0.300) and noble (8.0.300 and above). Since 11.0.100 satisfies the second condition it returns noble, but .NET 11 images on MCR are published under resolute (Ubuntu 26.04). The result, reported as dotnet/sdk#53553:

error CONTAINER1015: Unable to access the repository 'dotnet/runtime-deps' at tag '11.0.0-preview.2-noble-chiseled-extra'

The blast radius is exactly the paths this post is about. Framework-dependent publishing is fine, because it does not go down the codename-inference branch. Trimmed self-contained and PublishAot=true publishes both hit it. The fix is to stop relying on inference and name the family explicitly, which is why every command above passes it:

# .NET 11 SDK 11.0.100-preview.7. Explicit family, no codename inference.
dotnet publish --os linux --arch x64 /t:PublishContainer \
  -p PublishAot=true \
  -p ContainerFamily=resolute-chiseled

Setting ContainerBaseImage to a fully qualified name works too and bypasses ContainerFamily entirely. Pinning the family explicitly is good practice regardless: it is what stops a future SDK from silently moving your fleet to a different distro. The Ubuntu 26.04 tag rotation is the same lesson from the .NET 10 side.

The constraint that picks for you

Most teams never get to weigh sizes, because one hard constraint decides it:

Recommendation, restated

Default to framework-dependent on aspnet:11.0-<family>-chiseled. It is the cheapest image at fleet scale, it is the only mode where a runtime CVE is a base image bump instead of a release, and it is the only one that ships a single RID-agnostic artifact. Move to Native AOT on runtime-deps:11.0-<family>-chiseled when cold start or memory density is the binding constraint and your dependency tree publishes clean. Use self-contained plus trimming as the middle option when you need runtime version pinning or a non-.NET base image, understanding that it is the worst of the three for fleet-wide storage. Whichever you pick, set ContainerFamily explicitly, and chiseled the image before you optimize anything else.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back