Skip to main content
Redhat Developers  Logo
  • Products

    Platforms

    • Red Hat Enterprise Linux
      Red Hat Enterprise Linux Icon
    • Red Hat AI
      Red Hat AI
    • Red Hat OpenShift
      Openshift icon
    • Red Hat Ansible Automation Platform
      Ansible icon
    • See all Red Hat products

    Featured

    • Red Hat build of OpenJDK
    • Red Hat Developer Hub
    • Red Hat JBoss Enterprise Application Platform
    • Red Hat OpenShift Dev Spaces
    • Red Hat OpenShift Local
    • Red Hat Developer Sandbox

      Try Red Hat products and technologies without setup or configuration fees for 30 days with this shared Red Hat OpenShift and Kubernetes cluster.
    • Try at no cost
  • Technologies

    Featured

    • AI/ML
      AI/ML Icon
    • Linux
      Linux Icon
    • Kubernetes
      Cloud icon
    • Automation
      Automation Icon showing arrows moving in a circle around a gear
    • See all technologies
    • Programming languages & frameworks

      • Java
      • Python
      • JavaScript
    • System design & architecture

      • Red Hat architecture and design patterns
      • Microservices
      • Event-Driven Architecture
      • Databases
    • Developer experience

      • Productivity
      • Tools
      • GitOps
    • Automated data processing

      • AI/ML
      • Data science
      • Apache Kafka on Kubernetes
    • Platform engineering

      • DevOps
      • DevSecOps
      • Red Hat Ansible Automation Platform for applications and services
    • Secure development & architectures

      • Security
      • Secure coding
  • Learn

    Featured

    • Kubernetes & cloud native
      Openshift icon
    • Linux
      Rhel icon
    • Automation
      Ansible cloud icon
    • AI/ML
      AI/ML Icon
    • See all learning resources

    E-books

    • GitOps cookbook
    • Podman in action
    • Kubernetes operators
    • The path to GitOps
    • See all e-books

    Cheat sheets

    • Linux commands
    • Bash commands
    • Git
    • systemd commands
    • See all cheat sheets

    Documentation

    • Product documentation
    • API catalog
    • Legacy documentation
  • Developer Sandbox

    Developer Sandbox

    • Access Red Hat’s products and technologies without setup or configuration, and start developing quicker than ever before with our new, no-cost sandbox environments.
    • Explore the Developer Sandbox

    Featured Developer Sandbox activities

    • Get started with your Developer Sandbox
    • OpenShift virtualization and application modernization using the Developer Sandbox
    • Explore all Developer Sandbox activities

    Ready to start developing apps?

    • Try at no cost
  • Blog
  • Events
  • Videos

How to deploy .NET applications with systemd and Podman

