Managing Multiple .NET Versions in Production: A Complete Linux Tutorial

In real production environments, modern DevOps practices require running multiple .NET applications using different framework versions like .NET 6 (LTS), .NET 8 (LTS), and .NET 9 on the same Linux server. As enterprises migrate legacy systems and simultaneously build cutting-edge microservices, server consolidation becomes critical. Using a single system-wide installation tied to a specific Linux distribution’s package manager often leads to strict dependency conflicts, broken pipelines, and catastrophic downtime during routine server upgrades. For a comprehensive look at how these runtimes interact with web servers and background services, check out our complete guide on deploying .NET with Nginx and Systemd on Linux.

The professional, enterprise-grade solution to this challenge is a side-by-side (SxS) installation. Instead of switching SDKs manually, fighting with symlinks, or relying on restrictive package managers like APT (Debian/Ubuntu) or YUM/DNF (CentOS/RHEL), we install all necessary framework versions inside a centralized, optional directory: /opt/dotnet. This approach completely decouples your application runtimes from the underlying operating system's update cycle.

This setup is widely used in high-traffic enterprise production environments because it supports multiple applications running simultaneously without version collision. It ensures that an update to one application’s runtime does not inadvertently break another service hosted on the exact same hardware. Furthermore, it simplifies continuous integration and continuous deployment (CI/CD) workflows by giving deployment agents a predictable, isolated sandbox for building and executing .NET binaries.


Step 1: Create Production Directory

The standard Linux Filesystem Hierarchy Standard (FHS) explicitly suggests using the /opt directory for installing unbundled, third-party software that is not managed by the default OS package manager. By utilizing this directory, we maintain a clean and compliant server architecture. We must first create a dedicated folder and ensure the deployment user or service account has the necessary granular permissions to manage it.

Running .NET applications as the root user is a major security risk. By assigning ownership of the /opt/dotnet directory to your specific deployment user (often a dedicated service account like deployer or www-data), you adhere to the principle of least privilege. This guarantees that your application can access its runtime without requiring elevated administrative rights.

sudo mkdir -p /opt/dotnet
sudo chown -R $USER:$USER /opt/dotnet

Step 2: Download dotnet-install Script

Microsoft provides a robust, officially supported shell script specifically designed for automated and manual installations of the .NET Core and modern .NET runtimes. This script is vastly superior for production deployments because it allows you to specify exact semantic versions, specific architectures, or release "channels" without triggering interactive prompts that would block automated bash scripts or Ansible playbooks.

By downloading this script directly from Microsoft's content delivery network, you bypass the delays often associated with Linux distribution repositories, which can sometimes lag weeks behind official Microsoft security patches. The chmod +x command ensures the script has the necessary execution permissions to run within your terminal session.

sudo wget https://dot.net/v1/dotnet-install.sh
sudo chmod +x dotnet-install.sh

Step 3: Install Multiple .NET Versions

With the installation script ready, we run it for each required framework version. By pointing every command to the exact same /opt/dotnet directory using the --install-dir flag, the installer intelligently merges the Software Development Kits (SDKs) and Runtimes into a neatly structured hierarchy. It creates organized subfolders for sdk, shared, and host, preventing any files from overwriting one another.

In this example, we are installing a mix of Long Term Support (LTS) versions and Standard Term Support (STS) versions. .NET 6 and .NET 8 are LTS releases, providing three years of guaranteed patches, making them ideal for stable, enterprise-grade APIs. .NET 9 represents the cutting edge of the framework, offering the latest performance improvements and C# features for newer microservices.

# Installing Long Term Support (LTS) and Current versions
sudo ./dotnet-install.sh --channel 6.0 --install-dir /opt/dotnet
sudo ./dotnet-install.sh --channel 8.0 --install-dir /opt/dotnet
sudo ./dotnet-install.sh --channel 9.0 --install-dir /opt/dotnet

Step 4: Set Environment Variables (Temporary vs Permanent Setup)

Because we bypassed the system's default package manager, the Linux operating system does not natively know where our new .NET binaries live. For the system to recognize the global dotnet command and for compiled applications to find the appropriate runtimes, we must explicitly configure two key environment variables: DOTNET_ROOT and the system PATH.

