
Table of Contents
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.
/etc/systemd/system/your-app.service. Configure the ExecStart path to the published DLL, set Restart=always for resilience, apply hardening directives like ProtectSystem=strict, and manage secrets via environment files rather than hardcoding them in the unit.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.
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.jsonor 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.
- Create the secrets file:
sudo nano /etc/myapi/app.env - 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 - 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.
| Directive | Purpose | Trade-off / Note |
|---|---|---|
ProtectSystem=strict | Makes entire filesystem read-only except whitelisted paths | Must explicitly list writable dirs in ReadWritePaths |
PrivateTmp=yes | Gives service its own /tmp namespace | Prevents temp file conflicts and symlink attacks |
NoNewPrivileges=yes | Blocks privilege escalation via setuid/setgid binaries | Essential; rarely breaks legitimate .NET apps |
RestrictRealtime=yes | Prevents realtime scheduling abuse | Safe for web APIs and workers |
MemoryDenyWriteExecute=yes | Blocks W^X violations (JIT exception needed) | Disable for .NET — JIT requires WX memory |
ProtectKernelTunables=yes | Read-only /proc/sys, /sys | Prevents runtime kernel parameter tampering |
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:
#!/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.