September 12, 2026
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