Skip to content
Current coupon:10% offUse it in your order

Run an app 24/7 on a VPS: systemd service (Node.js, Python, Java)

Run an app or bot on your VPS as a systemd service: it runs in the background, restarts after a crash and after a VPS reboot, logs to the journal and keeps secrets out of the code.

  • advanced
  • ~20 min
  • Updated 1 Oct 2026

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.

MethodRestart after a crashStart after a VPS rebootLogsNote
Terminal, nohup, &NoNoOnly to a fileFine for a quick test only.
screen, tmuxNoNoOnly in the windowHandy for manual work, not for production.
pm2YesYes (once configured)Its ownOne more program, Node.js only.
systemdYesYesjournalPart 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:

bash
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/mujbot directory 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:

bash
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 -
Outputtext
node-v24.21.0-linux-x64.tar.xz: OK

Only 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):

bash
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
CommandWhat it does
apt -y install curl xz-utilsA 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.21.0-linux-x64.tar.xz.
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/nodejsA directory for Node.js.
tar -xJf … -C /opt/nodejs --strip-components=1Unpacks 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#

  1. Upload the app's code to /opt/mujbot/app (over SFTP, guide, or with git clone). Don't upload the .env file with the token or the node_modules directory.

  2. Hand the files over to the app user:

    bash
    chown -R mujbot:mujbot /opt/mujbot
  3. Install the packages as the app user:

    bash
    cd /opt/mujbot/app
    runuser -u mujbot -- npm ci --omit=dev
    • runuser -u mujbot -- – runs the command as the mujbot user (works even without the sudo package).
    • npm ci – installs exactly the versions from package-lock.json. If the project doesn't have one, use npm install --omit=dev.
    • --omit=dev – skips packages that are only needed for development.
  4. 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.

  1. Create the file with nano /etc/mujbot.env:

    /etc/mujbot.envini
    DISCORD_TOKEN=<BOT_TOKEN>
    NODE_ENV=production
    • Each line is NAME=value, without export and without spaces around =.
    • Lines starting with # are comments.
    • In the app you read the value as process.env.DISCORD_TOKEN.
  2. Restrict the permissions:

    bash
    chown root:root /etc/mujbot.env
    chmod 600 /etc/mujbot.env
    • chmod 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/systemd/system/mujbot.service:

/etc/systemd/system/mujbot.serviceini
[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
DirectiveWhat it doesRecommended valueWhen to change
Description=Service description shown in listings.App name–
After=network-online.targetThe service starts only after the network is up.network-online.target–
Wants=network-online.targetAsks the system to wait for the network (After alone isn't enough).network-online.target–
Type=simpleThe process from ExecStart is the service's main process and runs in the foreground.simpleOnly if the app forks into the background itself (forking).
User=, Group=Which user the app runs as.the app userNever root.
WorkingDirectory=Working directory; relative paths in the app are resolved from here.the app directoryWhen 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.jsDepending on the app's main file.
Restart=on-failureRestarts 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-failurealways if it should start again even after a clean exit.
RestartSec=5Waits 5 seconds before restarting (the default is 100 ms).5Longer if the crashing app hammers a third-party API.
NoNewPrivileges=trueNeither the app nor its child processes can gain higher privileges (for example through setuid programs).true–
WantedBy=multi-user.targetAfter 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=300 and StartLimitBurst=10 to the [Unit] section (at most 10 starts in 5 minutes).

Starting the service#

bash
systemctl daemon-reload
systemctl enable --now mujbot
systemctl status mujbot
  • systemctl daemon-reload – loads new and changed unit files. Run it after every change to a .service file.
  • 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:

Outputtext
● 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. failed means 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.

CommandWhat it shows
journalctl -u mujbot -fLive output like a console; stop it with Ctrl+C.
journalctl -u mujbot -n 100The last 100 lines.
journalctl -u mujbot --since "1 hour ago"Output from the last hour.
journalctl -u mujbot -bOutput since the last VPS boot.
journalctl --disk-usageHow 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:

bash
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.txt
  • python3 -m venv /opt/mujbot/venv – creates a separate Python environment in the venv directory.
  • /opt/mujbot/venv/bin/pip install -r … – installs the packages from requirements.txt into 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):

ini
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/mujbot/venv/bin/python /opt/mujbot/app/bot.py

Java

Install the Java runtime in the version your app requires. For Java 21:

bash
apt -y install openjdk-21-jre-headless

For 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:

ini
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 as failed after every systemctl stop.

Updating the app#

bash
systemctl stop mujbot
cd /opt/mujbot/app
runuser -u mujbot -- git pull
runuser -u mujbot -- npm ci --omit=dev
systemctl start mujbot
  • git pull – fetches the new version of the code (if your app is in Git; otherwise upload the files over SFTP and run chown -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 mujbot prints enabled, systemctl is-active mujbot prints active.
  • 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 seconds systemctl status mujbot shows active (running) again with a different Main PID. A plain systemctl kill without -s SIGKILL sends SIGTERM, which systemd treats as a clean shutdown, so with Restart=on-failure the 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:

bash
systemctl reset-failed mujbot
systemctl start mujbot

Warning: 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 with 600 permissions.
  • After editing the unit file: systemctl daemon-reload; enable and start: systemctl enable --now.
  • Logs: journalctl -u <SERVICE_NAME> -f.

Search the guides