

The systemd documentation is thorough. It covers every directive, every option, every edge case. What it doesn’t show you is which 10% of that actually matters when you’re running a Go API and a Node.js frontend on a VPS.
This post covers that 10%: the unit file options you’ll use, the gotchas that will cost you an afternoon, and why some directives that look optional will quietly break your app if you skip them.
The Setup#
If you’re not familiar with the overall deployment model, Deploying to a VPS Without Docker or CI/CD covers the full picture: two environments running side by side, nginx as the front door, and a git pull to deploy. That post treats systemd as a supporting character. This one puts it center stage.
The assumption here: you have a non-root deploy user that runs your services, and your apps live somewhere under /var/www/.
What systemd Is Actually Doing#
systemd is your process manager. When you run systemctl start myapp, systemd reads the unit file, sets up the environment, starts the process, and watches it. That’s the whole job.
The restart loop is what makes it worth using over just running your binary directly. If your app crashes at 3am, systemd restarts it. If the VPS reboots, systemd starts it. You don’t have to be there.
A Unit File, Line by Line#
[Unit]
Description=My App Backend (Staging)
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp-staging/myapp/backend
EnvironmentFile=/var/www/myapp-staging/myapp/backend/.env.staging
ExecStart=/var/www/myapp-staging/myapp/backend/myapp-backend
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/www/myapp-staging/myapp
[Install]
WantedBy=multi-user.targetiniThe non-obvious ones:
After=network.target tells systemd to start your service after the network is up. Without it, your app might try to bind a port before the network stack is ready. It’s an ordering hint, not a hard dependency, but you always want it.
Type=simple tells systemd your process doesn’t fork. The process you start in ExecStart is the service. This is correct for almost every Go and Node.js app. Type=forking is for old-style daemons that fork into the background on startup. You probably don’t have one of those. Type=notify is for apps that actively signal systemd when they’re ready; most apps don’t do this.
WorkingDirectory sets the current directory before starting your app. Your app resolves relative file paths from here. Leave this out and a path like ./data/app.db gets resolved relative to /, where it obviously doesn’t exist.
EnvironmentFile loads environment variables from a file. One KEY=VALUE per line, same as a .env file. systemd reads this as root before dropping privileges to the deploy user, so you can own it as root with chmod 600 and the service still gets the variables. The running process itself can’t read the file directly.
Restart=always restarts the service on any exit: crash, OOM kill, or a clean exit with code 0. on-failure would only restart on non-zero exits. For a long-running server that should never exit cleanly on its own, always is the right choice. A deliberate systemctl stop still stops it.
RestartSec=3 waits 3 seconds before restarting. Without this, a crashing app will restart in a tight loop and fill your journal with noise before you can investigate. Three seconds is enough breathing room.
The Hardening Directives#
The lower half of the [Service] section (NoNewPrivileges, ProtectHome, ProtectSystem, ReadWritePaths) restricts what the service process can access on the filesystem. These are kernel namespace features, not virtualization. They have no runtime overhead.
NoNewPrivileges=yes prevents the process from gaining elevated privileges through setuid binaries. Turn this on for every web app.
PrivateTmp=yes gives the service its own isolated /tmp instead of the shared system /tmp. Another process can’t snoop on your app’s temp files, and your temp files don’t accumulate in the system /tmp on crash.
ProtectHome=yes makes /home, /root, and /run/user invisible to the service. Your app binary cannot reach user home directories.
ProtectSystem=strict makes the entire filesystem read-only for the service, except for /dev, /proc, and /sys. Used together with ReadWritePaths, this gives your service exactly the write access it needs and nothing else.
ReadWritePaths carves out an exception to ProtectSystem=strict. List every directory your app needs to write to. If you have a data directory and a separate log directory, add both.
The ProtectHome Gotcha#
ProtectHome=yes will silently break any subprocess your app spawns if that subprocess tries to write to a home directory.
The common case: your backend triggers a build step as a subprocess. The build tool tries to write to ~/.config or ~/.cache. With ProtectHome=yes, that path is invisible to the process. The subprocess fails with EACCES or a confusing missing-directory error that doesn’t obviously point to systemd.
The fix is not to remove ProtectHome=yes. The fix is to redirect those cache paths to somewhere your service can write:
Environment=HOME=/var/www/myapp-staging
Environment=npm_config_cache=/var/www/myapp-staging/.npm-cache
Environment=XDG_CONFIG_HOME=/var/www/myapp-staging/.configiniThe better fix is to not run build tools from inside a running service at all. Build steps belong in your deploy script. The service should start a pre-built artifact, not build one.
The Deploy Cycle: Stop, Build, Start#
You can’t overwrite a running executable on Linux. If you try to go build while the binary is running, you get text file busy. The sequence is:
systemctl stop myapp-backend-staging
/usr/local/go/bin/go build -o myapp-backend ./cmd/server/
systemctl start myapp-backend-stagingbashThe downtime is under a second. For a personal project, that’s fine. If you need zero-downtime deploys, you’d pre-build the binary to a temp path and swap it atomically. That’s a different problem.
For the Node.js frontend, you don’t stop the service before building. The build writes to dist/, not to the running process. Build first, then restart to pick up the new files:
sudo -u deploy npm run build
systemctl restart myapp-astro-stagingbashReading Logs#
systemd captures stdout and stderr from your service automatically. Write to stdout in your app and it shows up in the journal.
# Follow in real time
journalctl -u myapp-backend-staging -f
# Last 100 lines, no pager
journalctl -u myapp-backend-staging -n 100 --no-pager
# Since last boot
journalctl -u myapp-backend-staging -b
# With full timestamps
journalctl -u myapp-backend-staging --output=short-isobashIf your app is failing to start, journalctl -u myapp -n 50 --no-pager immediately after systemctl start will show you why. Don’t reach for systemctl status first. The status output truncates the error message.
Common Gotchas#
| Problem | Cause | Fix |
|---|---|---|
| Subprocess fails with EACCES | ProtectHome=yes blocks ~/.config access | Redirect cache dirs via Environment=, or don’t spawn build tools from the service |
| App can’t write to its data directory | ProtectSystem=strict without a matching ReadWritePaths | Add the directory to ReadWritePaths |
| App can’t find a relative file path | WorkingDirectory not set or wrong | Set WorkingDirectory to the directory your app expects |
EnvironmentFile not loaded | File doesn’t exist at that path | Check the path and that the file exists before starting the service |
text file busy on deploy | Binary still running when you try to overwrite it | systemctl stop before rebuilding |
| Service restarts immediately after stopping | Restart=always with no start-limit configured | Use Restart=on-failure or adjust StartLimitBurst |
Is This Right for You?#
This approach works well if:
- You’re running a small number of long-running processes on a VPS
- You want automatic restarts and structured logs without adding a process manager tool
- You can tolerate a second of downtime during Go binary deploys
It’s worth knowing the limits:
Zero-downtime deploys require pre-building to a temp path and swapping atomically. The stop-build-start pattern has a gap. For a personal project that’s acceptable; for something that needs continuous availability it isn’t.
Many services means many unit files. systemd’s tooling is all CLI. If you have more than a dozen services, a tool like Coolify might be worth the tradeoff.
Complex startup ordering: if your app needs a database to be healthy before it starts, After=network.target isn’t enough. systemd has a full dependency system for this, but it’s more involved than what’s covered here.
For the common case of two or three processes on a single VPS, this is all you need. The unit files above run exactly as written in production. No surprises.