Developers

How to Host a Node.js App on a VPS with systemd and Nginx

Run a Node.js app in production: install Node, keep it alive with systemd, put Nginx in front as a reverse proxy and add HTTPS. Commands included.

By Zebra Hosting Cloud

Running node server.js in a terminal works on your laptop, but a production app has to survive crashes, reboots and closed SSH sessions, and it should not be exposed to the internet directly. The standard solution is simple: let systemd run and restart the app, and let Nginx sit in front as a reverse proxy that handles HTTPS. This guide shows how to set that up on a Linux VPS.

1. Install Node.js

You have a few options. The distribution package is easy but may be an older version. The NodeSource repository or the official binary builds give you current LTS releases, and tools such as nvm let you switch versions per user. For a server, prefer an LTS (long-term support) version, because it receives security fixes for longer. Check the result with:

node --version
npm --version

Whichever method you use, note the full path to the binary (which node), because systemd needs it. With nvm the path lives inside the user's home directory, which makes unit files fiddly, so many people use a system-wide install for production.

2. Put the app in place

Create an unprivileged user, place the code in a sensible folder, and install production dependencies only:

sudo adduser --system --group --home /var/www/myapp myapp
cd /var/www/myapp
sudo -u myapp git clone https://example.com/you/myapp.git .
sudo -u myapp npm ci --omit=dev

Replace the repository URL with your own. npm ci installs exactly what your lockfile says, which makes deployments repeatable. Make sure the app listens on a local port such as 3000 and, ideally, only on the loopback address, for example app.listen(3000, '127.0.0.1').

3. Run it with systemd

Create /etc/systemd/system/myapp.service:

[Unit]
Description=My Node.js app
After=network.target

[Service]
User=myapp
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=-/etc/myapp.env

[Install]
WantedBy=multi-user.target

The EnvironmentFile line, with a leading dash so a missing file is not an error, is where secrets and settings such as database URLs belong, outside the code. Then start it and make it boot automatically:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
sudo journalctl -u myapp -f

The last command follows the app's output, since systemd collects everything the process writes to stdout and stderr. This replaces the need for a separate log file for many apps.

4. Add Nginx as a reverse proxy

Nginx listens on ports 80 and 443 and forwards requests to your app. A basic server block:

server {
    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 "upgrade";
    }
}

The Upgrade and Connection headers allow WebSockets to pass through. Run sudo nginx -t and reload. If your app needs the visitor's real address or protocol, it must read the X-Forwarded-* headers, and frameworks such as Express require you to enable "trust proxy" for that to work correctly. Finally, add HTTPS with sudo certbot --nginx -d app.example.com.

Alternatives, a checklist and deploying updates

PM2 and containers

PM2 is a popular Node process manager that offers clustering and a nice process list. It is a valid choice, especially if you already know it, but systemd provides restart-on-crash, boot start and logging without another tool to install. Containers are another option; our Docker article covers that route. Use one approach per app and avoid mixing them.

Production checklist

Confirm the app is only reachable through the proxy by running sudo ss -tlnp | grep 3000; the local address should read 127.0.0.1:3000, not 0.0.0.0:3000 or *:3000. A port that listens on every interface is reachable from the internet unless a firewall blocks it. Also remember that journald keeps logs for you, but they are not infinite: check their size with journalctl --disk-usage and read the knowledge base for general server housekeeping tips.

  • Set NODE_ENV=production so frameworks skip development behaviour.
  • Run as an unprivileged user, never root.
  • Keep the app port closed to the internet; only Nginx should reach it.
  • Handle SIGTERM so the app finishes in-flight requests when systemd stops it.
  • Commit your lockfile and use npm ci on the server.
  • Monitor memory use; Node processes with leaks will eventually be killed by the kernel.
  • Update dependencies regularly and run npm audit to see known issues.
  • Add a simple health-check route, such as /health, that returns 200 when the app and its database connection work, so monitoring has something reliable to test.

Deploying new versions

The simple update routine is: pull the new code, install dependencies, restart.

cd /var/www/myapp
sudo -u myapp git pull
sudo -u myapp npm ci --omit=dev
sudo systemctl restart myapp

A restart causes a short interruption. If that matters, read about zero-downtime deployment techniques and run more than one instance behind the proxy.

Memory, logs and crash loops

Node.js applications run in a single process by default and keep their working data in memory, so watch memory use over days, not minutes. A slow upward trend usually means a leak, such as an array or cache that is never trimmed. The Restart=on-failure line brings the app back after a crash, but if it crashes immediately on every start, systemd will keep restarting it every five seconds and filling the journal. In that case look at journalctl -u myapp -n 100; typical causes are a missing environment variable, a wrong file path or a port that is already in use.

Plan for background work too. Tasks such as sending email or resizing images belong in a queue handled by a separate worker process, not inside a web request, so that a slow task never blocks visitors. A second systemd unit for the worker, using the same code but a different start command, keeps this simple. If your app needs a database or cache, install and secure those services first, and see the security page for broader hardening.

Hosting Node.js with Zebra Hosting

A Node.js app needs a server where you can install your own runtime and services, which is what a Zebra Hosting VPS provides. Delivery takes up to 24 hours, and our team can answer setup questions in 17 languages.