How to run a .NET Core console app as a service using Systemd on Linux (RHEL)
Niels Swimberghe - - .NET
Follow me on Twitter, buy me a coffee
This article has been updated for .NET 6 and RHEL 8 on 03/20/2022.
This article walks us through running a .NET Core console application on systemd. After running a simple console app as a service, we'll upgrade to the worker template which is designed for long running services/daemons. Lastly, we'll add the systemd package for increased integration with systemd.
To learn how to run ASP.NET Core services (web stuff) on Linux, check out "How to run ASP.NET Core as a service on Linux without reverse proxy, no NGINX or Apache".
Prerequisites:
- Red Hat Enterprise Linux (or a compatible Unix based OS)
- .NET 6 installed (Get started instructions from Red Hat)
- Sudo privileges
This walkthrough should work for most .NET Core supported Linux distributions, not just RHEL.
This walkthrough should work for older versions of .NET too, but you'll need to make small modifications.
.NET Core console application #
Let's start by making a new console application using the dotnet CLI:
mkdir ~/HelloWorld cd ~/HelloWorld dotnet new console
Verify that the console app works by running dotnet run
. The output should be "Hello World!".
If the application works, we can publish it somewhere logical such as /srv/HelloWorld:
sudo mkdir /srv/HelloWorld # Create directory /srv/HelloWorld sudo chown yourusername /srv/HelloWorld # Assign ownership to yourself of the directory dotnet publish -c Release -o /srv/HelloWorld
The published result contains an executable called 'HelloWorld' which will run the application. Let's verify we can also run the published application:
/srv/HelloWorld/HelloWorld #output will be 'Hello World!'
To run services on Linux, Systemd uses 'service unit configuration' files to configure services.
Let's create the file 'HelloWorld.service' inside our project so we can store it in source control along with our code. Add the following content to the file:
[Unit] Description=Hello World console application [Service] # systemd will run this executable to start the service # if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path ExecStart=/usr/bin/dotnet /srv/HelloWorld/HelloWorld.dll # to query logs using journalctl, set a logical name here SyslogIdentifier=HelloWorld # Use your username to keep things simple. # If you pick a different user, make sure dotnet and all permissions are set correctly to run the app # To update permissions, use 'chown yourusername -R /srv/HelloWorld' to take ownership of the folder and files, # Use 'chmod +x /srv/HelloWorld/HelloWorld' to allow execution of the executable file User=yourusername # This environment variable is necessary when dotnet isn't loaded for the specified user. # To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell. Environment=DOTNET_ROOT=/usr/lib64/dotnet [Install] WantedBy=multi-user.target
Make sure to update the 'User' to your username. Refer to the comments in the file for a basic explanation. For more in-depth information, read the freedesktop manual page or the Red Hat documentation.
Depending on how you installed .NET, the path to the dotnet executable (/usr/bin/dotnet) may be different. You can use the command `which dotnet` to find the correct path.
Alternatively, you can use `dotnet --info` which will list information about the .NET installation, and dig through the files to find the correct `dotnet` executable path.
Systemd expects all configuration files to be put under '/etc/systemd/system/'. Copy the service configuration file to '/etc/systemd/system/HelloWorld.service'. Then tell systemd to reload the configuration files, and start the service.
sudo cp HelloWorld.service /etc/systemd/system/HelloWorld.service sudo systemctl daemon-reload sudo systemctl start HelloWorld
Using the `systemctl status
` command we can view the status of the service:
sudo systemctl status HelloWorld # Output should be similar to below: # ● HelloWorld.service - Hello World console application # Loaded: loaded (/etc/systemd/system/HelloWorld.service; disabled; vendor preset: disabled) # Active: inactive (dead) # # Jan 27 19:15:47 rhtest systemd[1]: Started Hello World console application. # Jan 27 19:15:47 rhtest HelloWorld[4074]: Hello World!
In addition to the status command, we can use the 'journalctl' command to read everything our service is printing to the console. Using the unit-flag (-u), we can filter down to our HelloWorld service.
sudo journalctl -u HelloWorld # Output should be similar to below: # Jan 27 19:15:47 rhtest systemd[1]: Started Hello World console application. # Jan 27 19:15:47 rhtest HelloWorld[4074]: Hello World!
The console app only logs "Hello world!" to the console and then exits. When querying the status, systemd reports the service is inactive (dead). That's because the console app starts, runs, and immediately exits. That's not very useful, so let's add some code that will let the app run until told to stop. Update Program.cs with the following content:
while(true){ Console.WriteLine("Hello World!"); await Task.Delay(500); }
Let's publish the app again:
sudo systemctl stop HelloWorld # stop the HelloWorld service to remove any file-locks dotnet publish -c Release -o /srv/HelloWorld sudo systemctl start HelloWorld
Now we have a minimal application that is continuously running until told to stop.
If the application stops due to a crash, systemd will not automatically restart the service unless we configure that. Add the 'Restart' & 'RestartSec' options to HelloWorld.service:
[Unit] Description=Hello World console application [Service] # systemd will run this executable to start the service # if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path ExecStart=/usr/bin/dotnet /srv/HelloWorld/HelloWorld.dll # to query logs using journalctl, set a logical name here SyslogIdentifier=HelloWorld # Use your username to keep things simple. # If you pick a different user, make sure dotnet and all permissions are set correctly to run the app # To update permissions, use 'chown yourusername -R /srv/HelloWorld' to take ownership of the folder and files, # Use 'chmod +x /srv/HelloWorld/HelloWorld' to allow execution of the executable file User=yourusername # ensure the service restarts after crashing Restart=always # amount of time to wait before restarting the service RestartSec=5 # This environment variable is necessary when dotnet isn't loaded for the specified user. # To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell. Environment=DOTNET_ROOT=/usr/lib64/dotnet [Install] WantedBy=multi-user.target
Copy the service file, reload, and restart the service:
sudo cp HelloWorld.service /etc/systemd/system/HelloWorld.service sudo systemctl daemon-reload sudo systemctl restart HelloWorld
Now the service will automatically restart in case of a crash. But when the OS reboots, the application will not automatically start. To enable automatic startup of the service on boot, run the following command:
sudo systemctl enable HelloWorld
This console app works fine, but Microsoft has provided the worker template which is a more robust solution for long running services/daemons. Let's upgrade to using the worker template next.
.NET Core worker template #
Let's create a new empty directory and create the worker using the dotnet CLI:
mkdir ~/WorkerApp cd ~/WorkerApp dotnet new worker
Verify the worker is functional using the command dotnet run
.
If the application works, publish it somewhere logical such as /srv/WorkerApp:
sudo mkdir /srv/WorkerApp # Create directory /srv/WorkerApp sudo chown yourusername /srv/WorkerApp # Assign ownership to yourself of the directory dotnet publish -c Release -o /srv/WorkerApp
Let's verify we can also run the published application:
/srv/WorkerApp/WorkerApp # Output should look like this: # info: WorkerApp.WorkerApp[0] # WorkerApp running at: 01/28/2020 15:08:56 +00:00 # info: Microsoft.Hosting.Lifetime[0] # Application started. Press Ctrl+C to shut down. # info: Microsoft.Hosting.Lifetime[0] # Hosting environment: Production # info: Microsoft.Hosting.Lifetime[0] # Content root path: /home/yourusername/WorkerApp # info: WorkerApp.WorkerApp[0] # WorkerApp running at: 01/28/2020 15:08:57 +00:00 # info: WorkerApp.WorkerApp[0] # WorkerApp running at: 01/28/2020 15:08:58 +00:00
Create a service unit configuration file called "WorkerApp.service" inside our project:
[Unit] Description=Long running service/daemon created from .NET worker template [Service] # will set the Current Working Directory (CWD). Worker service will have issues without this setting WorkingDirectory=/srv/WorkerApp # systemd will run this executable to start the service # if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path ExecStart=/usr/bin/dotnet /srv/WorkerApp/WorkerApp.dll # to query logs using journalctl, set a logical name here SyslogIdentifier=WorkerApp # Use your username to keep things simple. # If you pick a different user, make sure dotnet and all permissions are set correctly to run the app # To update permissions, use 'chown yourusername -R /srv/WorkerApp' to take ownership of the folder and files, # Use 'chmod +x /srv/WorkerApp/WorkerApp' to allow execution of the executable file User=yourusername # ensure the service restarts after crashing Restart=always # amount of time to wait before restarting the service RestartSec=5 # This environment variable is necessary when dotnet isn't loaded for the specified user. # To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell. Environment=DOTNET_ROOT=/usr/lib64/dotnet [Install] WantedBy=multi-user.target
Copy the service configuration file to /etc/systemd/system/WorkerApp.service and tell systemd to reload the configuration files:
sudo cp WorkerApp.service /etc/systemd/system/WorkerApp.service sudo systemctl daemon-reload sudo systemctl start WorkerApp
Using 'journalctl', we can verify that the application is contentiously running successfully. The following 'journalctl' command will follow the output of the application. Use Ctrl-C to exit the command.
sudo journalctl -u WorkerApp -f
The .NET Core worker now runs as a systemd service, but the integration between .NET Core and systemd can bi improved on by installing the systemd-integration.
Adding .NET Core Systemd integration #
Microsoft recently added a package to better integrate with systemd. The .NET Core application will notify systemd when it's ready and when it's stopping. Additionally, systemd will now understand the different log levels when the .NET Core application logs to output.
Using the dotnet CLI, add the 'Microsoft.Extensions.Hosting.Systemd' (nuget) package:
dotnet add package Microsoft.Extensions.Hosting.Systemd
Next, we'll need to add one line to the 'Program.cs', `.UseSystemd()
`:
using WorkerApp; using Microsoft.Extensions.Hosting; IHost host = Host.CreateDefaultBuilder(args) .UseSystemd() .ConfigureServices(services => { services.AddHostedService<Worker>(); }) .Build();
Lastly, we need to update our service unit configuration file to specify 'type=Notify':
[Unit] Description=Long running service/daemon created from .NET worker template [Service] Type=notify # will set the Current Working Directory (CWD). Worker service will have issues without this setting WorkingDirectory=/srv/WorkerApp # systemd will run this executable to start the service # if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path ExecStart=/usr/bin/dotnet /srv/WorkerApp/WorkerApp.dll # to query logs using journalctl, set a logical name here SyslogIdentifier=WorkerApp # Use your username to keep things simple. # If you pick a different user, make sure dotnet and all permissions are set correctly to run the app # To update permissions, use 'chown yourusername -R /srv/WorkerApp' to take ownership of the folder and files, # Use 'chmod +x /srv/WorkerApp/WorkerApp' to allow execution of the executable file User=yourusername # ensure the service restarts after crashing Restart=always # amount of time to wait before restarting the service RestartSec=5 # This environment variable is necessary when dotnet isn't loaded for the specified user. # To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell. Environment=DOTNET_ROOT=/usr/lib64/dotnet [Install] WantedBy=multi-user.target
Let's publish, reload, and restart the service:
sudo systemctl stop WorkerApp # stop service to release any file locks which could conflict with dotnet publish dotnet publish -c Release -o /srv/WorkerApp sudo cp WorkerApp.service /etc/systemd/system/WorkerApp.service sudo systemctl daemon-reload sudo systemctl start WorkerApp
With the Systemd integration in place, we can now use the priority-flag (-p) on 'journalctl' to filter the output according to the log levels below:
LogLevel | Syslog level | systemd name |
Trace/Debug | 7 | debug |
Information | 6 | info |
Warning | 4 | warning |
Error | 3 | err |
Critical | 2 | crit |
For example, the following command will only print output with log level 4 and below:
sudo journalctl -u WorkerApp -f -p 4
We won't see much because there's nothing being logged as a warning, error, or critical.
Update the 'WorkerApp.cs' file to include 'LogWarning', 'LogError', 'LogCritical' and republish:
namespace WorkerApp; public class Worker : BackgroundService { private readonly ILogger<Worker> _logger; public Worker(ILogger<Worker> logger) { _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { _logger.LogInformation("Information - Worker running at: {time}", DateTimeOffset.Now); _logger.LogWarning("Warning - Worker running at: {time}", DateTimeOffset.Now); _logger.LogError("Error - Worker running at: {time}", DateTimeOffset.Now); _logger.LogCritical("Critical - Worker running at: {time}", DateTimeOffset.Now); await Task.Delay(1000, stoppingToken); } } }
Republish and restart the service:
sudo systemctl stop WorkerApp # stop service to release any file locks which could conflict with dotnet publish dotnet publish -c Release -o /srv/WorkerApp sudo systemctl start WorkerApp
When we run the same 'journalctl' command, we can now see the warning output as bold white text, the error and critical output as red bold text.
sudo journalctl -u WorkerApp -f -p 4 # Output should look like this: # Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Warning - WorkerApp running at: 01/28/2020 18:17:02 +00:00 # Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Error - WorkerApp running at: 01/28/2020 18:17:02 +00:00 # Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Critical - WorkerApp running at: 01/28/2020 18:17:02 +00:00 # Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Warning - WorkerApp running at: 01/28/2020 18:17:03 +00:00 # Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Error - WorkerApp running at: 01/28/2020 18:17:03 +00:00 # Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Critical - WorkerApp running at: 01/28/2020 18:17:03 +00:00
The 'UseSystemd' function will not do anything when run outside of a systemd service.
The implementation checks if the OS is a Unix system and whether the parent process is systemd.
If not, the systemd integration is skipped.
Summary #
.NET Core has good support for running services on Linux. Using the worker template, we can create a long running service/daemon that integrates well with systemd. Using the systemd hosting integration, systemd is notified when the .NET Core application is ready & also understand the different log levels in .NET.
The setup for ASP.NET Core apps is very similar, though it'll take some extra steps to ensure the web application is accessible to other machines in your network or the internet.