While a temporary export command in the terminal works for the current SSH session, production servers require a permanent, foolproof configuration. If a server reboots, these variables must persist. We add these to the ~/.bashrc file (for the current user profile) or /etc/profile.d/dotnet.sh (if you require global access for all users on the server). Additionally, if you are using systemd to run your .NET applications as background daemons, you will need to specify the Environment=DOTNET_ROOT=/opt/dotnet directive directly inside your service files.

# Add these lines to ~/.bashrc
export DOTNET_ROOT=/opt/dotnet
export PATH=$PATH:/opt/dotnet
# Apply changes
source ~/.bashrc

Step 5: Verify Installed Versions

After modifying your profile script, verification is a critical step. Verification ensures that the .NET Host (the underlying "muxer" executable that routes traffic) can successfully see all the installed SDKs and base runtimes within the /opt/dotnet path. If this step fails, it generally means your PATH variable was not applied correctly.

The first command checks the installed Software Development Kits, which are required if you intend to compile code or run CI/CD builds directly on this server. The second command checks the installed Runtimes (including the base .NET runtime and the ASP.NET Core runtime), which are the actual engines responsible for hosting your compiled production web applications and APIs.

dotnet --list-sdks
dotnet --list-runtimes

How Multi-Version .NET Works in Production

The beauty and architectural elegance of this setup lies in the .NET Host Muxer. When you execute a compiled framework-dependent application, the system doesn't just guess which framework to use. Instead, it reads the MyApp.runtimeconfig.json file that is automatically generated and bundled alongside your application DLLs during the build process. This configuration file acts as a manifest, telling the host system exactly which base runtime and minor version your code was targeted for.

Because all of your installed runtimes securely coexist in the /opt/dotnet/shared directory, the .NET host intelligently evaluates the manifest and automatically selects the highest compatible patch match based on roll-forward policies:
App A (compiled for .NET 6) -> Safely launches using the isolated 6.0.x Runtime.
App B (compiled for .NET 8) -> Safely launches using the isolated 8.0.x Runtime.

This architectural design is what allows engineering teams to scale microservices independently. You can deploy and upgrade one specific service to take advantage of .NET 9 performance gains, while safely keeping older, critical legacy services untouched on .NET 6. All of these applications will run concurrently on the same hardware instance, binding to different internal ports (like 5000, 5001, 5002) and routing traffic securely through a reverse proxy like Nginx or Apache.


Advantages of /opt/dotnet Multi-Version Setup

  • Total Independence: No system-wide SDK switching or symlink manipulation is required for building or running apps. The framework resolves itself based entirely on the application's config file.
  • Seamless Simultaneity: Fully supports multiple .NET applications running at the exact same time without locking files or causing port-binding conflicts at the runtime level.
  • Production Stability: A centralized installation in /opt/dotnet strictly follows Linux administration best practices for third-party software, completely protecting your runtimes from accidental `apt upgrade` deletions.
  • Massive Scalability: Greatly simplifies the environment setup for automated CI/CD runners (like GitLab Runners or GitHub Actions) and bare-metal microservice architectures.
  • Effortless Cleanup: Unlike package managers that leave orphaned dependencies behind, to remove an older version (e.g., when .NET 6 reaches End of Life), you simply delete its specific semantic folder within /opt/dotnet/shared and /opt/dotnet/sdk.

Frequently Asked Questions

Can multiple .NET versions run together?

Yes, absolutely. The modern .NET ecosystem is fundamentally designed from the ground up for side-by-side (SxS) installation. Multiple runtimes can be active in system memory simultaneously, serving different applications, without ever interfering with each other's execution context or memory space.

Do I need to switch SDK manually?

No, manual switching is a thing of the past. If you are actively compiling and developing on the server, you can place a global.json file in your specific project root. This file tells the global dotnet CLI command exactly which SDK version to invoke for that specific folder. For compiled production binaries, the embedded runtimeconfig.json handles runtime resolution completely automatically.

Is /opt/dotnet recommended for production?

Yes. In fact, it is widely considered the cleanest, most reliable, and most secure production setup for enterprise Linux servers. It keeps the application hosting environment fully decoupled from the OS package manager, giving DevOps teams complete control over patching and upgrading schedules.

Does this setup support .NET 9?

Yes, the script supports all current and future releases. Simply change the --channel argument in the bash install script to 9.0 (or any future version like 10.0) to seamlessly add the latest framework capabilities to your existing environment without impacting your current running applications.


f X W