
Create a systemd Service to Run Your App on Boot
You wrote an app, logged into your VPS over SSH, typed node server.js, and it worked. Then you closed the laptop, the SSH session ended, and the app died with it. Maybe you got smarter and started it inside screen or with nohup, and it ran for three weeks, right up to the first kernel update and reboot. After that nobody noticed it was gone until a customer called. There is a better way, and it is already installed on your server: systemd. In this tutorial we create a systemd service that starts your app at boot, restarts it when it crashes, runs it as an unprivileged user, keeps secrets in a separate file, locks it into a sandbox and sends every log line to a place you can actually search.
We use a tiny Node.js app as the running example, then show exactly what changes for Python and Go. Everything was tested for Debian 13 and Ubuntu 24.04 on a fresh VPS, and where the two distributions behave differently, we say so.
What systemd is and why your app should use it
When Linux boots, the kernel starts exactly one program by itself: process number 1, or PID 1. On Debian and Ubuntu that program is systemd. Everything else (SSH, cron, the web server, the database) is started by systemd, watched by systemd and stopped by systemd at shutdown. It is a “service manager”: a supervisor whose job is to keep long-running programs, called services or daemons, in the state you asked for.
A few terms we will use all the time:
- Unit: anything systemd manages. A service is one kind of unit; timers, mounts, sockets and targets are others.
- Unit file: a small text file in INI format (sections in square brackets,
Key=valuelines) describing a unit. Ours will be calledmyapp.service. - Target: a named group of units that marks a stage of the boot.
multi-user.targetmeans “the system is up, networking and services are running, users can log in”. Hooking our service into it is what makes it start on boot. - Journal: systemd’s central log store, kept by the
systemd-journaldservice and read with thejournalctlcommand.
Why not screen, nohup or a process manager?
screen, tmux and nohup keep a process alive after you log out, and that is all they do. They do not start it again after a reboot, do not restart it after a crash, and leave logs scattered in random files. Language-specific process managers such as PM2 solve more of this, but they are one more daemon to install, update and start on boot, and they only cover one language. systemd is already running on every Debian and Ubuntu server, works the same way for Node.js, Python, Go, Java or a shell script, and offers sandboxing features no process manager has. If you already ship your app as a Docker container, Docker’s restart policies cover similar ground; for a single binary or a script, a systemd service is the lighter path.
What you need
- A VPS running Debian 13 or Ubuntu 24.04. A KVM VPS supports everything in this guide; in an LXC container a few sandboxing options may need adjusting (we flag them in Step 8).
- SSH access as a regular user with
sudorights, not as root. - An app that runs in the foreground and listens on a port, or our 10-line example.
- A text editor; we use
nano. - About 20 minutes.
How the pieces fit together
At boot, or whenever you run systemctl start, systemd reads two files we are about to create: the unit file, which says how to run the app, and the environment file, which holds configuration and secrets. It then starts the app as a dedicated user inside a sandbox where most of the filesystem is read-only. Anything the app prints on standard output or standard error goes straight into the journal, so there are no log files for you to rotate. If the process crashes, systemd waits a few seconds and starts it again.
The app only listens on 127.0.0.1 (the loopback interface, reachable only from the server itself), so it is not exposed to the internet. If it needs to be public, a reverse proxy such as Nginx with a TLS certificate goes in front of it.
Create a systemd service, step by step
Step 1: Write and test a small app
First we need Node.js. The distribution packages are enough for this example:
sudo apt update
sudo apt install -y nodejs curl
node --version
command -v node
Debian 13 ships Node.js 20 and Ubuntu 24.04 ships Node.js 18 (already past its end of life). Both run our example fine, but for a real app use a current LTS release, 22 or newer, from the official nodejs.org
downloads, and check the Node.js releases page for the newest one. The last command prints where the node binary lives, usually /usr/bin/node. Write that path down: systemd needs absolute paths.
Now create a tiny web app. It has no dependencies and reads its port and greeting from environment variables, which is exactly how we will configure it later:
mkdir -p ~/myapp
nano ~/myapp/server.js
Paste this, then save with Ctrl+O, Enter and exit with Ctrl+X:
const http = require('http');
const port = Number(process.env.PORT || 3000);
const greeting = process.env.GREETING || 'Hello';
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`${greeting} from myapp, pid ${process.pid}\n`);
}).listen(port, '127.0.0.1', () => {
console.log(`listening on 127.0.0.1:${port}`);
});
Run it by hand to make sure it works before systemd gets involved:
PORT=3000 GREETING=Hi node ~/myapp/server.js
You should see listening on 127.0.0.1:3000. Open a second SSH session and run curl http://127.0.0.1:3000; the answer is Hi from myapp, pid 12345 (with a different number). Go back to the first session and stop the app with Ctrl+C. This manual test matters: if the app does not run by hand, it will not run as a service either, and debugging is much easier here.
Step 2: Create a dedicated system user
Running an app as root means any bug in it, or in one of its hundreds of dependencies, can take over the whole server. Instead we create a system user: an account with no password, no login shell and no home directory, whose only purpose is to own the running process.
sudo useradd --system --user-group --home-dir /opt/myapp --no-create-home --shell /usr/sbin/nologin myapp
id myapp
The output looks like uid=998(myapp) gid=998(myapp) groups=998(myapp); the numbers vary. --system gives the account a low ID reserved for services, --user-group creates a matching myapp group, and /usr/sbin/nologin makes sure nobody can log in as this user, on both Debian and Ubuntu.
Step 3: Install the app under /opt
Your home directory is for you, not for running services. We copy the app to /opt/myapp and make it owned by root but readable by the myapp group. That way the service can read its code but cannot modify it, so a compromised app cannot plant a backdoor in itself.
sudo mkdir -p /opt/myapp
sudo cp -r ~/myapp/. /opt/myapp/
sudo chown -R root:myapp /opt/myapp
sudo chmod -R u=rwX,g=rX,o= /opt/myapp
ls -l /opt/myapp
Expect a line like -rw-r----- 1 root myapp 352 Nov 10 10:02 server.js. In u=rwX,g=rX,o=, the capital X adds execute permission only to directories and files that are already executable, and o= removes all access for other users. For a real Node.js project, run npm ci --omit=dev in your build folder (~/myapp) before copying, so node_modules comes along.
Step 4: Create the environment file
Configuration and secrets do not belong in the code or in the unit file. We put them in a separate file that systemd loads when it starts the service:
sudo mkdir -p /etc/myapp
sudo nano /etc/myapp/myapp.env
# /etc/myapp/myapp.env
PORT=3000
GREETING=Hello
NODE_ENV=production
# Example secret, replace it with your own long random value
API_TOKEN=change-me-to-a-long-random-string
Then lock it down so only root can read it:
sudo chown root:root /etc/myapp/myapp.env
sudo chmod 600 /etc/myapp/myapp.env
You might wonder how the app reads a file it has no permission to open. It does not have to: systemd itself (running as root) reads the file before switching to the myapp user and hands the values to the process as environment variables. The format is strict because systemd is not a shell: one NAME=value per line, no spaces around =, no export keyword, no $(command) substitutions. Lines starting with # are comments, and values with spaces go in double quotes.
Step 5: Write the unit file
Unit files you write yourself live in /etc/systemd/system/. Units installed by packages live in /usr/lib/systemd/system/ (also reachable as /lib/systemd/system/ on both distributions); never put your own files there, because package updates own that directory.
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My example web app
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
What each line does:
- Description: the human-readable name shown in
systemctl statusand in the logs. - After= and Wants=network-online.target:
Wants=pulls in the “network is configured” target, andAfter=makes our service wait for it. This matters if your app connects to a remote database at startup. - StartLimitIntervalSec and StartLimitBurst: if the service starts more than 5 times within 60 seconds, systemd gives up instead of looping forever. These two belong in
[Unit], not[Service], a very common mistake. - Type=exec: the service counts as started once the program has actually been executed. If the path is wrong,
systemctl startfails immediately with a clear error.Type=simpleis the older default;Type=notifyis for apps that report readiness themselves. - User and Group: the account from Step 2.
- WorkingDirectory: relative paths in your app resolve from here.
- EnvironmentFile: the file from Step 4.
- ExecStart: the command to run. It must start with an absolute path, and there is no shell, so pipes,
&&,>redirections and~do not work. - Restart=on-failure: restart after a non-zero exit code, a crash signal or a timeout, but not after a clean exit or
systemctl stop. UseRestart=alwaysfor workers that are designed to exit and come back. - RestartSec=5: wait five seconds before restarting, so a crash loop does not burn the CPU.
- TimeoutStopSec=20: on stop, systemd sends SIGTERM, waits 20 seconds for a graceful shutdown, then sends SIGKILL.
- SyslogIdentifier: the tag in front of every log line.
- WantedBy=multi-user.target: this is what
enableuses to start the service on boot.
Note that unit files are readable by every user on the system, which is exactly why secrets stay in the environment file. Check the syntax before going further:
sudo systemd-analyze verify /etc/systemd/system/myapp.service
No output means no problems found.
Step 6: Enable, start and test automatic restarts
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
systemctl status myapp.service --no-pager
daemon-reload tells systemd to re-read unit files from disk (needed every time you edit one). enable creates the link that starts the service on boot, and --now also starts it right away. The status output should look roughly like this:
● myapp.service - My example web app
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-11-10 10:15:02 EET; 4s ago
Main PID: 2481 (node)
Tasks: 7 (limit: 2307)
Memory: 38.5M
Nov 10 10:15:02 vps systemd[1]: Started myapp.service - My example web app.
Nov 10 10:15:02 vps myapp[2481]: listening on 127.0.0.1:3000
The important parts are enabled (starts on boot) and active (running). Test it with curl http://127.0.0.1:3000, which should answer Hello from myapp, pid 2481: the greeting now comes from the environment file.
Now simulate a crash by killing the process the hard way:
sudo systemctl kill --signal=SIGKILL myapp.service
sleep 7
systemctl status myapp.service --no-pager
The service is running again with a new Main PID, and the log lines show Main process exited, code=killed, status=9/KILL followed by Scheduled restart job, restart counter is at 1. That is your safety net working. Finally, the real test: run sudo reboot, reconnect after a minute and check systemctl status myapp again. It should be running without you touching anything.
The everyday commands you will use from now on: sudo systemctl restart myapp after a deploy, sudo systemctl stop myapp to stop it (no automatic restart after a deliberate stop), sudo systemctl disable myapp to stop starting it on boot, and systemctl is-active myapp or systemctl is-enabled myapp for quick checks in scripts.
Step 7: Read the logs with journalctl
Because the app writes to standard output, every line ends up in the journal, tagged with the service name and a timestamp. The most useful commands:
journalctl -u myapp -f # follow live, like tail -f
journalctl -u myapp -n 50 --no-pager # last 50 lines
journalctl -u myapp --since "1 hour ago" # time window
journalctl -u myapp -b # only since the last boot
journalctl -u myapp -p err # only errors and worse
-u filters by unit, -f follows new lines (exit with Ctrl+C), -b limits output to the current boot and -p err shows only messages logged at error priority or higher. Add -o short-iso for ISO timestamps or -o cat to see just the message text.
Permissions differ between distributions. On Ubuntu, the first user is a member of the adm group and can read all logs. On Debian, the user created at install time often is not, so you only see your own messages. Either prefix the commands with sudo, or add yourself to the systemd-journal group and log in again:
sudo usermod -aG systemd-journal "$USER"
Make sure logs survive a reboot. Current Debian and Ubuntu releases normally store the journal persistently in /var/log/journal. Check with ls /var/log/journal and journalctl --disk-usage. If the directory is missing, logs are kept in memory only; create it and restart journald:
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
To cap disk usage, use a drop-in file instead of editing the main journald.conf (if the file below already exists, merge the setting into it rather than overwriting it):
sudo mkdir -p /etc/systemd/journald.conf.d
sudo nano /etc/systemd/journald.conf.d/size.conf
[Journal]
SystemMaxUse=500M
Apply it with sudo systemctl restart systemd-journald.
Step 8: Add sandboxing and check the security score
Our service already runs as an unprivileged user, but it can still read most of the filesystem, see other processes and call almost any kernel function. systemd can shrink that dramatically with a few lines. First, measure where we are:
systemd-analyze security myapp.service
You get a long table of checks and, at the end, a line like Overall exposure level for myapp.service: 9.2 UNSAFE followed by a small icon. The score goes from 0 (locked down) to 10 (fully exposed). Do not panic: “UNSAFE” here just means “no sandboxing configured”, which is the default for every service.
Open the unit file again (sudo nano /etc/systemd/system/myapp.service) and add these lines at the end of the [Service] section, just before [Install]:
# Writable data directory: /var/lib/myapp, exported as $STATE_DIRECTORY
StateDirectory=myapp
UMask=0077
# Filesystem
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
# Privileges
NoNewPrivileges=yes
CapabilityBoundingSet=
RestrictSUIDSGID=yes
# Kernel and system
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
# Network, namespaces, system calls
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
RemoveIPC=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
SystemCallErrorNumber=EPERM
# Resource limits
MemoryMax=512M
TasksMax=200
In plain words:
- ProtectSystem=strict makes the entire filesystem read-only for the app, except the directories you explicitly allow. StateDirectory=myapp is that allowance: systemd creates
/var/lib/myapp, owned bymyapp, and passes its path in theSTATE_DIRECTORYvariable. Store uploads, SQLite files or caches there. UMask=0077 makes new files private to the service. - ProtectHome hides
/homeand/root, PrivateTmp gives the app its own empty/tmp, PrivateDevices hides hardware devices. - NoNewPrivileges, an empty CapabilityBoundingSet and RestrictSUIDSGID guarantee the process can never gain root powers, even through a setuid binary.
- The Protect… kernel options stop it from changing kernel settings, loading modules, reading kernel logs, changing the clock or the hostname. ProtectProc=invisible hides other users’ processes.
- RestrictAddressFamilies allows only IPv4, IPv6 and local Unix sockets (add
AF_NETLINKif your app lists network interfaces). SystemCallFilter allows the system calls a normal service needs and blocks privileged ones; withSystemCallErrorNumber=EPERMa blocked call returns an error instead of killing the process. - MemoryMax and TasksMax stop a memory leak or a fork bomb from taking the whole VPS down.
We deliberately left out MemoryDenyWriteExecute=yes: it breaks Node.js, whose JavaScript engine generates machine code at runtime. It is a good addition for Go binaries (see below). Apply and measure again:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl status myapp.service --no-pager
curl http://127.0.0.1:3000
systemd-analyze security myapp.service | tail -n 1
The app answers as before, and the last line now reads something like Overall exposure level for myapp.service: 2.1 OK. Exact numbers vary with the systemd version (Debian 13 ships systemd 257, Ubuntu 24.04 ships 255).
A note for LXC containers. If your VPS is an unprivileged LXC container rather than a KVM virtual machine, systemd runs inside the container and cannot always create the namespaces some of these options rely on. The symptom is a service that fails with status=226/NAMESPACE. Remove the options one at a time, starting with PrivateDevices, ProtectClock, ProtectKernelLogs, ProtectKernelModules and ProtectProc, until it starts, or ask your provider whether nesting can be enabled. On a KVM VPS everything works as shown.
Adapting the unit for Python and Go apps
Steps 2, 3, 4, 6, 7 and 8 stay the same. Only the way you install the app and the ExecStart= line change.
Python
Python apps should run from a virtual environment (venv), an isolated folder holding the interpreter link and the app’s own libraries. Debian and Ubuntu need the python3-venv package for that. Here is the same example app in Python, saved as ~/myapp/app.py:
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(os.environ.get("PORT", "3000"))
GREETING = os.environ.get("GREETING", "Hello")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = f"{GREETING} from myapp, pid {os.getpid()}\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
print(f"listening on 127.0.0.1:{PORT}", flush=True)
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
Install it with a venv (skip the pip line if you have no requirements.txt):
sudo apt install -y python3-venv
sudo mkdir -p /opt/myapp
sudo cp -r ~/myapp/. /opt/myapp/
sudo python3 -m venv /opt/myapp/venv
sudo /opt/myapp/venv/bin/pip install -r /opt/myapp/requirements.txt
sudo chown -R root:myapp /opt/myapp
sudo chmod -R u=rwX,g=rX,o= /opt/myapp
In the unit file, replace the ExecStart= line and add one environment line:
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py
PYTHONUNBUFFERED=1 makes Python write log lines immediately instead of buffering them, otherwise they show up in the journal late or in bursts. For a Flask or Django app, run a production server instead, for example ExecStart=/opt/myapp/venv/bin/gunicorn --bind 127.0.0.1:3000 --workers 2 app:app.
Go
Go compiles to a single static binary, which makes it the easiest case. Build it on your workstation for the server’s architecture (most VPS are amd64; use arm64 for ARM servers), copy it over and install it with the right owner and mode in one command:
# on your workstation
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o myapp .
scp myapp youruser@your-vps-ip:/tmp/
# on the VPS
sudo mkdir -p /opt/myapp
sudo install -o root -g myapp -m 0750 /tmp/myapp /opt/myapp/myapp
The unit uses ExecStart=/opt/myapp/myapp. Go programs do not generate code at runtime, so you can also add MemoryDenyWriteExecute=yes to the hardening block for an even lower score.
Deploying a new version
Updating the app is always the same three moves: copy the new files, fix ownership, restart.
sudo cp -r ~/myapp/. /opt/myapp/
sudo chown -R root:myapp /opt/myapp
sudo chmod -R u=rwX,g=rX,o= /opt/myapp
sudo systemctl restart myapp.service
journalctl -u myapp -n 20 --no-pager
If you change the environment file, restart too: variables are read only when the service starts. If you change the unit file, run sudo systemctl daemon-reload before restarting.
Troubleshooting and common mistakes
When a service fails, systemctl status myapp shows an exit status, and journalctl -u myapp -n 50 shows the reason. The most frequent ones:
status=203/EXEC: systemd could not executeExecStart. The path is wrong, not absolute, not executable, or the runtime is installed inside your home directory (nvm, pyenv), where themyappuser cannot reach it. Use a system-wide runtime and the path fromcommand -v node.status=200/CHDIR: theWorkingDirectoryis missing or not readable by the service user. Test withsudo -u myapp ls /opt/myapp.status=217/USER: the user inUser=does not exist. Checkid myapp.status=226/NAMESPACE: the sandbox could not be built, usually in an unprivileged LXC container (see Step 8), or a path listed inReadWritePaths=does not exist.- “The unit file changed on disk”: you forgot
sudo systemctl daemon-reloadafter editing. - “Start request repeated too quickly”: the start limit was hit. Read the journal, fix the cause, then run
sudo systemctl reset-failed myapp.serviceand start it again. EROFSorEACCESwhen writing files: that isProtectSystem=strictdoing its job. Write to$STATE_DIRECTORYor add aReadWritePaths=line for the exact folder.- Environment variables not applied: check the file format (no
export, no spaces around=) and remember to restart after changes. - Port below 1024 fails with
EACCES: unprivileged users cannot bind ports like 80 or 443. Put a reverse proxy in front, or allow it withAmbientCapabilities=CAP_NET_BIND_SERVICEplusCapabilityBoundingSet=CAP_NET_BIND_SERVICE. - Works by hand, fails as a service: run it exactly like systemd does,
sudo -u myapp /usr/bin/node /opt/myapp/server.js, and read the error.
Security notes
- Never run apps as root. The dedicated user has no shell, no password and no sudo rights; give each app its own user.
- Code is read-only for the app. Root owns
/opt/myapp, so even a compromised app cannot modify itself. Only/var/lib/myappis writable. - Keep secrets out of unit files. Unit files and the output of
systemctl showare readable by every user. Secrets go in the600environment file. Keep in mind that environment variables are still visible to root and to processes of the same user; for stronger isolation, look at systemd’sLoadCredential=, which exposes secrets as files in$CREDENTIALS_DIRECTORY. - Do not expose the app port. It listens on 127.0.0.1 and a reverse proxy with TLS handles the public traffic. If you really must bind to all interfaces, open the port deliberately with
sudo ufw allow 3000/tcp. Since this is a normal host service, ufw does filter it (unlike Docker-published ports, which bypass ufw). - Sandboxing is defense in depth, not a replacement for updates. Keep the system and the runtime patched with
sudo apt update && sudo apt upgrade. On Ubuntu, reboot when/var/run/reboot-requiredexists; Debian does not always create that file, so reboot whenever a newlinux-imagepackage was installed. Your service will come back on its own. - Use the limits.
MemoryMaxandTasksMaxkeep one misbehaving app from starving everything else on the VPS.
So, is a systemd service all you need?
For most single apps on a VPS, yes. With one unit file of about 50 lines you get start on boot, automatic restarts with a sane crash-loop limit, a dedicated user, secrets kept out of the code, central searchable logs and a sandbox that turns an “UNSAFE” score into an “OK” one. No extra daemon, no extra language runtime, and the same recipe works for Node.js, Python, Go and anything else that runs in the foreground. When you outgrow it, the same skills carry over to timers, socket activation and template units.
Grab a VPS from us and you can have your app running as a hardened service in twenty minutes. If you would rather have someone else write, harden and monitor your services, our server administration team does exactly that every day.
FAQ
What is the difference between enable and start?
start runs the service now, until the next reboot or stop. enable makes it start automatically on every boot but does not start it now. enable --now does both, which is usually what you want.
Can I run several copies of the same app?
Yes, with a template unit. Name the file myapp@.service and use %i inside it, for example EnvironmentFile=/etc/myapp/%i.env. Then sudo systemctl enable --now myapp@shop and myapp@blog run two independent instances, each with its own configuration.
Can I create a service without sudo?
Yes, as a user service: place the unit in ~/.config/systemd/user/, manage it with systemctl --user, and ask an admin to run sudo loginctl enable-linger yourname once so it keeps running after you log out and starts at boot. System services as shown here are still the better choice on a server you control.
Should I edit unit files installed by packages?
No. Use sudo systemctl edit nginx.service, which creates an override file in /etc/systemd/system/nginx.service.d/ that survives package updates. Your own units in /etc/systemd/system/ can be edited directly.
Do I still need PM2 or supervisord?
Not for keeping an app alive. systemd already covers starting on boot, restarts, logs and resource limits. PM2’s cluster mode can still be handy for Node.js, but you can also run several instances with a template unit behind a reverse proxy.