← All posts

ASP.NET Core as a systemd service: the unit file, restarts, logs and environment

dotnet MyApp.dll in a terminal is a demo, not a deployment — close the terminal, or the process crashes on an unhandled exception, and the app is gone until someone notices and starts it again by hand. systemd is what turns a published build into a service that survives a crash, a reboot, and a bad deploy, using nothing that isn’t already on every Ubuntu box. Every claim below was checked against a real unit running a real ASP.NET Core app on Ubuntu 24.04.

The unit file

/etc/systemd/system/myapp.service:

[Unit]
Description=myapp
After=network.target

[Service]
Type=simple
WorkingDirectory=/var/www/myapp/current
ExecStart=/usr/bin/dotnet /var/www/myapp/current/MyApp.dll
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=myapp
User=deploy
EnvironmentFile=-/var/www/myapp/shared/.env
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now myapp

systemctl status myapp should show active (running) within a second or two of a normal startup.

Restart=always: the whole point

Kill the process outright and watch what happens:

kill -9 <pid>
sleep 6
systemctl status myapp

Verified: the service shows a new PID, active (running), about RestartSec (5 seconds here) after the kill — systemd noticed the process died and started it again without being asked. Restart=always covers a clean exit, a crash, and a signal, which is why it’s the right default for a web app; Restart=on-failure would skip restarting after a deliberate systemctl stop, but also after a graceful shutdown that happened to exit 0, which isn’t what you want for something that’s supposed to always be up.

KillSignal=SIGINT: shutting down without dropping requests

systemctl stop myapp sends whatever KillSignal says — systemd’s own default is SIGTERM, but ASP.NET Core’s host specifically listens for SIGINT to trigger IHostApplicationLifetime’s graceful shutdown path. Set it explicitly:

KillSignal=SIGINT

Verified with journalctl -u myapp immediately after systemctl stop myapp:

systemd[1]: Stopping myapp.service - myapp...
myapp[1234]: info: Microsoft.Hosting.Lifetime[0]
              Application is shutting down...
systemd[1]: myapp.service: Deactivated successfully.

“Application is shutting down…” is the app’s own graceful-shutdown log line — with the default SIGTERM, most of the time nothing looks different because .NET treats it similarly on Linux, but SIGINT is what matches Console.CancelKeyPress/Ctrl+C semantics the framework was actually built around, so it’s the safer explicit choice rather than relying on default signal handling.

EnvironmentFile: config without touching source control

EnvironmentFile=-/var/www/myapp/shared/.env

The leading - means “don’t fail the unit if the file is missing” — useful the first time you provision a site, before any env vars have been set. The file itself is plain KEY=value lines, no export, no quotes:

ConnectionStrings__Default=Host=127.0.0.1;Database=myapp_db;Username=myapp_user;Password=...
Stripe__SecretKey=sk_live_...

The double underscore (__) is ASP.NET Core’s configuration-key separator for environment variables — it maps to ConnectionStrings:Default in appsettings.json’s nested-key notation. Permissions matter here: this file holds secrets, so it should be readable only by the user the service runs as (chmod 600, owned by deploy), never world-readable and never in the release folder that gets rebuilt on every deploy.

Hardening the unit

Four extra lines under [Service] shrink what a compromised app process can touch, without changing how the app behaves:

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/www/myapp/shared

ProtectSystem=strict mounts almost the entire filesystem read-only for this process, including /var/www/myapp/current itself — exactly right, since a running app should never rewrite its own binaries. ReadWritePaths punches the one hole it actually needs: the shared folder, if your app writes anything there (uploaded files, a SQLite database, that sort of thing). Verified: with all four lines added, systemctl restart and /health still return normally — these flags don’t touch network access or the ports the app binds, only the filesystem and privilege escalation.

When systemd refuses to restart at all

Failed with result 'start-limit-hit'.

systemd’s own default is at most 5 restart attempts in 10 seconds — past that, it stops trying and marks the unit failed, on the theory that a service crash-looping that fast isn’t going to fix itself by trying a sixth time. This is most likely to bite you while you’re actively debugging (a few manual systemctl restart in quick succession hits the same limiter as real crash-looping):

systemctl reset-failed myapp
systemctl start myapp

reset-failed clears the counter; it’s not a fix for whatever caused the failures, just what lets you try again immediately instead of waiting out the window. StartLimitIntervalSec=/ StartLimitBurst= under [Unit] change the threshold if 5-in-10-seconds is genuinely too aggressive for your app’s normal startup behavior, but treat that as a last resort, not step one.

Capping memory per service

On a droplet running more than one thing — the app, nginx, maybe Postgres — one runaway process can starve the others. MemoryMax under [Service] caps this one:

MemoryMax=512M

Past that limit, the kernel’s cgroup OOM killer kills the process (which Restart=always then brings back), rather than letting memory pressure degrade every other service on the box at once. Start generous — a small ASP.NET Core app idles well under 100 MB but garbage collection can spike usage under load — and tighten it only after watching systemctl status myapp’s Memory: line under real traffic for a while.

Reading logs with journalctl

journalctl -u myapp -f          # follow live
journalctl -u myapp -n 50       # last 50 lines
journalctl -u myapp --since "10 min ago"

SyslogIdentifier=myapp is what makes -u myapp find the right lines — without it, systemd still tags entries by unit name, but an explicit identifier makes grep-ing raw syslog easier if you ever need to. Stdout and stderr from the dotnet process both land here automatically; there’s nothing extra to configure for basic logging; ASP.NET Core’s console logger output shows up as-is.

What breaks (and why)

Service restarts every few seconds in a loop. Restart=always with RestartSec=5 will happily restart a genuinely broken build forever. journalctl -u myapp -n 30 almost always shows the real error in the last few lines — a missing environment variable, a port already in use, a bad connection string — restarting doesn’t fix a startup exception, it just repeats it.

“Application is shutting down” never appears, the process just disappears. KillSignal is still the default SIGTERM, or a supervisor further up (a container runtime, a process manager) is sending SIGKILL directly, which skips graceful shutdown entirely, in .NET or any other runtime.

Environment variables aren’t reaching the app. EnvironmentFile paths are absolute, and the syntax is strict: no export FOO=bar, no quotes around the value, one KEY=value per line, and the file needs to exist with the exact path in the unit or the - prefix silently skips it. systemctl show myapp -p Environment shows what the unit actually resolved, which is the fastest way to tell “the file wasn’t read” from “the app is ignoring a variable it did receive.”

Deploy it with DotDeployer

DotDeployer writes this unit file, sets EnvironmentFile from the environment variables you add in the dashboard, and gives you journalctl-backed logs without an SSH session.

Deploy to DigitalOcean with DotDeployer · Supported runtimes