An app started in a terminal stops as soon as you close SSH or reboot the VPS. We'll set it up as a systemd service: it runs in the background, restarts itself after a crash, starts when the VPS boots, and its output ends up in the system log.
What you'll need
- A StormHost VPS with Ubuntu 24.04 or Debian 13
- An SSH connection as root (guide)
- An app you can start from the command line (for example a Discord bot in Node.js)
- About 20 minutes
Why systemd#
systemd is the service manager that ships with both Ubuntu and Debian. It starts all system services (SSH, Nginx, databases), so there's nothing extra to install.
| Method | Restart after a crash | Start after a VPS reboot | Logs | Note |
|---|---|---|---|---|
Terminal, nohup, & | No | No | Only to a file | Fine for a quick test only. |
screen, tmux | No | No | Only in the window | Handy for manual work, not for production. |
| pm2 | Yes | Yes (once configured) | Its own | One more program, Node.js only. |
| systemd | Yes | Yes | journal | Part of the system, works for any language. |
A user for the app#
The app shouldn't run as root. If someone compromises it, they only get the permissions of its user. Create a system user with a home directory in /opt:
useradd --system --create-home --home-dir /opt/mujbot --shell /usr/sbin/nologin mujbot--system– a system account for a service (lower user ID, no password).--create-home --home-dir /opt/mujbot– creates the/opt/mujbotdirectory owned by the user.--shell /usr/sbin/nologin– nobody can log in as this user over SSH or in a terminal.mujbot– the user name (and the name of the matching group). Pick your own and replace it everywhere in this guide.
Node.js LTS#
The Ubuntu 24.04 repository ships Node.js 18 and Debian 13 ships Node.js 20. Neither version is supported by the Node.js project anymore (18 since April 30, 2025, 20 since April 30, 2026). Install the current LTS release from the official packages on nodejs.org (opens in a new window). In October 2026 the active LTS is Node.js 24 (at the end of October 2026 Node.js 26 becomes LTS).
First download the archive and verify it:
apt -y install curl xz-utils
cd /tmp
NODE_SOUBOR=$(curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt | grep -o 'node-v[0-9.]*-linux-x64.tar.xz' | head -n 1)
curl -fsSLO "https://nodejs.org/dist/latest-v24.x/$NODE_SOUBOR"
curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt | grep " $NODE_SOUBOR\$" | sha256sum -c -node-v24.21.0-linux-x64.tar.xz: OKOnly continue if you see OK. If you get FAILED, delete the file (rm /tmp/node-v*.tar.xz) and download it again. Then install Node.js (in the same terminal window, the NODE_SOUBOR variable must still be set):
mkdir -p /opt/nodejs
tar -xJf "$NODE_SOUBOR" -C /opt/nodejs --strip-components=1
ln -sf /opt/nodejs/bin/node /usr/local/bin/node
ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm
ln -sf /opt/nodejs/bin/npx /usr/local/bin/npx| Command | What it does |
|---|---|
apt -y install curl xz-utils | A download tool and support for unpacking .tar.xz archives. |
NODE_SOUBOR=$(… SHASUMS256.txt | grep -o … | head -n 1) | Reads the checksum list of the latest 24.x release and finds the Linux x64 file name, for example node-v24.. |
curl -fsSLO "…/$NODE_SOUBOR" | Downloads the archive; -O saves it under its original name. |
… | grep " $NODE_SOUBOR\$" | sha256sum -c - | Checks the downloaded file's checksum against the official list. Protects you from a corrupted or tampered file. |
mkdir -p /opt/nodejs | A directory for Node.js. |
tar -xJf … -C /opt/nodejs --strip-components=1 | Unpacks the archive (J = xz format) into /opt/nodejs without the top-level directory named after the version. |
ln -sf /opt/nodejs/bin/… /usr/local/bin/… | Creates links to node, npm and npx in a directory where the system looks for programs. The path /usr/local/bin/node stays the same after updates. |
Check: node -v prints v24.…, npm -v prints the npm version.
For a different release line, replace latest-v24.x in all URLs with, for example, latest-v26.x. Updating Node.js = stop the service, delete /opt/nodejs and repeat the steps.
Why not nvm
nvm (offered on the nodejs.org download page) installs Node.js into the user's home directory under a path that contains the version number. A systemd service needs a fixed absolute path to the program that doesn't change after an update. That's why we use the official archive with a link in /usr/local/bin.
The app#
Upload the app's code to
/opt/mujbot/app(over SFTP, guide, or withgit clone). Don't upload the.envfile with the token or thenode_modulesdirectory.Hand the files over to the app user:
bash chown -R mujbot:mujbot /opt/mujbotInstall the packages as the app user:
bash cd /opt/mujbot/app runuser -u mujbot -- npm ci --omit=devrunuser -u mujbot --– runs the command as themujbotuser (works even without the sudo package).npm ci– installs exactly the versions frompackage-lock.json. If the project doesn't have one, usenpm install --omit=dev.--omit=dev– skips packages that are only needed for development.
Try the app by hand:
runuser -u mujbot -- node index.js. If it starts without errors, stop it with Ctrl+C. (If it needs a token, do the manual test after the Secrets section.)
Secrets in EnvironmentFile#
Tokens and passwords don't belong in the code or in the unit file. Store them in a variables file that only root can read, and systemd passes them to the app when it starts.
Create the file with
nano /etc/mujbot.env:/etc/ mujbot.env iniDISCORD_TOKEN=<BOT_TOKEN> NODE_ENV=production- Each line is
NAME=value, withoutexportand without spaces around=. - Lines starting with
#are comments. - In the app you read the value as
process..env. DISCORD_ TOKEN
- Each line is
Restrict the permissions:
bash chown root:root /etc/mujbot.env chmod 600 /etc/mujbot.envchmod 600– only root can read and change the file. systemd reads it before switching to the app user, so the variables reach the app but nobody else can read the file.
The service unit file#
Create the file with nano /etc/:
[Unit]
Description=My Discord bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mujbot
Group=mujbot
WorkingDirectory=/opt/mujbot/app
EnvironmentFile=/etc/mujbot.env
ExecStart=/usr/local/bin/node /opt/mujbot/app/index.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target| Directive | What it does | Recommended value | When to change |
|---|---|---|---|
Description= | Service description shown in listings. | App name | – |
After= | The service starts only after the network is up. | network-online.target | – |
Wants= | Asks the system to wait for the network (After alone isn't enough). | network-online.target | – |
Type=simple | The process from ExecStart is the service's main process and runs in the foreground. | simple | Only if the app forks into the background itself (forking). |
User=, Group= | Which user the app runs as. | the app user | Never root. |
WorkingDirectory= | Working directory; relative paths in the app are resolved from here. | the app directory | When you move the app. |
EnvironmentFile= | File with environment variables. If it doesn't exist, the service won't start (a dash before the path, -/etc/…, makes it optional). | /etc/mujbot.env | – |
ExecStart= | The start command. The program must be given as an absolute path. | /usr/local/bin/node /opt/…/index.js | Depending on the app's main file. |
Restart=on-failure | Restarts when the process exits with a non-zero code or is killed by a signal (except SIGTERM, SIGINT, SIGHUP and SIGPIPE, which systemd treats as a clean shutdown). Exiting with code 0 doesn't trigger a restart. | on-failure | always if it should start again even after a clean exit. |
RestartSec=5 | Waits 5 seconds before restarting (the default is 100 ms). | 5 | Longer if the crashing app hammers a third-party API. |
NoNewPrivileges=true | Neither the app nor its child processes can gain higher privileges (for example through setuid programs). | true | – |
WantedBy= | After enable, the service starts when the VPS boots. | multi-user.target | – |
Start rate limit
By default systemd allows no more than 5 starts of a service within 10 seconds. With RestartSec=5 this limit is never reached, so a crashing service will keep trying to start every 5 seconds forever. If you want it to give up after a series of crashes, add for example StartLimitIntervalSec= and StartLimitBurst=10 to the [Unit] section (at most 10 starts in 5 minutes).
Starting the service#
systemctl daemon-reload
systemctl enable --now mujbot
systemctl status mujbotsystemctl daemon-reload– loads new and changed unit files. Run it after every change to a.servicefile.systemctl enable --now mujbot– enables start at VPS boot and starts the service right away.systemctl status mujbot– the service status and the last few log lines.
In the status output, look for:
● mujbot.service - My Discord bot
Loaded: loaded (/etc/systemd/system/mujbot.service; enabled; preset: enabled)
Active: active (running) since …
Main PID: 12345 (node)Loaded: … enabled– the service will start after a VPS reboot.Active: active (running)– it's running.failedmeans it crashed;activating (auto-restart)means it's waiting to restart.Main PID– the app's process ID.
Other commands: systemctl restart mujbot (restart), systemctl stop mujbot (stop), systemctl disable mujbot (don't start at boot).
Logs#
Everything the app prints (console.log, print, errors) is stored by systemd in the journal.
| Command | What it shows |
|---|---|
journalctl -u mujbot -f | Live output like a console; stop it with Ctrl+C. |
journalctl -u mujbot -n 100 | The last 100 lines. |
journalctl -u mujbot --since "1 hour ago" | Output from the last hour. |
journalctl -u mujbot -b | Output since the last VPS boot. |
journalctl --disk-usage | How much space the journal takes. |
The journal cleans itself up: by default it uses at most 10% of the file system size, up to 4 GB.
Python and Java#
The steps are the same; only the runtime installation and the ExecStart line differ.
Python
Python 3 is already on the system. Create a virtual environment for the app so its packages don't mix with the system ones:
apt -y install python3-venv
runuser -u mujbot -- python3 -m venv /opt/mujbot/venv
runuser -u mujbot -- /opt/mujbot/venv/bin/pip install -r /opt/mujbot/app/requirements.txtpython3 -m venv /opt/mujbot/venv– creates a separate Python environment in thevenvdirectory./opt/mujbot/venv/bin/pip install -r …– installs the packages fromrequirements.txtinto that environment.
In the unit file, use the Python from the environment and turn off output buffering (otherwise logs show up in the journal with a delay):
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/mujbot/venv/bin/python /opt/mujbot/app/bot.pyJava
Install the Java runtime in the version your app requires. For Java 21:
apt -y install openjdk-21-jre-headlessFor Java 25, install the openjdk-25-jre-headless package instead. Both are in the Ubuntu 24.04 and Debian 13 repositories. Check the version with java -version.
In the unit file:
ExecStart=/usr/bin/java -Xms512M -Xmx1G -jar /opt/mujbot/app/aplikace.jar
SuccessExitStatus=143-Xms512M -Xmx1G– Java's initial and maximum memory. Set the maximum comfortably below the VPS's free RAM.SuccessExitStatus=143– on a clean stop (SIGTERM signal) Java exits with code 143. Without this line systemd would report the service asfailedafter everysystemctl stop.
Updating the app#
systemctl stop mujbot
cd /opt/mujbot/app
runuser -u mujbot -- git pull
runuser -u mujbot -- npm ci --omit=dev
systemctl start mujbotgit pull– fetches the new version of the code (if your app is in Git; otherwise upload the files over SFTP and runchown -R mujbot:mujbot /opt/mujbot).npm ci --omit=dev– installs changed packages.
If only code files changed, systemctl restart mujbot is enough.
Verification#
systemctl is-enabled mujbotprintsenabled,systemctl is-active mujbotprintsactive.- After a VPS reboot (
reboot) the app runs without you doing anything. - Testing the automatic restart:
systemctl kill -s SIGKILL mujbot(kills the process forcibly, as if it crashed). Within 5 secondssystemctl status mujbotshowsactive (running)again with a differentMain PID. A plainsystemctl killwithout-s SIGKILLsends SIGTERM, which systemd treats as a clean shutdown, so withRestart=on-failurethe service won't start again.
Troubleshooting#
status=203/EXEC#
systemd couldn't start the program from ExecStart. The program path doesn't exist, isn't absolute, or the file isn't executable. Check the path (ls -l /usr/local/bin/node) and, for scripts, the first line (#!/usr/bin/env …) and the execute permission.
status=217/USER#
The user from User= doesn't exist. Check the name (id mujbot), or create the user as described in A user for the app.
status=200/CHDIR#
The directory from WorkingDirectory= doesn't exist or the user can't access it. Check the path and the owner (ls -ld /opt/mujbot/app).
Failed to load environment files: No such file or directory#
The file from EnvironmentFile= doesn't exist. Check the path, or put - in front of it if the file is optional.
Start request repeated too quickly. (start-limit-hit)#
The service crashed too many times in a row and systemd stopped starting it. You'll find the cause in the log (journalctl -u mujbot -n 50). Once it's fixed, reset the counter and start the service:
systemctl reset-failed mujbot
systemctl start mujbotWarning: The unit file … changed on disk. Run 'systemctl daemon-reload' to reload units.#
You edited the unit file and didn't run systemctl daemon-reload. Run it and restart the service.
The app reports EACCES or permission denied#
The app user can't read or write the files. This typically happens after uploading files as root. Fix the owner: chown -R mujbot:mujbot /opt/mujbot. A regular user can't open a port below 1024 (for example 80); let the app listen on a higher port and put Nginx in front of it (guide).
The app doesn't see a variable from .env#
systemd doesn't load the .env file in the app directory on its own. Put the variables into the file from EnvironmentFile=, or load them in the app with a library (for example dotenv). Restart the service after changing the variables file.
Summary
- The app runs as its own user, not as root.
- The unit file in
/etc/systemd/system/sets the user, the directory, the command with an absolute path and restart after a crash. - Secrets live in the
EnvironmentFile=file with600permissions. - After editing the unit file:
systemctl daemon-reload; enable and start:systemctl enable --now. - Logs:
journalctl -u <SERVICE_NAME> -f.