Run .NET in Production with systemd

Khimananda Oli 8 min read Programming and Languages
Run .NET in Production with systemd

By Khimananda Oli | Last reviewed: August 2026

Deploying ASP.NET Core on Linux without a proper process manager is a common cause of preventable outages and security gaps. To reliably run .NET in production with systemd, you must configure a native unit file that handles automatic restarts, structured logging via journald, and strict resource isolation. This approach replaces fragile shell scripts and integrates your application directly into the operating system's lifecycle.

How do you configure a systemd unit file to run .NET in production?

The foundation of any stable Linux service is a correct unit file. When you manage Linux daemons with systemd, precision matters more than convenience. A frequent mistake I see in audits is running applications as root or relying on default working directories that change between reboots. Your unit file must be explicit about paths, users, and dependencies.

systemd (PID 1)Service Managerdotnet App.dllKestrel / WorkerjournaldStructured LogsHardening & Isolation LayerUser=appuserProtectSystem=strictNoNewPrivileges=trueReadWritePaths=/var/log/appEnvironmentFile=/etc/app/envRestart=always (5s delay)Security boundaries enforced by kernel namespaces and cgroups
Systemd architecture for running .NET in production with isolated logging and hardened execution context

Below is a production-grade unit file template. Save this to /etc/systemd/system/myapi.service:

[Unit]
Description=MyCompany Production API (.NET 9)
After=network-online.target postgresql.service
Wants=network-online.target
Documentation=https://docs.microsoft.com/en-us/dotnet/core/deploying/linux-systemd

[Service]
Type=notify
User=myapi-svc
Group=myapi-svc
WorkingDirectory=/opt/myapi/current
ExecStart=/usr/bin/dotnet /opt/myapi/current/MyApi.dll --urls=http://127.0.0.1:5000
Restart=always
RestartSec=5
KillMode=mixed
TimeoutStopSec=30

# Environment & Secrets
Environment=ASPNETCORE_ENVIRONMENT=Production
EnvironmentFile=-/etc/myapi/app.env

# Hardening
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
ReadWritePaths=/var/log/myapi /opt/myapi/data
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

Critical configuration choices explained

  • Type=notify: Modern .NET apps support systemd notification. This tells systemd the app is "ready" only after Kestrel binds successfully, preventing premature load balancer routing.
  • WorkingDirectory: Never rely on defaults. Relative paths in appsettings.json or file logging will break if this is missing.
  • KillMode=mixed: Sends SIGTERM only to the main process, allowing child processes (like background workers) to shut down gracefully within TimeoutStopSec.
  • EnvironmentFile with dash prefix: The - means systemd won't fail if the file is absent during testing, but you should always have it in production.

How do you securely manage secrets and environment variables for .NET services?

When you run .NET in production with systemd, never embed connection strings or API keys directly in the unit file. Unit files are often world-readable or stored in version-controlled infrastructure repositories. Instead, use an environment file with restricted permissions.

  1. Create the secrets file: sudo nano /etc/myapi/app.env
  2. Add key-value pairs without spaces around the equals sign:
    ConnectionStrings__Default=Host=db.internal;Database=prod;Username=app;Password=s3cur3P@ss
    Jwt__SigningKey=base64-encoded-key-here
    Redis__ConnectionString=redis.internal:6379,password=r3d1sP@ss
  3. Lock down permissions so only the service user can read it:
    sudo chown myapi-svc:myapi-svc /etc/myapi/app.env
    sudo chmod 400 /etc/myapi/app.env

This pattern aligns with Ubuntu security hardening best practices and satisfies SOC 2 evidence requirements for secret management. During audits, I can demonstrate that secrets are never in source control and are accessible only to the specific service identity. For higher security environments, consider integrating HashiCorp Vault or AWS Secrets Manager and injecting values at startup via a wrapper script, but the environment file approach covers most compliance needs without adding operational complexity.

What hardening directives protect .NET applications running under systemd?

Running as a non-root user is table stakes. Real protection comes from namespace isolation and capability restrictions. These directives leverage Linux kernel features to limit blast radius if your application is compromised.

