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.
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 --versionInstall 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.
sudo dnf module list nodejs
sudo dnf module install nodejs:24
node --version
npm --versionCreate 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.
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/myappWrite 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.
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.serviceStart 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.
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/sudo systemctl kill --kill-whom=main -s KILL myapp
sleep 5
systemctl is-active myappv24.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)) 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.
sudo journalctl -u myapp -f
sudo journalctl -u myapp --since "1 hour ago" --no-pagerUpdate 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.
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-pagerOfficial documentation
These upstream references provide more detail on the commands and workflows in this guide.
- systemd.service manual (opens in a new tab)
- systemd.exec manual (sandboxing options) (opens in a new tab)
- Node.js: Download and install (opens in a new tab)
- Node.js documentation (opens in a new tab)
- Node.js release support policy (opens in a new tab)
- NodeSource distribution installation instructions (opens in a new tab)