A Node.js application in production needs four things the development server does not give you: it must start at boot and restart when it crashes, run as an unprivileged user, sit behind a web server that handles TLS, and leave logs you can read. On a Debian or Ubuntu VPS, systemd and Nginx provide all of that without extra process managers. This guide builds the setup step by step.
1. Install a supported Node.js version
Check what the distribution offers before you install. Debian 13 (trixie) packages Node.js 20.19.2, Debian 12 (bookworm) 18.20.4, and Ubuntu 24.04 18.19.1. All three are older lines that the Node.js project itself no longer supports, although the distributions ship security updates for their own packages. If your app needs a current LTS line (24 or 26 as of late October 2026), use NodeSource's repository, reading the setup script before you run it:
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
less nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
command -v nodeNote the path printed by the last command; the systemd unit needs it.
2. Create a service user and a layout
Run the app as a system account that cannot log in and does not own its own code. On Debian, adduser --system creates an account with the shell /usr/sbin/nologin and no password:
sudo adduser --system --group --no-create-home myapp
sudo mkdir -p /srv/myapp/app /srv/myapp/data /etc/myapp
sudo chown myapp:myapp /srv/myapp/dataPut the code in /srv/myapp/app, owned by your normal deploy account rather than by myapp, and install production dependencies there with npm ci --omit=dev. The app can read its code but cannot modify it; only /srv/myapp/data is writable, for uploads or other files the app must store.
3. Keep configuration in an environment file
Secrets do not belong in the unit file or the repository. Create /etc/myapp/myapp.env:
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
DATABASE_URL=postgres://myapp:change-me@127.0.0.1:5432/myappsudo chown root:myapp /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.envMake sure the application listens on process.env.HOST and process.env.PORT, so it only accepts connections from Nginx on the same machine. Setting NODE_ENV=production matters for frameworks such as Express, whose performance guide lists it as one of the simplest improvements.
4. Write the systemd unit
Create /etc/systemd/system/myapp.service, using the node path from step 1:
[Unit]
Description=My Node.js app
After=network.target
[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/myapp/data
[Install]
WantedBy=multi-user.targetWhat the important lines do:
Type=execmakessystemctl startreport a failure if the binary cannot be started, for example when the user or the file is missing; the systemd manual calls it typically the better choice than the defaultsimple.Restart=on-failurerestarts the app when it crashes, but not when you stop it on purpose.ProtectSystem=strictmounts the whole file system read-only for the service, andReadWritePaths=opens only the data directory.ProtectHome=truehides/home,/rootand/run/user.
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
curl -I http://127.0.0.1:3000/5. Put Nginx in front
Nginx terminates HTTPS, serves the public ports and forwards requests to the app. First add the WebSocket helper from the Nginx documentation as /etc/nginx/conf.d/connection_upgrade.conf (Debian's nginx.conf includes conf.d/*.conf inside the http block):
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then create /etc/nginx/sites-available/myapp:
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxproxy_http_version 1.1 is needed for WebSockets on the Nginx versions Debian ships (1.22 in bookworm, 1.26 in trixie); Nginx only made 1.1 the default in 1.29.7. Because Upgrade and Connection are hop-by-hop headers, Nginx does not pass them on unless you set them explicitly. If you use Express, configure its trust proxy setting (for example to loopback) so it reads the client address and protocol from these headers.
6. Add HTTPS with certbot
The certbot project recommends its snap package; Debian also packages certbot and its Nginx plugin, and the Debian package installs a systemd timer for renewals:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com
systemctl list-timers | grep certbot
sudo certbot renew --dry-runOpen only ports 80 and 443 in your firewall. The app's port stays private because it listens on 127.0.0.1. On a Skyline Cloud VPS you can also apply these rules in the cloud firewall from the console, which filters traffic before it reaches the server.
7. Read the logs
Anything the app writes to standard output or standard error goes to the systemd journal:
journalctl -u myapp -f
journalctl -u myapp -n 100
journalctl -u myapp --since "1 hour ago"Nginx keeps its own logs in /var/log/nginx/; a 502 error there usually means the app is down or listening on a different port.
Deploying updates
A simple release is: update the code, run npm ci --omit=dev and any build step, then sudo systemctl restart myapp. The restart causes a short gap while the process starts. If that is not acceptable, run two instances on different ports behind an Nginx upstream and restart them one at a time.
If you would rather not manage a server, Skyline's web hosting runs Node.js 22 apps (up to three per account) from S Panel, restarts them if they crash, shows their logs and serves them on your domain with free SSL.
What you should do
- Install a Node.js version that is still supported upstream, and note its path.
- Run the app as a dedicated system user that cannot write to its own code.
- Keep secrets in an environment file readable only by root and the service group.
- Use a systemd unit with
Type=exec,Restart=on-failureand the sandboxing options shown. - Bind the app to 127.0.0.1, put Nginx and certbot in front, and expose only ports 80 and 443.
- Check
journalctlafter every deploy and confirm certbot's renewal timer is active.
Sources
- qa.debian.org/madison.php?package=nodejs
- packages.ubuntu.com/noble/nodejs
- raw.githubusercontent.com/nodejs/Release/main/schedule.json
- deb.nodesource.com/setup_24.x
- github.com/nodesource/distributions
- manpages.debian.org/trixie/adduser/adduser.8.en.html
- docs.npmjs.com/cli/v11/commands/npm-ci
- expressjs.com/en/advanced/best-practice-performance.html
- expressjs.com/en/guide/behind-proxies.html
- manpages.debian.org/trixie/systemd/systemd.service.5.en.html
- manpages.debian.org/trixie/systemd/systemd.exec.5.en.html
- manpages.debian.org/trixie/systemd/journalctl.1.en.html
- nginx.org/en/docs/http/ngx_http_proxy_module.html
- nginx.org/en/docs/http/websocket.html
- qa.debian.org/madison.php?package=nginx
- sources.debian.org/src/nginx/1.26.3-3%2Bdeb13u7/debian/conf/nginx.conf
- eff-certbot.readthedocs.io/en/stable/install.html
- eff-certbot.readthedocs.io/en/stable/using.html
- packages.debian.org/trixie/all/certbot/filelist
- cloud.alskyline.com/vps-saudi-arabia
- cloud.alskyline.com/nodejs-hosting-saudi-arabia
Comments
0 total · 0 threads