Server administrationWritten by GLWS15 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

What you are building

A production app should start at boot, restart if it crashes, run as an unprivileged user and write logs you can read. systemd does all four, so you do not need extra process managers. The same approach works for Python and other languages: only the install steps and the ExecStart line change.

This guide runs a tiny example app on 127.0.0.1:3000. Replace it with your own application, and use the reverse proxy guide to publish it with HTTPS. Do not publish the app's port directly.

Install Node.js on Ubuntu

Use Node.js 24 LTS for this example. Ubuntu 24.04's default Node.js 18 package is past its upstream end of life. The following commands add NodeSource's third-party repository; download and inspect its setup script before running it with sudo. Check the release policy before choosing a version for your own app.

Ubuntu only
sudo apt update
sudo apt install ca-certificates curl
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource-setup.sh
less nodesource-setup.sh
sudo bash nodesource-setup.sh
sudo apt install nodejs
node --version
npm --version

Install Node.js on AlmaLinux

AlmaLinux 9 provides Node.js 24 as a module stream. Install that stream for this example. If another stream is already enabled, review its applications before switching; do not reset or replace a runtime used by another service.

AlmaLinux only
sudo dnf module list nodejs
sudo dnf module install nodejs:24
node --version
npm --version

Create a service user and the app

Run the app as a dedicated system user with no login shell and no home directory privileges beyond its own folder. Put the code in /opt/myapp, owned by root so the app cannot rewrite itself.

The package manifest and lockfile make the later npm ci update step reproducible. This tiny app has no external dependencies; for a real project, commit its package.json and package-lock.json together and deploy both.

Both systems
sudo useradd --system --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo mkdir -p /opt/myapp
sudo tee /opt/myapp/server.js >/dev/null <<'EOF'
const http = require('http');
const port = process.env.PORT || 3000;
http.createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);
  res.end('Hello from my app\n');
}).listen(port, '127.0.0.1', () => console.log(`Listening on ${port}`));
EOF
sudo tee /opt/myapp/package.json >/dev/null <<'EOF'
{
  "name": "glws-node-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": { "start": "node server.js" }
}
EOF
cd /opt/myapp
sudo npm install --package-lock-only --ignore-scripts
sudo chown -R root:root /opt/myapp

Write the unit file

The unit restarts the app after failures, loads settings from an environment file and applies sandboxing options that block most common ways for a compromised app to damage the system. Put secrets in the environment file, not in the unit.

Both systems
sudo tee /etc/myapp.env >/dev/null <<'EOF'
PORT=3000
NODE_ENV=production
EOF
sudo chmod 640 /etc/myapp.env
sudo chgrp myapp /etc/myapp.env
sudo tee /etc/systemd/system/myapp.service >/dev/null <<'EOF'
[Unit]
Description=My Node.js app
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp.env
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
EOF
sudo systemd-analyze verify /etc/systemd/system/myapp.service
ProtectSystem=strict makes the whole filesystem read-only for the app. If it needs to write somewhere, such as /var/lib/myapp, create the folder, own it by myapp and add ReadWritePaths=/var/lib/myapp to the unit.

Start it and test

Reload systemd so it sees the new file, then enable the service to start at boot and start it now. Check its status, test the port and kill the process to confirm that systemd restarts it.

Both systems
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp --no-pager
curl --fail --show-error --retry 10 --retry-delay 1 --retry-connrefused http://127.0.0.1:3000/
Prove the restart
sudo systemctl kill --kill-whom=main -s KILL myapp
sleep 5
systemctl is-active myapp
RECORDED LAB OUTPUT · Ubuntu 24.04 LTS
v24.21.0
Hello from my app
Hello from my app
LISTEN 0      511        127.0.0.1:3000       0.0.0.0:*    users:(("MainThread",pid=45003,fd=21))   
Recorded Node.js 24 service response and loopback listener. Process identifiers and installed patch versions vary.

Read the logs

Everything the app prints to standard output and standard error goes to the journal, with timestamps. Follow it live while testing, and filter by time when investigating a problem.

Both systems
sudo journalctl -u myapp -f
sudo journalctl -u myapp --since "1 hour ago" --no-pager

Update the app

Copy the new code to /opt/myapp, install production dependencies, then restart. Keep the previous version until you have tested the new one.

Both systems
cd /opt/myapp
sudo npm ci --omit=dev
sudo systemctl restart myapp
curl --fail --show-error --retry 10 --retry-delay 1 --retry-connrefused http://127.0.0.1:3000/
sudo journalctl -u myapp -n 20 --no-pager
If the app has a package.json, run npm as root only in the code directory, and never as the service user. Avoid running install scripts from packages you do not trust.

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.