In this article, we will deploy a .NET app as a systemd service. Instead of installing the application directly on the system, we will take a different approach by using a container instead.

Why use a container?

When we deploy an application as a systemd service, we need to solve problems that go beyond starting the application. Containers solve problems such as: how the application gets distributed, how upgrades are managed, and how native dependencies are provided.

Container images are distributed through registries. Images are versioned using tags, allowing for easy upgrades and including all the dependencies. When deploying to Kubernetes, packing up your .NET application as a container for systemd gives it the benefits of being self-contained and universally deployable.

Creating an application

As an example for this article, we will use an application based on the Worker template. The Worker template is a console application based on Microsoft.Extension.Hosting and provides the configuration and dependency injection services you know from ASP.NET Core. Worker is meant for long-running applications that aren't web applications. You can learn more about the Worker template in Worker Services. What we do in this article also applies to ASP.NET Core.

Create the application as follows:

$ dotnet new worker -o worker
$ cd worker

If you open up the application folder, you'll find the Worker.cs file, which implements a simple long-running job that prints a message to the console once a second.

Execute dotnet run to see the application in action. When you've seen a couple of messages, press Ctrl-C to terminate the application.

The .NET Core 3.0 added support for systemd services. Specifically, the application host (from Microsoft.Extension.Hosting) can notify systemd upon startup. For a web application, notifying systems indicates that the application is ready to serve requests. In our example, the notification means that all the hosted services, such as the Worker class, have started for our worker.

If you want a BackgroundService such as Worker.cs to do asynchronous initialization that affects readiness, you can allow time for that activity by overriding the base StartAsync method, as shown in the following code:

public async override Task StartAsync(CancellationToken cancellationToken)
  // Perform startup.
  await Task.Delay(500, cancellationToken);

  // Call base method.
  await base.StartAsync(cancellationToken);

In .NET 7, systemd support was extended to include .NET running in a container managed by systemd. If you are using an earlier version of .NET, systemd can run your container, but it won't know when your application is fully ready.

To enable systemd integration, add the Microsoft.Extensions.Hosting.Systemd package:

$ dotnet add package Microsoft.Extensions.Hosting.Systemd

Next, call UseSystemd on your application's IHostBuilder:

IHost host = Host.CreateDefaultBuilder(args)
  .ConfigureServices(services =>
+ .UseSystemd()

You can add this code to any .NET application to make it runnable with systemd. If the application doesn't execute from systemd, the UseSystemd call is a no-op.

Creating a container image

Now we're going to create a container image. There are a number of ways you can do this. You can hand-craft a Containerfile (Dockerfile) based on Microsoft's Containerize a .NET app tutorial. You can use the new SDK feature that builds an image for you: as described in Announcing built-in container support for the .NET SDK. And a third option is to use the dotnet build-image global tool that I introduced in the article Containerize .NET applications without writing Dockerfiles.

Feel free to pick the option you prefer. I'm going to use the third option.

Install the global tool:

$ dotnet tool install -g dotnet-build-image

And now, create the image:

$ dotnet build-image -t awesome-worker-app:latest -b ubi

We're passing two arguments. awesome-worker-app:latest is the name of the image. ubi indicates that creation should be based on Red Hat's UBI images. You can pick another base, such as alpine or jammy-chiseled.

For this tutorial, we'll deploy the systemd unit on the local system. To deploy it on several systems, you can set the tag to match the image registry (for example: -t, and add the --push argument to push the image to the registry after it was built.

The following command runs the image locally:

$ podman run –rm awesome-worker-app:latest

As this executes, you'll see the messages from the containerized .NET application, similar to when executing dotnet run. Terminate the container by pressing Ctrl+C.

Creating a systemd unit

We'll use Podman to generate a systemd unit file for us.

Start the container by running the following command:

$ podman run --name awesome-worker-app --rm --label "io.containers.autoupdate=local" localhost/awesome-worker-app:latest

This command is similar to the one we used earlier. We're using the fully qualified name of the image (by adding the localhost/ prefix), and we've added a label (io.containers.autoupdate), which we'll discuss in a moment. If your image is deployed to a remote registry, set the label value to registry instead of local.

If your application uses .NET 7, which supports systemd containers, you can add --sdnotify=container to use that support.

In another terminal, run:

$ podman generate systemd --new -n -f --start-timeout 600 awesome-worker-app

This will write out a file named container-awesome-worker-app.service with the following content:

# container-awesome-worker-app.service
# autogenerated by Podman 4.2.0

Description=Podman container-awesome-worker-app.service

ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStart=/usr/bin/podman run \
    --cidfile=%t/%n.ctr-id \
    --cgroups=no-conmon \
    --rm \
    --sdnotify=conmon \
    -d \
    --replace \
    --name awesome-worker-app \
    --label io.containers.autoupdate=local localhost/awesome-worker-app:latest
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id


This systemd file isn't specific to your system. It can be used to deploy the image anywhere.

We've provided a large --start-timeout value of 10 minutes to allow the system time to pull the image (in case the image is hosted on a remote registry).

The label we added through the --label argument shows up in the ExecStart command. This label is detected by a podman auto-update command, which monitors all containers with this label. If a new image is available, the command pulls it and restarts the systemd unit.

Podman ships with a timer that triggers the update daily at midnight. If the new image fails to deploy, Podman even rolls back the container to the previous version. Failure detection works best if you use the --sdnotify=container option (with .NET 7).

Now, if you host the image in an image registry solely using this unit file, you can deploy the .NET application to a large number of systems and even have it update automatically.

Deploying the service

You can deploy this unit with the system units at /usr/systemd/system. We'll deploy it as part of our user's systemd services as follows:

$ mkdir -p ~/.config/systemd/user
$ cp container-awesome-worker-app.service ~/.config/systemd/user/awesome-worker-app.service
$ systemctl --user daemon-reload
$ systemctl --user start awesome-worker-app

That's it—your service is up and running.

You can get the logs of the service by running the following:

$ journalctl --user -f -u awesome-worker-app

If you'd like this service to start at boot, you have to enable lingering for your user and enable the service as follows:

$ sudo loginctl enable-linger $(id -u)
$ systemctl -- user start awesome-worker-app

Triggering an update

To finish the example, let's see auto-update in action.

First, change the message that is printed in Worker.cs:

    while (!stoppingToken.IsCancellationRequested)
-     _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
+     _logger.LogInformation("Updated worker running at: {time}", DateTimeOffset.Now);
      await Task.Delay(1000, stoppingToken);

Then rebuild the image:

$ dotnet build-image -t awesome-worker-app:latest -b ubi

The new image is now available. A system that has the auto-update timer enabled will start using it at midnight. But you don't have to wait for that: to update the image the right way, just invoke podman auto-update:

$ podman auto-update
UNIT                        CONTAINER                          IMAGE                      POLICY  UPDATED
awesome-worker-app.service  342942f00932 (awesome-worker-app)  awesome-worker-app:latest  local   true

The command updates the service, and you can see the change in the journal:

$ journalctl --user -f -u awesome-worker-app

Containers and new .NET features simplify systemd

This article has covered how to deploy .NET applications as systemd services using containers. Containers offer a convenient way to distribute, deploy, and update your application. You've also learned that applications using .NET 7 support notifying systemd about their readiness when running in containers.

Last updated: September 19, 2023