DirectivePurposeTrade-off / Note
ProtectSystem=strictMakes entire filesystem read-only except whitelisted pathsMust explicitly list writable dirs in ReadWritePaths
PrivateTmp=yesGives service its own /tmp namespacePrevents temp file conflicts and symlink attacks
NoNewPrivileges=yesBlocks privilege escalation via setuid/setgid binariesEssential; rarely breaks legitimate .NET apps
RestrictRealtime=yesPrevents realtime scheduling abuseSafe for web APIs and workers
MemoryDenyWriteExecute=yesBlocks W^X violations (JIT exception needed)Disable for .NET — JIT requires WX memory
ProtectKernelTunables=yesRead-only /proc/sys, /sysPrevents runtime kernel parameter tampering
❌ Unprotected ServiceRuns as root → full system access on RCEShared /tmp → symlink & race condition attacksWritable /etc, /usr → persistence & backdoorsSecrets in unit file → credential leakageNo restart policy → extended downtime✅ Hardened ServiceDedicated user + NoNewPrivilegesPrivateTmp → isolated temp namespaceProtectSystem=strict → read-only rootfsEnvironmentFile (0400) → secret isolationRestart=always + watchdog → self-healingHardening reduces attack surface by ~80% per CIS benchmarks
Attack surface comparison: unprotected root service versus hardened systemd unit for .NET production workloads

A critical note for .NET specifically: do not enable MemoryDenyWriteExecute=yes. The .NET JIT compiler requires writable-and-executable memory pages. Enabling this will crash your application immediately. If you need memory safety guarantees, use ahead-of-time (AOT) compilation in .NET 9+, which eliminates JIT and allows stricter memory policies.

How do you integrate .NET logging with journald and monitor service health?

When you run .NET in production with systemd, abandon file-based logging. Journald provides structured metadata (PID, UID, timestamp, unit name) automatically, integrates with structured logging best practices, and handles rotation without external tools like logrotate.

Configure Serilog or NLog for systemd journal

Add the systemd journal sink to your .NET application:

// Program.cs
builder.Host.UseSerilog((ctx, cfg) => cfg
    .ReadFrom.Configuration(ctx.Configuration)
    .WriteTo.SystemdJournal());

This outputs structured fields that journald indexes natively. Query logs with:

# All logs from your service
journalctl -u myapi.service --since "1 hour ago"

# Filter by structured field
journalctl -u myapi.service MESSAGE_ID=RequestCompleted

# Follow live output
journalctl -u myapi.service -f

# Export JSON for SIEM ingestion
journalctl -u myapi.service -o json-pretty

Health checks and watchdog integration

Enable systemd watchdog support in your .NET app to signal readiness and liveness:

// After Kestrel starts successfully
if (Environment.GetEnvironmentVariable("NOTIFY_SOCKET") != null)
{
    // Signal systemd that startup is complete
    Systemd.NotifyReady();
    
    // Optional: periodic heartbeat
    _ = Task.Run(async () =>
    {
        while (!ct.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(30), ct);
            Systemd.NotifyWatchdog();
        }
    });
}

In your unit file, add WatchdogSec=60. If your app fails to call NotifyWatchdog() within 60 seconds, systemd considers it hung and triggers Restart=always. This catches deadlocks and thread pool exhaustion that HTTP health checks miss.

What is the correct deployment workflow for updating .NET systemd services?

Zero-downtime deployments require coordination between your CI/CD pipeline and systemd. Never overwrite running binaries directly. Use atomic directory swaps:

1. Publishdotnet publish -c Release2. StageCopy to /opt/myapi/releases/v2.3.13. Symlinkln -sfn releases/v2.3.1 current4. Reload & Restartsystemctl reload-or-restartRollback Procedure (Instant)ln -sfn /opt/myapi/releases/v2.3.0 /opt/myapi/currentsystemctl restart myapi.servicePrevious release directory remains intact for forensic analysisVerification Commandssystemctl status myapi.service → confirm active (running)journalctl -u myapi.service --since "2 min ago" → check startup logscurl http://127.0.0.1:5000/health → validate endpoint response
Atomic deployment sequence for .NET systemd services with instant rollback capability
#!/bin/bash
# deploy.sh — Atomic .NET deployment script
set -euo pipefail

VERSION="$1"
APP_NAME="myapi"
BASE_DIR="/opt/${APP_NAME}"
RELEASE_DIR="${BASE_DIR}/releases/${VERSION}"

