Most solo founders' first production deploy looks the same: SSH into the VPS, cd into the app directory, run node server.js &, or start a tmux session and leave it detached. It works, right up until it doesn't — an unattended-upgrades reboot at 3am, an out-of-memory kill, a dropped SSH connection that somehow takes the shell's child process with it. Nobody notices until a customer emails asking why the app has been down for six hours. The fix isn't a fancier way to keep a terminal open. It's using the process supervisor that's already running as PID 1 on your server: systemd.
Why "it works in my tmux session" isn't process management
tmux and screen solve a narrower problem than people use them for: keeping a shell session alive after you disconnect. They were never built to supervise a long-running service. None of them restart your app if it exits with a non-zero code. None of them start your app automatically after a reboot. nohup node server.js & detaches the process from your terminal, but the moment that process crashes, it's just gone — no restart, no alert, no record of why, unless you separately piped stdout somewhere and remembered to rotate it. And because none of these tools understand service dependencies, there's no way to say "don't start the app until Postgres is actually accepting connections," so you get boot-time races that work until the one day they don't.
systemd already exists on every modern Ubuntu, Debian, or AlmaLinux VPS specifically to solve this. It restarts failed services, starts them on boot in the right order, captures their output in a structured log, and lets you inspect and control them with two or three commands you'll use for the life of the server. Writing the unit file takes about the same effort as remembering your tmux session name.
A minimal systemd unit for your app
Create a unit file in /etc/systemd/system/, one per app:
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp SaaS backend
After=network.target postgresql.service
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/myapp
ExecStart=/usr/bin/node /home/deploy/myapp/server.js
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
EnvironmentFile=/home/deploy/myapp/.env
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Then register and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service
enable makes it start on boot; --now also starts it immediately. Swap ExecStart for whatever actually runs your app — a gunicorn command for Django, a compiled Go binary, a pnpm start invocation. The rest of the unit stays basically the same regardless of language.
Environment variables and secrets
Point EnvironmentFile at a plain KEY=value file — no export, no shell syntax, since systemd isn't invoking a shell around your process:
DATABASE_URL=postgres://myapp:secret@localhost:5432/myapp
NODE_ENV=production
PORT=3000
Lock it down so only the app's own user can read it:
chmod 600 /home/deploy/myapp/.env
chown deploy:deploy /home/deploy/myapp/.env
This keeps secrets out of your shell history and out of the unit file itself, which is world-readable by default under /etc/systemd/system/.
Restart policy: what actually happens when it crashes
Restart=on-failure restarts the service on a crash, an unhandled exception that kills the process, or a non-zero exit code — but not when you deliberately run systemctl stop. That distinction matters: it means deploys and intentional restarts behave the way you'd expect, while an actual crash gets self-healed within RestartSec seconds. The StartLimitIntervalSec / StartLimitBurst pair stops a genuinely broken deploy from crash-looping forever — after 5 failures in 60 seconds, systemd gives up and marks the unit failed instead of hammering your CPU. That failed state is also your signal to go check the logs before blindly restarting it again.
journalctl: logs without standing up a logging stack
Every stdout and stderr line your app writes goes straight into the systemd journal — no log file to create, no rotation cron job to remember:
journalctl -u myapp.service -f # tail it live
journalctl -u myapp.service --since "1 hour ago"
journalctl -u myapp.service -n 200 --no-pager
The one thing worth configuring up front is a cap, so logs from a chatty app don't slowly eat your disk. In /etc/systemd/journald.conf:
SystemMaxUse=500M
then sudo systemctl restart systemd-journald. This is enough for a single-VPS setup; if you're already tracking uptime and resource usage, it pairs naturally with the tools in Monitoring Your VPS on a Budget rather than replacing them — journald covers "what did my app print right before it died," not dashboards.
Running more than one app on the same VPS
If you're running several client instances or a few small services on one box — the pattern covered in Nginx Reverse Proxy Setup for Multiple Node.js Apps on One Server — a systemd template unit avoids copy-pasting a service file per app. Name the file with an @:
# /etc/systemd/system/[email protected]
[Unit]
Description=Node app instance %i
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/apps/%i
EnvironmentFile=/home/deploy/apps/%i/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Each instance gets its own .env with its own PORT, and you start them by name:
sudo systemctl enable --now [email protected]
sudo systemctl enable --now [email protected]
%i expands to whatever comes after the @, so clienta and clientb each get their own working directory and environment file, while nginx routes each subdomain to the port that instance listens on.
Deploying without drama
A deploy script that pulls, installs, and restarts the service is the whole CI/CD loop most solo founders actually need — see CI/CD for a Solo Founder for the full pipeline. The systemd-specific piece is usually just:
git pull origin main
npm ci --omit=dev
sudo systemctl restart myapp.service
Rather than giving the deploy user full sudo, scope it to exactly the commands it needs in /etc/sudoers.d/deploy-restart:
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service, /usr/bin/systemctl status myapp.service
That's a meaningfully smaller blast radius than an SSH key that can run anything as root.
Frequently asked questions
Should I use systemd or PM2 for a Node app?
For a single VPS running one or two Node processes, systemd does everything PM2 does for process supervision — restart on crash, boot startup, structured logs — without adding an npm dependency you have to keep patched. PM2 earns its keep when you actually need its cluster mode to run one app across multiple CPU cores with a shared load balancer built in; if you're not using that, you're running an extra process manager to duplicate what's already built into the OS.
What about Docker — doesn't that solve this?
Docker's own restart policies (--restart=on-failure, or restart: on-failure in Compose) handle container-level supervision, but you're still choosing between running Docker itself under systemd (the normal setup on most distros) or running your app directly on the host. See Docker vs Bare Metal Deployment for when the extra layer is worth it for a solo-run app versus adding overhead you don't need yet.
How do I confirm the restart policy is actually working?
Kill the process directly and watch systemd bring it back:
sudo systemctl show myapp.service -p MainPID
sudo kill -9 <that PID>
sudo systemctl status myapp.service
systemctl show myapp.service -p NRestarts
NRestarts increments every time systemd restarts the unit, which is the fastest way to confirm the policy fired without digging through timestamps in the journal.