.NET Deployment Models and Features With Examples

If you've ever wanted a better understanding of: Self-contained, trimming, single file, Native AOT, and ReadyToRun for .NET deployments, this post will take you through with worked examples.

.NET Deployment Models and Features With Examples

The .NET documentation is great, however sometimes it's easier to grasp a situation with examples you can see. In this post we'll be looking at the .NET Application Publishing documentation in the context of a .NET console tool I wrote: GameboyColour-Decolouriser - it takes in one Gameboy Color image and turns it into the original 4-green-tone Gameboy palette.

Everything in this post is be annotated with direct links to the said documentation. Note: that this post will also skew toward optimising for file size, but I'll give you enough links and ideas to optimise for speed.

Key Terms

First let's get some vocabulary so we can fully understand what's coming up.

.NET CLI

It took me awhile to get used to it, but I love the new(ish) .NET CLI. And for this post we'll be specifically calling publish a lot:

dotnet publish -c Release

With the deployment examples coming up, they will include both this CLI version and the .csproj version. Either one will work.

Project file .csproj

The classic .csproj project file. Depending on your Visual Studio version, you can right click the project and go Edit or open the .csproj with another file editor. For this we'll just be looking at adding our flags to a PropertyGroup and in GameboyColor-Decolouriser, I'll just be adding our flags into the existing PropertyGroup, which for the project (by default) looks like this:

<PropertyGroup>
	<OutputType>Exe</OutputType>
	<TargetFramework>net7.0</TargetFramework>
	<ImplicitUsings>enable</ImplicitUsings>
	<Nullable>enable</Nullable>
	<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

With the deployment examples coming up, they will include both this .csproj version and the CLI version. Still, either one will work.

Framework dependent executable

Framework dependent executables gives us an executable that uses the .NET installed on the client's machine.

RID

Runtime Identifier, used to identify the target platform where the application will run when we do the publish command. We'll be using these when we specifically want to do builds for Windows, OSX, or Linux.

For these examples, we will be mostly going with win-x64 for a 64bit Windows system.

GameboyColour-Decolouriser

A simple tool that converts Gameboy Color images to old green-y Gameboy images. You can find my repo here: GameboyColour-Decolouriser, specifically at this check in as a base. Adding some colour, check out below the four stages it does to decolourise:

Four stages of decolouring.

Why I'll be using this as an example:

  • It targets .NET 7 and I will be using Preview 5
  • Some of the items below will only work with console projects
  • It uses SkiaSharp (an external library) to do some image processing
  • Running dotnet publish -c Release produces the following output:
25 files, 22 folders, and 40.8MB.

We'll be using this as our baseline. So let's get into the real content and look at deployments!

Self-Contained

You want self-contained when you want full control of what .NET version you use with your application and what better way than to bring all of .NET with you. Of course this comes with downsides such as:

  • Your application needs to be correctly built for your client's system
  • It doesn't come with security patches
  • BIG as it contains what it needs to run .NET itself

Command

If you prefer CLI over .csproj:

dotnet publish -c Release -r win-x64 --self-contained true

Technically if we put forward a runtime, -r, the .NET CLI with automatically create the application as self-contained, but it's nice for the clarity (and I swear I read somewhere that it's better to be explicit in case of breaking changes in the future, but I can't find that documentation for the life of me).

Self-contained is true by default if a runtime is specified, via the .NET CLI help command.

.csproj

If you prefer .csproj over CLI, add these to your <PropertyGroup>:

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

Then, to publish:

dotnet publish -c Release

Result

40.8MB -> 78.5MB

We end up with a big publish folder as expected because we're dragging along all the .NET we need to run this on a 64 bit Windows machine.

202 files, 13 folders, and 78.5MB.

What if we could reduce the size we have to deploy? Trimming is our friend.

Trimming

Only available for self-contained apps.

Trimming is wonderful. Not using that function? Throw it away at compile time. Trimming is all about removing excess code in order to produce a smaller file. Sure, there are a few things trimming can't handle like COM marshalling and dynamic assembly loading but outside of that, it puts in work. You might also have to take into account and think about the libraries and assemblies you depend on as they might not be trim friendly.

You can somewhat influence the trimming with some given options such as removing unused members or being more cautious and removing only completely unused assemblies.

Command

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishTrimmed=true

The command looks a little different with a -p: as that indicates an MS Build property.

.csproj

Note that since self-contained is a requirement for trimming, those elements are included as well.

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>

Then, to publish:

dotnet publish -c Release

Result

78.5MB -> 29.5MB

Considerably smaller!

78 files, 13 folders, and 29.5MB.

Single File

I feel turning your application into a single file .exe is when it starts to come together. If you want to read more about the fascinating design choices from the .NET team, check out this design doc.

For single file applications we can choose either:

  • The framework-dependent deployment model (user needs .NET installed on their machine)
  • Self-contained applications (.NET is baked into your .exe) + since it's self-contained, you can trim!

And we'll look at these in turn with some fun extra flags to see what they do:

Framework-dependent deployment

Remember, they'll need .NET installed on their machine.

For the .NET CLI:

dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true

Or for .csproj:

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>

Note this is our first usage of self-contained = false.

29.5MB -> 10.7MB

3 files, and 10.7MB.

Hold on, this is more than a single file. What's going on?

  1. The .pdb file contains useful debugging information like mapping calls to source code. You may want it to aid debugging of the release code. Or maybe you're doing a little side project and don't want them at all. For the sake of our example, I'm going to have them as embedded.
  2. The libSkiaSharp.dll is native code, which by default isn't bundled, meaning if we do want to bring it in, we need to use IncludeNativeLibrariesForSelfExtract.

