You've brought up your application with nohup ./app &, closed SSH, and half an hour later the site returns 502. Or worse: the process is still alive but nobody knows where it is, who started it, or why it didn't come back after a server restart. This is exactly where you need to write a systemd unit file and get rid of shell scripts.
Why nohup and screen don't cut it
Both tools keep the process alive, but neither is accountable. If the process crashes, nobody brings it back. If the server restarts, nothing comes up. If you want logs, you have to hunt for scattered files in /tmp. systemd does all three with a single text file, and on top of that, it manages resource limits, boot ordering, and dependencies on other services.
Creating your first unit file
Create the file at /etc/systemd/system/myapp.service. The path matters: units inside /etc/systemd/system take priority over anything the package manager installed and won't be wiped by a package update.
[Unit]
Description=My Node API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/srv/myapp
ExecStart=/usr/bin/node /srv/myapp/server.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=-/etc/myapp/env
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
A few lines in this file matter more than the rest. After=network-online.target ensures the app doesn't start before the network is ready; without it, a service that connects to a database over the network dies at boot with a connection error, and you think the code is broken. EnvironmentFile with that leading dash in the path means systemd won't error out if the file is missing. And Restart=on-failure only brings the process back when it dies with a non-zero exit code or a signal; if your app exits with code zero on its own and you expect it to come back up, you need Restart=always.
Type=simple or Type=exec?
For most apps, Type=simple is enough. If you want systemd to make sure the executable actually exists and has been executed, choose Type=exec; the practical difference is that you see the "file not found" error right in the service status, not in the log. For services that daemonize themselves (like PHP-FPM), use Type=forking, but if you're writing your own app, don't daemonize; systemd dislikes it and it makes process tracking harder.
Running as a non-root user
This is where people make mistakes: they write the unit without User= and the app comes up as root. They see the sign later, when a simple vulnerability in the app turns into full server access. Create a user and give it ownership of the directory:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuser
sudo chown -R appuser:appuser /srv/myapp
sudo chmod 750 /srv/myapp
If the app listens on a port below 1024 (like 80 or 443), a non-root user isn't allowed to bind. The right way is to put a reverse proxy like Nginx in front of the app and have the app listen on a high port like 3000. The second way is to give the service AmbientCapabilities=CAP_NET_BIND_SERVICE, which grants the minimum required privilege. The third way, running as root, is wrong.
Enabling and automatic restart
After every change to the unit file, systemd must re-read it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
systemctl status myapp.service
The enable --now line both enables the service for the next boot and starts it right now. If you only run start, the service won't come up after a server restart, and this is one of the most common reasons for "it worked yesterday."
To test automatic restart, deliberately kill the process and see systemd bring it back a few seconds later:
sudo pkill -f server.js
sleep 6
systemctl status myapp.service | grep -E "Active|Main PID"
If the service is stuck in activating (auto-restart), it means the restart count has exceeded the limit. By default, if a service dies several times in a row within a short window, systemd gives up trying. Control this behavior with StartLimitIntervalSec=60 and StartLimitBurst=5 in the [Unit] section. Don't deliberately make this limit infinite; an app stuck in a crash loop that keeps coming back up silently eats server resources.
Logs with journalctl
The app's output is automatically recorded in the journal, provided you've set StandardOutput=journal. The most commonly used commands:
journalctl -u myapp.service -n 100 --no-pager
journalctl -u myapp.service -f
journalctl -u myapp.service --since "1 hour ago"
journalctl -u myapp.service -p err -b
The -f switch works like tail -f, -b only brings logs from the current boot, and -p err filters for error level and above. One thing that catches many people off guard: by default, the journal isn't stored on disk and is wiped on restart. If you want logs to persist, set Storage=persistent in /etc/systemd/journald.conf and run systemctl restart systemd-journald. To control size, also set SystemMaxUse=500M so the journal doesn't fill up the entire disk.
When the app writes logs to its own file
If for whatever reason your app must write logs to a separate file, use StandardOutput=append:/var/log/myapp/app.log (systemd version 240 and above). This is better than manually redirecting in ExecStart, because you control log rotation and file ownership yourself, and systemd opens the file as the service user.
Resource limits and hardening
One advantage no shell script gives you is limiting the service. A few lines that are truly worth it:
MemoryMax=512M
CPUQuota=80%
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/srv/myapp/uploads
NoNewPrivileges=true
MemoryMax kills the service when it exceeds the limit, and it's recorded in the log with an oom-kill message; if you see this message in the journal, know that the problem isn't the code, it's the memory cap. ProtectSystem=strict makes the entire filesystem read-only, and only the paths you list in ReadWritePaths remain writable. This setting is great, but it has a cost: if your app creates temporary files somewhere outside those paths, it dies with a Read-only file system error, and finding it takes time. Start with ProtectSystem=full first, then tighten it further.
Before going to production
Check three things. First, don't forget systemctl enable. Second, restart the server once and see whether the service comes up on its own; not testing this costs the most time during critical hours. Third, make sure the app runs as the correct user: ps -o user= -p $(systemctl show -p MainPID --value myapp.service) should return the service user's name, not root.
If you're still working on a dedicated server and want to implement this same structure on a managed environment, Linux hosting is a good starting point since you have full access to systemd and journalctl. For WordPress apps too, if your cron runs as wp-cron instead of real cron, the same unit file logic works; we've written about an example in WordPress wp-cron; why it doesn't run. If the service comes up but users get an HTTP error, see HTTP status codes: a practical guide to common errors to get to the root faster. And if you've just stepped into Linux servers, ServerNet's documentation and knowledge base has several ready-made unit examples you can copy and edit.
Frequently asked questions
Why does my systemd service die after closing SSH?
A systemd service is never tied to your session, and closing SSH shouldn't kill it. If it dies, you probably didn't start the app with systemctl start and that old nohup process is still running. Check with systemctl status myapp that the Main PID matches the process you see in ps aux.
What's the difference between systemctl restart and reload?
restart fully kills the process and brings it back up, so there's a few seconds of downtime. reload sends a SIGHUP signal and, if your app supports it, re-reads the configuration without downtime. If the app doesn't handle SIGHUP, reload is either ineffective or kills the process; to be sure, explicitly write ExecReload=/bin/kill -HUP $MAINPID.
How do I find out which user the service runs as?
The command systemctl show -p MainPID --value myapp.service gives you the main process ID, and ps -o user= -p PID gives you the username. If it returns root, it means the User= line isn't in the unit file, or you didn't run daemon-reload after changing it.
journalctl logs are wiped after restart; what should I do?
By default, the journal is kept in memory on some distributions. Enable Storage=persistent in /etc/systemd/journald.conf and restart the journald service. Also set a size cap with SystemMaxUse so logs don't fill up the disk.
Comments 0
No comments yet — be the first!