# 1. Stage new release
mkdir -p "${RELEASE_DIR}"
cp -r ./publish/* "${RELEASE_DIR}/"

# 2. Atomic symlink swap
ln -sfn "${RELEASE_DIR}" "${BASE_DIR}/current"

# 3. Graceful restart (waits for in-flight requests)
systemctl reload-or-restart "${APP_NAME}.service"

# 4. Verify health within timeout
for i in {1..30}; do
    if curl -sf http://127.0.0.1:5000/health >/dev/null; then
        echo "✅ Deployment ${VERSION} healthy"
        exit 0
    fi
    sleep 1
done

echo "❌ Health check failed — rolling back"
ln -sfn "$(readlink -f ${BASE_DIR}/current/../)" "${BASE_DIR}/current"
systemctl restart "${APP_NAME}.service"
exit 1

This pattern ensures that a failed deployment can be rolled back in under two seconds by simply repointing the symlink. Keep at least three previous releases for quick rollback and forensic analysis. Clean up old releases via a weekly cron job or systemd timer to prevent disk exhaustion.

Run .NET in Production with systemd: Next Steps

Getting the unit file right is just the beginning. To truly run .NET in production with systemd at scale, layer in observability from day one. Pair your systemd journal integration with Prometheus and Grafana for full-stack monitoring to correlate service restarts with metric anomalies. Define SLOs around availability and latency before your first production incident, not after. If you're managing multiple services across several servers, consider automating server setup with Ansible to ensure every node has identical, audited unit files. Need help designing a compliant, resilient .NET hosting platform? Reach out to discuss your infrastructure requirements.

Frequently Asked Questions

Create a unit file at /etc/systemd/system/myapp.service specifying Type=notify, ExecStart pointing to your published DLL or executable, and User set to a dedicated service account. Always run systemctl daemon-reload after creating or modifying the file to apply changes.

Use the absolute path to the published executable or dotnet binary followed by the DLL path. Avoid relative paths since systemd runs from root directory. Example: ExecStart=/usr/bin/dotnet /opt/myapp/MyApp.dll ensures consistent resolution across reboots and environment changes.

Use Type=notify with Microsoft.Extensions.Hosting.Systemd package for accurate readiness signaling. Type=simple marks the service active immediately before startup completes, causing false health checks during deployments. Notify type waits for sd_notify ready signal before reporting active status.

Define variables using Environment= or EnvironmentFile= directives in the service unit. EnvironmentFile=/etc/myapp/app.env loads key-value pairs securely without exposing secrets in process listings. Never hardcode connection strings directly in the unit file for security and maintainability reasons.

Yes, configure Restart=on-failure and RestartSec=5 in your unit file. Systemd monitors the main process and triggers automatic restarts on non-zero exit codes or signals. Combine with StartLimitBurst and StartLimitIntervalSec to prevent infinite crash loops during persistent failures.

Use journalctl -u myapp.service -f to stream live logs or add --since "1 hour ago" for historical entries. Structured logging from Serilog or NLog integrates natively with journald. Avoid writing to separate log files when using systemd to centralize observability.

Create a dedicated system user with no login shell and minimal permissions. Specify User=myapp and Group=myapp in the unit file. Never run production .NET services as root since compromised applications gain full system access through inherited privileges.

Set TimeoutStopSec=30 and ensure your app handles SIGTERM via IHostApplicationLifetime. Systemd sends SIGTERM first, waiting the specified timeout before forcing SIGKILL. Configure Kestrel shutdown timeout to match or exceed systemd value to prevent dropped requests during deploys.

Yes, use MemoryMax=2G and CPUQuota=80% directives to enforce resource boundaries. Systemd cgroups prevent runaway .NET processes from starving other services. Monitor actual usage with systemctl status myapp.service which displays current memory consumption against configured limits.

Implement IOptionsMonitor for hot configuration reloading within .NET itself. Systemd cannot reload app config independently since it only manages process lifecycle. Use ExecReload=/bin/kill -HUP $MAINPID only if your application explicitly handles SIGHUP for custom reload logic.

Grant read-only access to application binaries and write access only to required data directories. Use ProtectSystem=strict and ReadWritePaths=/var/lib/myapp to isolate filesystem access. Remove unnecessary capabilities with CapabilityBoundingSet to minimize attack surface exposure.

Check journalctl -u myapp.service --no-pager for error output and exit codes. Verify file permissions, paths, and environment variables match the running context. Test manually as the service user with sudo -u myapp to reproduce issues outside systemd isolation.

Systemd suits bare-metal or VM deployments where container overhead is unnecessary. Docker provides better isolation and portability but adds complexity. Many teams combine both, using systemd to manage Docker containers or podman pods as managed services.

Deploy new binaries to a versioned directory, then atomically symlink and run systemctl restart myapp.service. Use ExecStartPre validation scripts to verify integrity before starting. Combine with Type=notify to ensure old instance stops only after new one signals readiness.

Install Microsoft.Extensions.Hosting.Systemd NuGet package for proper Type=notify support and journal logging. Base .NET runtime includes no systemd bindings. Without this package, readiness notifications fail silently and structured logs lose metadata like timestamps and severity levels.