January 9, 2026
Tom Deseyn
Related topics:
.NETContainersLinux
Related products:
Red Hat Enterprise LinuxRHEL UBI

    In this article, we'll explore how to run .NET applications as systemd services using containers. We'll be using the .NET SDK to create the container image and describe various scenarios to publish the image. To deploy our service, rather than hand-crafting the service file, we'll use Podman quadlets.

    Why use a container image

    When we deploy an application as a systemd service, we need to solve problems that go beyond starting the application. We need mechanisms to distribute the application, handle upgrades, and provide (native) dependencies. Containers provide a solution to these challenges through registries that enable distribution via versioned, tagged images. These images bundle all their dependencies.

    What are Podman quadlets

    Podman quadlets are a feature added to Podman 4.4 (2023). Instead of writing a systemd service that manages a podman process, quadlets provide specialized unit files for containers, volumes, networks, and builds. The podman-systemd.unit documentation details different types.

    When systemctl daemon-reload is called, a systemd generator (that comes with Podman) will read these Podman specific unit files and create corresponding regular systemd unit files. Then you can start and stop these unit files via the usual systemctl start/stop/restart.

    Normally using systemctl enable/disable can enable and disable systemd services. These commands applies the [Install] section from the unit file which is commonly used to start the service at boot. Because Podman services are transient, the generated services can not be enabled through systemctl enable. Instead, the generator will apply the [Install] section during generation. In other words, the Podman units always have their [Install] section applied.

    Containerize a .NET application

    Since .NET 8, the .NET SDK has excellent support for containerization.

    Using the PublishContainer target, we can make container images for console and ASP.NET core applications.

    dotnet publish /t:PublishContainer

    With a .NET 8 or .NET 9 SDK, publishing a (non-ASP.NET Core) console application requires enabling the container tooling by setting /p:EnableSdkContainerSupport=true. This is no longer required with the .NET 10 SDK.

    Because our app is meant to be deployed as a systemd service, it is recommended to use the application host (from Microsoft.Extensions.Hosting) with the Microsoft.Extensions.Hosting.Systemd package. Thanks to these libraries, our application will be able to signal systemd when it is fully started, it will respond properly when systemd asks it to shut down, and it will log with formatting for the systemd journal.

    Let's start by creating a project based on the Web template and add the Microsoft.Extensions.Hosting.Systemd package to it.

    dotnet new web -o SystemdDemoService
    cd SystemdDemoService
    dotnet add package Microsoft.Extensions.Hosting.Systemd

    In Program.cs, add a call to AddSystemd to enable the systemd support:

    builder.Services.AddSystemd();

    In SystemdDemoService.csproj, we add some properties for containerizing the app which we'll discuss below the code snippet. You need to update the property values for your project.

    <PropertyGroup>
    ...
    <!-- Override the default base image used by the SDK -->
    <ContainerBaseImage>registry.access.redhat.com/dotnet/aspnet:10.0</ContainerBaseImage>
    
    <!-- Set the target image name and tags. -->
    <ContainerRegistry>quay.io</ContainerRegistry>
    <ContainerRepository>tmds/systemd-demo-service</ContainerRepository>
    <ContainerImageTags>latest</ContainerImageTags>
    
    <!-- When ContainerRepository is set, the SDK will push to a remote repository,
         otherwise it will add the image to the local container host (Podman/Docker).
         This makes the push to remote conditional on PushToRemote so we can have the
         same name for the local and remote case. -->
    <ContainerRepository Condition="'$(PushToRemote)' != 'true'">$(ContainerRegistry)/$(ContainerRepository)</ContainerRepository>
    <ContainerRegistry Condition="'$(PushToRemote)' != 'true'" />

    The ContainerBaseImage enables us to override the default base image used by the SDK. My project has a TargetFramework of net10.0, and I've set the base image to Red Hat's ASP.NET core runtime image for that version. You can read about the .NET images available from Red Hat: What you need to know about Red Hat's .NET container images.

    The ContainerRegistry, ContainerRepository, ContainerImageTags properties name the target image. The SDK will publish to a remote registry when the ContainerRepository is set. The PushToRemote property that is introduced in the project file enables us to publish locally (to Podman/Docker) or to a remote registry with a name that includes the registry in both cases.

    By default (when the project has no RuntimeIdentifier set), the application image targets Linux on the build host architecture. To build for another os/architecture, the ContainerRuntimeIdentifier property can be used. For example, ContainerRuntimeIdentifier can be set to linux-arm64 to build for Linux on arm64.

    We can publish this image to the local container host and run it:

    dotnet publish /t:PublishContainer --verbosity detailed
    podman run quay.io/tmds/systemd-demo-service:latest

    You'll see the messages from the ASP.NET Core web server startup get printed. Press Ctrl+C to stop the container.

    To push this project to the remote registry, we'll set the PushToRemote property to true. Registry credentials can be provided by calling podman login/docker login, or by setting the DOTNET_CONTAINER_PUSH_REGISTRY_UNAME/DOTNET_CONTAINER_PUSH_REGISTRY_PWORD environment variables.

    podman login <registry>
    dotnet publish /t:PublishContainer /p:PushToRemote=true --verbosity detailed

    When the image is published to the remote registry, it can be pulled by other systems so they can run it locally.

    If you don't have a registry available, you can distribute the image as a tarball instead. Set the ContainerArchiveOutputPath property to specify the tarball location.

    dotnet publish /t:PublishContainer /p:ContainerArchiveOutputPath=app-image.tar --verbosity detailed

    We can compress the archive and load it with Podman on a remote system via ssh:

    gzip -c app-image.tar | ssh <user@remote-server> podman load

    Create the container unit file

    We'll write a dotnet-demo.container unit file to deploy the .NET application image we've published in the previous section.

    System administrator unit files can be placed at /etc/containers/systemd/. For non-root containers, the files are placed at ~/.config/containers/systemd/ or /etc/containers/systemd/users/$(UID) depending on whether the user or the administrator is maintaining them.

    Create the directory ~/.config/containers/systemd/ and add dotnet-demo.container file with this content:

    [Unit]
    Description=Example .NET systemd service
    
    [Service]
    TimeoutStartSec=900
    Restart=always
    
    [Container]
    Image=quay.io/tmds/systemd-demo-service:latest
    AutoUpdate=registry
    Notify=true
    Environment=ASPNETCORE_URLS=http://*:5000
    PublishPort=9000:5000
    
    [Install]
    WantedBy=default.target

    The file has several sections that depend on the unit file type. The [Container] section is specific to container units, while [Unit] and [Install] are common to all unit files. The [Service] section applies to service.unit files, which manage processes like our podman container.

    Our unit file has these settings:

    • Unit.Description: a descriptive name for the service
    • Container.Image: the image to deploy
    • Container.AutoUpdate=registry: let the podman-auto-update.service service (when enabled) poll for updates to Container.Image and restart the service to deploy them
    • Service.TimeoutStartSec: set to 15 minutes to give this service time to pull the image on first start.
    • Service.Restart=always: systemd should restart the service if it terminates unexpectedly
    • Container.Notify=true: described below
    • Container.Environment: this shows an example of setting an environment variable. This one configures ASP.NET Core's web server to listen on port 5000 in the container.
    • Container.PublishPort: this shows an example of mapping a port from inside the container (port 5000) so it becomes available on the host (port 9000). Note that the host port is bound on all IPs. If you want to limit to localhost, you can prefix this with the host IP, for example: PublishPort=127.0.0.1:9000:5000.
    • Install.WantedBy=default.target: the service will be started on boot.

    By setting Notify=true, Microsoft.Extensions.Hosting.Systemd will:

    • Signal to systemd when the application startup is complete. This happens when the .NET Host completes its startup. This allows other units to be started after this one has fully started via Unit.Wants/Unit.After properties in their unit files.
    • Format the logging to be suitable for the systemd journal. The logging is line-based and prefixed with a syslog severity that corresponds to the .NET LogLevel. Currently, podman's journald log driver does not understand the priority prefixes causing them to appear literally in the log messages.

    Start the service

    With our container unit file in place, we can call systemctl daemon-reload. The Podman quadlet generator gets invoked as part of that command and generates a service file. Since this is a user service, we pass the --user argument to the user service manager (instead of the system service manager).

    systemctl --user daemon-reload

    We can now start the service:

    systemctl --user start dotnet-demo

    We can use curl to get a response from the ASP.NET Core application that is running in the container:

    curl http://localhost:9000

    To see the current status of the service, you can run:

    systemctl --user status dotnet-demo

    And to get its logs:

    journalctl --user --unit dotnet-demo

    To allow the service to run at boot, lingering needs to be enabled for the user. You can do this for the current user by executing:

    sudo loginctl enable-linger $(id -u)

    Users in rootless containers

    In the next section, we'll mount a directory from the host into our container. Before we do that, we need to understand how users work in rootless containers.

    Podman's architecture is built around running containers on behalf of the user who starts them. This is a key security feature which isolates containers in the exact same way as regular users.

    Container images define their own user IDs. For example, the .NET user in a container image may have a user ID (uid) of 1001. To prevent conflicts between different container users, Podman maps them to different UIDs on the host using separate ranges for each host user. This ensures one user's containers never overlap with another user's containers. These ranges are configured in /etc/subuid (and GIDs in /etc/subgid).

    $ id
    uid=1000(tmds) gid=1000(tmds) groups=1000(tmds),10(wheel)
    $ cat /etc/subuid
    tmds:524288:65536

    My user (tmds with uid 1000) has a range of 65536 uids that start at 524288.

    By default, Podman sets up the container user namespace as follows: the container uid of 0 (root in the container) is mapped to the user running podman (in my case: 1000) and subsequent uids get mapped to the range allocated in subuid. For example, a container uid of 2000 is mapped to host uid of 524288 + 2000 - 1 = 526287.

    Add a host volume

    Often you need directories accessible to the service. For example, for config files, application data, or storage that persists across restarts. We can use a specific directory, or let podman manage one for us using a named volume.

    The following shows how to mount the ~/<service-name>/data directory into the container at /data. Inside the container /data can then be used by the .NET app.

    [Service]
    ...
    ExecStartPre=mkdir -p %h/%N/data
    
    [Container]
    ...
    Volume=%h/%N/data:/data:Z,U

    Volume maps the host directory to the container directory. We're including suffixes (:Z,U) to grant the container user access to this directory. The Z suffix is for relabeling on SELinux systems to permit write access in the container. The U suffix is for changing the ownership to match the mapped container user.

    If you'd like the directory on the host system to be owned by the user that runs the service, you can add a User=0 under [Container]. This makes the container user root which podman maps to the user that runs the container.

    For the host path, we're using specifiers like %h for $HOME and %N for the unit name. These avoid using hard-coded paths and make the file more generic. The specifiers are documented in systemd.unit.

    The ExecStartPre ensures the directory exists.

    If you don't want to control the location of the directory on the host, you can let Podman manage it. To do that, instead of specifying a path, we use a name for the volume. Podman will create the directory and handle the labels and ownership. The following example shows how to use a named volume.

    [Container]
    Volume=%N-data:/data

    You can find the directory that podman uses on the host for this volume using the podman volume inspect <volume-name> command. Named volumes can be managed via the podman volume subcommands.

    Creating a dedicated service user

    We've placed the service file under our own user account. For additional isolation, we can create a separate user account to run this service.

    The following steps create a dotnet-worker user and move the service file to be run as that user.

    sudo useradd -r -m -d /var/lib/dotnet-worker dotnet-worker
    sudo -u dotnet-worker mkdir -p ~dotnet-worker/.config/containers/systemd
    sudo cp ~/.config/containers/systemd/dotnet-demo.container ~dotnet-worker/.config/containers/systemd
    sudo chown -R dotnet-worker:dotnet-worker ~dotnet-worker/.config/containers/systemd

    To run rootless containers, our dotnet-worker needs a subuid/subgid range. We're using a range that doesn't overlap with the existing ranges in /etc/subuid//etc/subgid.

    sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 dotnet-worker

    We stop and remove the service from our user:

    systemctl --user stop dotnet-demo
    rm ~/.config/containers/systemd/dotnet-demo.container
    systemctl --user daemon-reload

    For dotnet-worker to be able to run the service without a login we need a user session. We enable lingering so the session is started at boot.

    sudo loginctl enable-linger dotnet-worker

    To run systemctl commands for dotnet-worker's unit, you have two options: use the --machine=USERNAME@ argument, or use machinectl to start a shell in the user session and run the commands in that shell with systemctl --user.

    # Target the user-session using '--machine'
    sudo systemctl --user --machine=dotnet-worker@ <systemctl-command>
    # Or, start a shell:
    sudo machinectl shell dotnet-worker@ /bin/bash

    We can start the service by running:

    sudo systemctl --user --machine=dotnet-worker@ daemon-reload
    sudo systemctl --user --machine=dotnet-worker@ start dotnet-demo

    The dotnet-worker doesn't have permissions to read the journal. To get these messages we can use an administrator account and filter for its _UID, and optionally the user unit as well.

    sudo journalctl _UID=$(id -u dotnet-worker) _SYSTEMD_USER_UNIT=dotnet-demo.service

    Final thoughts

    In this article, we've demonstrated how to containerize .NET apps and deploy the container images as systemd services, using the .NET SDK and Podman quadlets. You learned how the Microsoft.Extensions.Hosting.Systemd package in our .NET application improves integration with systemd. 

    By mapping in rootless containers, you can successfully mount a host directory, making it writable for the container user. Finally, you learned how to enhance isolation by creating a dedicated user to execute the systemd service.

    Related Posts

    • Lift and shift a .NET application to OpenShift

    • What you need to know about Red Hat's .NET container images

    • .NET 10 is now available for RHEL and OpenShift

    • How to name, version, and reference container images

    Recent Posts

    • How to deploy .NET applications with systemd and Podman

    • Building effective AI agents with Model Context Protocol (MCP)

    • Kafka Monthly Digest: December 2025

    • Gain visibility into Red Hat Quay with Splunk

    • Our top articles for developers in 2025

    What’s up next?

    Learning Path Featured image for Red Hat Enterprise Linux.

    Build a bootable .NET 10 application using image mode for RHEL with Podman Desktop

    Use Podman Desktop to create a bootable .NET 10-based application using image...
    Red Hat Developers logo LinkedIn YouTube Twitter Facebook

    Platforms

    • Red Hat AI
    • Red Hat Enterprise Linux
    • Red Hat OpenShift
    • Red Hat Ansible Automation Platform
    • See all products

    Build

    • Developer Sandbox
    • Developer tools
    • Interactive tutorials
    • API catalog

    Quicklinks

    • Learning resources
    • E-books
    • Cheat sheets
    • Blog
    • Events
    • Newsletter

    Communicate

    • About us
    • Contact sales
    • Find a partner
    • Report a website issue
    • Site status dashboard
    • Report a security problem

    RED HAT DEVELOPER

    Build here. Go anywhere.

    We serve the builders. The problem solvers who create careers with code.

    Join us if you’re a developer, software engineer, web designer, front-end designer, UX designer, computer scientist, architect, tester, product manager, project manager or team lead.

    Sign me up

    Red Hat legal and privacy links

    • About Red Hat
    • Jobs
    • Events
    • Locations
    • Contact Red Hat
    • Red Hat Blog
    • Inclusion at Red Hat
    • Cool Stuff Store
    • Red Hat Summit
    © 2025 Red Hat

    Red Hat legal and privacy links

    • Privacy statement
    • Terms of use
    • All policies and guidelines
    • Digital accessibility

    Report a website issue