Understanding the .NET's 'dotnet publish' command
You probably don't think much about
dotnet publish. It may be placed somewhere at the end of your CI/CD pipeline configuration, if that. Yet it's a powerful tool in the .NET toolkit that can drastically influence how your applications are deployed and executed. Underneath its simple syntax lies a plethora of options and configurations that, when used correctly, can make your deployments faster, more efficient, and more robust.
dotnet publish works is integral to being able to use it effectively because it covers the tail-end of the .NET 7 deployment flow:
- Deployment management -
dotnet publishcompiles applications, copies necessary files to a directory for deployment, and creates a .NET runtime-dependent executable.
- Application packaging - helps you bundle up the applications along with dependencies and configuration into a single unit, ensuring the application works correctly in the target runtime environment.
- Optimization -
dotnet publishsupports flags and parameters used to optimize the output executable, reduce the size, improve the startup performance, and enable self-contained deployment withour requiring the .NET runtime on the target machine.
Let's dive right in.
While there is no point in repeating the documentation, (or the parameter list you can see by running
dotnet publish --help)
there are a few flags and parameters that are worth mentioning, either because if their usefulness or because of how they work internally.
This parameter enables the .NET Core's Intermediate Language (IL) Linker to trim unused assemblies from the application, effectively reducing the deployment size of self-contained applications. Note that it will only work for , as it requires knowledge of the complete set of dependencies to accurately trim unnecessary ones. You can do this by also including the
Also beware of the build-time errors when trimming reflection-heavy code or external libraries that use
RequiresUnreferencedCodeAttribute, because the linker might remove code that is actually necessary. In such cases, consider using linker configuration files or
TrimmerRootAssembly attributes to preserve required assemblies. The compiler is quite good however and may give you advice on how to fix the issue, for instance:
[path]/SomeFile.cs([line],[character]): error IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.
The purpose of
PublishReadyToRun (aka ReadyToRun or R2R) is improving the program start-up performance using a form of ahead-of-time (AOT) compilation.
Ahead-of-time compilation via ReadyToRun often results in larger assembly sizes, which can initially increase load times but significantly reduces the number of methods compiled at runtime. This leads to performance improvements, especially in large codebase applications, and benefits not only startup times, but also the latency of first-time code usage. (like the initial response of an .NET Web API) However, as the ahead-of-time generated code isn't as optimized as JIT-produced methods, tiered compilation balances this by replacing frequently-used ReadyToRun methods with JIT-generated ones.
Starting with .NET 6, Composite ReadyToRun compilation has been introduced, allowing a set of assemblies to be compiled together, resulting in better optimizations and a reduced set of methods that bypass the ReadyToRun process. The downside, however, includes slower compilation speed and increased application file size, making Composite ReadyToRun best suited for applications that disable Tiered Compilation or Linux applications seeking optimal startup time with self-contained deployment. Also, Composite R2R is only supported for self-contained deployments.
Not many know that you can disable the globalization-aware behavior of the .NET runtime completely at build time. This is useful for instance when you know that your application is not globally aware and you want to improve the startup performance. It removes all globalization-related data from your app, making it invariant. It will also significantly reduce the size of they deployment, but at the cost of all culture-specific APIs not working correctly.
The following scenarios are affected when the invariant mode is enabled:
- cultures and culture data
- string casing
- string sorting and searching
- sort keys
- string Normalization
- Internationalized Domain Names (IDN) support
- time zone display name on Linux
dotnet publish command serves as a final assembly point for your application, preparing it for deployment. When this command is executed, several key steps occur:
- Compilation: The application is compiled into Intermediate Language (IL) code, resulting in an assembly file with a .dll extension. (or .exe/no extension for self-contained applications)
- Dependency Mapping: The command parses the project file to identify the application's dependencies. These dependencies are then documented in a
- Runtime Configuration: A
.runtimeconfig.jsonfile is created to specify the shared runtime that the application expects, along with other runtime configuration options, such as the garbage collection type.
- Dependency Copying: The application's dependencies are copied from the NuGet cache into the output folder. This ensures that all the necessary files are present for the application to run correctly on the target system.
This output, which is ready for deployment, can be transferred to any hosting system. The type of deployment specified in the project determines whether or not the hosting system needs to have the .NET shared runtime installed on it.
dotnet publish implicitly runs
dotnet restore to ensure all dependencies are up-to-date. However, this automatic restore can be disabled with the
--no-restore flag. While the
dotnet restore command can still be useful in certain situations, like CI/CD scenarios where an explicit restore process is beneficial, it isn't necessary to run before dotnet publish.
Underneath the hood,
dotnet publish leverages MSBuild and invokes the 'Publish' target. Parameters passed to
dotnet publish are passed onto MSBuild. For instance, the
-o parameters map to MSBuild's Configuration and PublishDir properties respectively. Also, if a project's
IsPublishable property is set to
false, the Publish target won't be invoked, and only the implicit
dotnet restore is executed.
Runtime Identifiers, or RIDs, are a core component of .NET's deployment process. They enable .NET applications to specify the platforms they are targeting, which can range from broad operating systems to specific versions or distributions. RIDs are used in various .NET CLI commands, including
dotnet publish, to define the runtime environment the application is intended for.
When you run the dotnet publish command with the
--runtime option, you effectively tell .NET to prepare the application for the specified runtime environment. This can range from generic identifiers such as 'linux-x64' or 'win7-x64' to more specific ones like 'ubuntu.14.04-x64' or 'osx.10.12-x64'.
The structure of a RID generally follows the pattern:
[os].[version]-[architecture]-[additional qualifiers]. Here,
[os] denotes the operating system (like 'ubuntu'),
[version] is the OS version number (not the marketing version),
[architecture] is the processor architecture ('x64', 'arm', etc.), and
[additional qualifiers] can further differentiate platforms (like 'aot' for ahead-of-time compilation).
RID definitions also have an associated "fallback graph" or "RID graph" defined in the
Microsoft.NETCore.Platforms package. This graph outlines which RIDs are compatible with each other. When NuGet restores packages, it attempts to find an exact match for the specified RID. If it can't find an exact match, it refers to this graph to identify the nearest compatible RID.
An example of a RID definition can be seen for 'osx.10.12-x64' which imports 'osx.10.11-x64'. This means that if packages for 'osx.10.12-x64' are unavailable, NuGet can use packages designed for 'osx.10.11-x64'.
However, working with RIDs requires careful consideration:
- do not parse RIDs to get component parts
- use predefined RIDs for the platform
- RIDs need to be specific; do not make assumptions based on RID values
- do not build RIDs programmatically unless absolutely necessary, as casing mismatches could cause issues, especially on case-sensitive OSs like Linux
All the available RID, as well as all future additions, are available in the dotnet/runtime repository.
One question I think it's imperative I answer is: "What the heck is the difference between running dotnet build and dotnet publish?". In short:
dotnet build is used primarily to compile your code into Intermediate Language (IL) and generate a DLL file, useful for local testing and development. It takes your source files and produces assemblies, performing essential tasks such as resolving dependencies and handling the process from code to binary.
On the other hand,
dotnet publish goes a step further. While it does everything
dotnet build does, it additionally prepares your application for deployment.
dotnet publish creates a publishable output which contains all the necessary files to run the application - the compiled application, its dependencies, the .runtimeconfig.json file, and potentially, the .NET runtime itself. Microsoft made
dotnet publish the official and recommended way of preparing .NET apps for deployment.
These are some common issues I've encountered while working with
dotnet publish in the past. (and more recently, when doing research and playing around for this article)
These arise when there are conflicting versions of the same dependency in your project or between your project and any of its dependencies. .NET tries to resolve these automatically, but there are cases where it can't, and that's when you might see a runtime error or unexpected behavior. Make sure that the versions specified in your project files match the ones that are actually being used.
-p:PublishTrimmed=true option is used with
dotnet publish, the published output will only include the parts of the .NET libraries that your app is observed to use. This is a great feature for reducing the size of your published output, but it can lead to runtime errors if code that wasn't observed during build time is needed at runtime. As I mentioned earlier in the article, an example of this might be reflection-based code, where the necessary assemblies can't be determined statically.
When you're publishing your application to be run on a specific platform, you have to specify the correct RID. If you specify the wrong RID, you could end up with an app that doesn't work on the intended platform - the operating system will complain that it doesn't understand the executable format.
.NET supports app configuration through multiple means like JSON files, environment variables, command-line arguments, etc. If your app relies on a certain configuration to be present and it's not, the publish process might succeed but the app might fail at runtime. These kind of issues are often environment-specific and might not appear until the app is deployed to the target environment. (don't ask how I know)
Since I only scratched the surface and there's much more to learn if you really want to dive deep into the topic, here are some useful links are resources:
PS. If you liked this article, please share to spread the word.