Putting both together, we get this command:

dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded

Or:

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>

29.5MB -> 10.7MB

1 file, and 10.7MB.

Woohoo! Truly single file.

Self-contained

Let's do another version and bring .NET with us.

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true

Or

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>

Pretty much as we'd expect. A big .exe and the same two files as previously.

29.5MB -> 73.6MB

3 files, and 73.6MB.

Let's use the same flags we did before to create a true single file.

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded

Or

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>

29.5MB -> 73.6MB

1 file, and 73.6MB.

And there we go, our self-contained version is a single file now too!

Trimming self-contained single file apps

Hinted at before, we can use trimming with our self-contained single file apps. Let's see what the difference is.

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -p:PublishTrimmed=true

Or:

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
<PublishTrimmed>true</PublishTrimmed>

76.3MB -> 24.4MB

1 file, and 24.4MB.

Compressing self-contained single file apps

Mentioned earlier, we can compress our single file apps at the cost of a slower start up while decompressing occurs.

dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -p:PublishTrimmed=true -p:EnableCompressionInSingleFile=true
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
<PublishTrimmed>true</PublishTrimmed>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>

24.4MB -> 15.3MB

1 file, and 15.3MB.

Native AOT

While AOT has been around in .NET for awhile, it's been more of a niche topic or an experiment. However, .NET 7 Native AOT is hitting mainstream (at the time of writing this .NET 7 Preview 5 is out) and here's how to use it. Do note that at the time of writing this, it's still new and there's all sorts of libraries that aren't fully compatible with it yet.

But first, what does AOT do for us?

Native AOT apps start up very quickly and use less memory. Users of the application can run it on a machine that doesn't have the .NET runtime installed.

Just looking at vanilla AOT, without anything from earlier in this post:

dotnet publish -c Release -r win-x64 -p:PublishAot=true

Or

<PublishAot>true</PublishAot>

40.8MB -> 92.6MB

5 files, 92.6MB.

Sweet, we now have the benefits of AOT working for us like

  • Faster startup times
  • Less memory usage
  • We can use this in non JIT environments

But what if we started mixing in some of the work we've seen previously?

We can make it better

So we have the speed and efficiency, but with a big file size. Time to optimise. We'll look at what goes into it, then talk about the documentation after.

dotnet publish -c Release -r win-x64 -p:PublishAot=true -p:TrimmerDefaultAction=link -p:InvariantGlobalization=true -p:IlcGenerateStackTraceData=false -p:IlcOptimizationPreference=Size -p:DebugType=none -p:IlcFoldIdenticalMethodBodies=true -p:IlcTrimMetadata=true

Or:

<!-- Normal .NET 7 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishAot>true</PublishAot>
<TrimmerDefaultAction>link</TrimmerDefaultAction>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<DebugType>none</DebugType>

<!-- Still works from the experimental AOT version -->
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<IlcTrimMetadata>true</IlcTrimMetadata>
	
<!-- Still works from the experimental AOT version, but high risk -->
<!-- <IlcDisableReflection>true</IlcDisableReflection> -->

92.6MB -> 14.5MB

4 files, 14.6MB.

Much smaller!

Now, for that documentation:

  • The normal docs for optimising
  • The experimental AOT docs for optimisations which seem to still work and have even more interesting flags such as the one used above, IlcFoldIdenticalMethodBodies.
  • The hammer, the docs for killing off reflection. While this can create dramatic size cutting, be careful on this as .NET relies a lot on reflection, and while other options for AOT might make things weird, this option might just kill things entirely.

You might notice SkiaSharp.dll is still there. It can't even be bundled with IncludeNativeLibrariesForSelfExtract set to true. Why? My knowledge here gets a bit murky but it's to do with static native libraries (SkiaSharp is a wrapper around Skia). Here's some evidence on static libraries not bundling with NativeAOT:

When we build the application, we can see all the different runtimes SkiaSharp handles:

With each folder containing the binary for that RID. For example, the osx version:

So what if I used something else that didn't have static libraries? If I convert the code to use ImageSharp instead of SkiaSharp and run the same fancy AOT arguments, the output ends up being the single file .exe we expect:

1 file (2 unnecessary .json files), and 9.44MB.

Neat stuff right?

💡
As they're optional, if you don't want the two .json files, you can use-p:GenerateRuntimeConfigurationFiles=false or <GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>

ReadyToRun

As a bookend, we'll look at ReadyToRun. An almost-AOT deployment model as it combines AOT and JIT scenarios.  

We'll only be quickly looking at this and I'll be using PublishReadyToRunComposite rather than the default PublishReadyToRun because more efficient is more fun. I'll also be using the ImageSharp version we just looked at for NativeAOT. AND going straight to the small file size version.

dotnet publish -c Release -r win-x64 -p:PublishReadyToRunComposite=true -p:SelfContained=true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:DebugType=none -p:EnableCompressionInSingleFile=true -p:InvariantGlobalization=true

Or:

<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRunComposite>true</PublishReadyToRunComposite>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<DebugType>none</DebugType>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<InvariantGlobalization>true</InvariantGlobalization>

40.8MB -> 12.7MB

1 file, and 12.7MB.

To Conclude

I really like the direction that .NET is taking with deployment models. Like with a lot of things in .NET there are many ways to do a similar task. Having these deployment options (with so many arguments!) is fascinating as each one can dramatically impact the final publishing result and perhaps, creating a better publish for your needs.

I also super love the direction NativeAOT is going too! ⭐