Key takeaways

  • Bind the app to 127.0.0.1 and let Nginx be the only process listening on the public ports 80 and 443.
  • Describe the app in a PM2 ecosystem file, and name it ecosystem.config.cjs if package.json sets "type": "module".
  • Use PM2 cluster mode for stateless HTTP APIs, because it is what lets pm2 reload replace workers without dropping requests.
  • Add a cheap /healthz endpoint and a SIGINT handler that closes the server, then check the endpoint after every deploy.
  • Install pm2-logrotate on day one; PM2 never deletes its own log files.
  • A 413 page with an nginx footer means client_max_body_size is too small; a 413 from the app itself means its body parser limit is.

To deploy a Node.js app to a VPS, you put the code on the server, install its dependencies with Node.js 22, run it under PM2 so it restarts after crashes and reboots, and put Nginx in front of it to handle HTTPS and forward requests to the app on a local port. That holds for Express, Fastify, NestJS, Koa or a hand-written http server. On a fresh Ubuntu 24.04 server it takes about an hour the first time.

This guide goes further than "it runs". It covers binding to the right interface, a health check endpoint, a PM2 ecosystem file, cluster mode, log rotation and zero-downtime reloads, then the errors you will actually meet and how to fix each. If you are deploying Next.js, How to Deploy a Next.js App on a Linux VPS covers the build-time pieces specific to it.

What does a Node.js app on a VPS look like?

A request passes through three layers:

Internet --> Nginx on :80 and :443   (TLS, forwarding headers, body size limits)
         --> 127.0.0.1:3000          (your Node.js app, supervised by PM2)
  • Nginx terminates TLS, enforces request size limits, adds forwarding headers and can serve static files. It is the only thing listening on public ports.
  • PM2 starts the app, restarts it when it crashes, starts it again after a reboot, and collects its logs.
  • The app listens on a local port and knows nothing about certificates.

You could run Node directly on port 443, but you would be reimplementing certificate renewal, and only root can bind ports below 1024. Keep the app on a high port behind Nginx.

What do you need before you start?

  • An Ubuntu 24.04 LTS VPS with at least 1 GB of RAM. Debian 12 uses the same commands.
  • SSH access with sudo, ideally with a key rather than a password.
  • A domain whose DNS records you can edit.
  • The app in a Git repository with a committed lockfile, and a single start command such as node dist/main.js.
  • For TypeScript, a build script that compiles to JavaScript, such as tsc or nest build. Compile once at deploy time and run the output, rather than compiling on every start.

The examples use api as the app name, api.example.com as the domain and deploy as the Linux user.

Should a Node.js app listen on 127.0.0.1 or 0.0.0.0?

Behind Nginx on the same server, listen on 127.0.0.1. Binding to 0.0.0.0, or calling listen(port) with no host at all, which in Node means every interface including IPv6, makes the app reachable directly on its port whenever the firewall lets traffic through. That bypasses Nginx's TLS, headers and limits. With 127.0.0.1, the only way in is through Nginx, whatever the firewall says.

Use 0.0.0.0 only when something on another interface has to reach the process: inside a Docker container, where the published port arrives on the container's own network interface, or when a load balancer on another machine connects to the app directly.

Read the host and port from the environment so the same code runs everywhere:

// server.js (Express)
const express = require('express');
const app = express();

app.set('trust proxy', 1); // believe X-Forwarded-* from Nginx, one hop away

const port = Number(process.env.PORT) || 3000;
const host = process.env.HOST || '127.0.0.1';

const server = app.listen(port, host, () => {
  console.log('listening on ' + host + ':' + port);
  if (process.send) process.send('ready'); // tells PM2 this instance is up
});

For Fastify, pass the host explicitly to fastify.listen({ port, host }). For NestJS, await app.listen(port, host). The trust proxy line matters more than it looks: without it, Express sees every request arriving from 127.0.0.1 over plain HTTP, so req.ip, rate limiting and secure cookies all behave wrongly. Fastify's equivalent is its trustProxy option.

What should the app have before it goes on a server?

Two small pieces of code make every later step more reliable.

A health check endpoint

app.get('/healthz', (req, res) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() });
});

Keep it cheap and unauthenticated, and exclude it from rate limiting. Call it after every deploy: curl -fsS http://127.0.0.1:3000/healthz exits non-zero if the app is down or returns an error, which makes it easy to script. Point an external uptime monitor at it too. Decide deliberately whether it checks the database. A check that pings the database tells you more, but it also reports the app as unhealthy when only the database is, which matters if anything restarts the app when the check fails.

Graceful shutdown

PM2 stops a process by sending SIGINT, then SIGKILL if it is still running after kill_timeout, which defaults to 1.6 seconds. Close the server so requests in flight can finish:

process.on('SIGINT', () => {
  server.close(() => process.exit(0));
});

Close database pools and queues in the same handler. Without it, every restart cuts off whatever requests were in progress. In NestJS, call app.enableShutdownHooks() and do the cleanup in onModuleDestroy or beforeApplicationShutdown.

How to deploy a Node.js app to a VPS manually

Step 1: Prepare the server (10 minutes)

Update the system, create a non-root user that will own and run the app, copy your SSH key to it, and install the base packages:

sudo apt update && sudo apt upgrade -y
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
sudo apt install -y git nginx build-essential

Confirm ssh deploy@your-server-ip works from a second terminal, then do everything else as deploy. The app, its files and its PM2 processes all belong to this one user, which is what keeps most of the permission errors later in this guide from happening.

Step 2: Install Node.js 22 and PM2 (5 minutes)

Node.js 22 and 24 are both LTS lines; Ubuntu's own nodejs package is older than either. Install from NodeSource:

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
sudo npm install -g pm2
node -v && pm2 -v

The Node.js download page lists the other supported install methods, including nvm, which installs Node per user without sudo. If you use nvm, drop sudo from npm install -g, and expect the Node path to change with each version.

Step 3: Get the code onto the server (5 minutes)

mkdir -p ~/apps
git clone git@github.com:your-org/api.git ~/apps/api
cd ~/apps/api

For a private repository, generate a key on the server with ssh-keygen -t ed25519 and add its public half to the repository as a read-only deploy key, instead of copying your personal key onto the server. Do not clone with sudo: the files would belong to root, and the app, running as deploy, could not write to them.

Step 4: Put the environment variables on the server (5 minutes)

Split configuration in two. Non-secret settings such as the port go in the PM2 ecosystem file in Step 6. Secrets go in a file on the server, readable only by the app's user and never committed:

nano ~/apps/api/.env
chmod 600 ~/apps/api/.env
DATABASE_URL=postgres://api:secret@127.0.0.1:5432/api
JWT_SECRET=change-me

Node.js 22 reads env files natively, so you do not need a library for it. Call this at the very top of your entry file, before anything reads process.env:

process.loadEnvFile('.env'); // Node 20.12 and later; throws if the file is missing

dotenv does the same on older versions. Two rules save debugging later. First, PM2 captures a process's environment when it starts; values from the ecosystem file or the shell change only on a restart or reload with --update-env, while values the app loads from .env itself are re-read on every start. Second, set NODE_ENV=production: Express and many libraries only switch to production behavior, such as caching views and hiding stack traces, when it is set.

Step 5: Install dependencies and build (5 minutes)

npm ci
npm run build          # skip if the app is plain JavaScript
npm prune --omit=dev   # optional: remove build-only packages

npm ci installs exactly what the lockfile says. If NODE_ENV=production is exported in your shell, npm leaves out devDependencies, and a TypeScript build then fails because typescript or @nestjs/cli is missing. Install everything, build, then prune.

Step 6: Write an ecosystem file and start the app (5 minutes)

An ecosystem file records how the app runs, so nobody retypes flags and the configuration lives next to the code. Create ecosystem.config.js in the project root:

module.exports = {
  apps: [
    {
      name: 'api',
      cwd: '/home/deploy/apps/api',
      script: 'dist/main.js',
      exec_mode: 'cluster',
      instances: 2,
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
        HOST: '127.0.0.1',
      },
      max_memory_restart: '300M',
      wait_ready: true,
      listen_timeout: 10000,
      kill_timeout: 5000,
      time: true,
    },
  ],
};

What the settings do:

  • script is the compiled entry file, and cwd makes relative paths such as .env resolve from the project root.
  • exec_mode: 'cluster' with instances: 2 runs two copies sharing port 3000; the cluster section below explains when that is right. 'max' means one per CPU core.
  • max_memory_restart restarts an instance that grows past the limit, instead of letting a leak push the whole server into swap.
  • wait_ready makes PM2 wait for process.send('ready') before treating an instance as up, with listen_timeout as the upper bound. kill_timeout gives graceful shutdown five seconds.
  • time: true prefixes each log line with a timestamp.

If package.json contains "type": "module", a .js file is treated as an ES module and module.exports fails, so name the file ecosystem.config.cjs instead. The PM2 ecosystem generator writes this file from a form. Start the app and check it:

pm2 start ecosystem.config.js
pm2 status
curl -fsS http://127.0.0.1:3000/healthz

Step 7: Survive reboots and rotate the logs (3 minutes)

pm2 save
pm2 startup
# then run the "sudo env PATH=..." line it prints

pm2 startup generates a systemd unit named pm2-deploy that restores the saved process list at boot, but nothing happens until you run the command it prints. Run pm2 save again whenever the process list changes.

PM2 writes logs to ~/.pm2/logs/ and never deletes them, so a chatty app slowly fills a small disk. Install the rotation module:

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

That rotates any log larger than 10 MB, and every log once a day at midnight by default, keeps 14 rotated files per log and compresses them. pm2 flush empties the current logs if one has already grown too large.

Step 8: Put Nginx in front (10 minutes)

Define the WebSocket upgrade mapping once for the whole server:

# /etc/nginx/conf.d/websocket-upgrade.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Then create /etc/nginx/sites-available/api:

server {
    listen 80;
    listen [::]:80;
    server_name api.example.com;

    client_max_body_size 10m;

    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;
    }
}

The forwarding headers are what trust proxy reads. The Upgrade and Connection headers, with HTTP/1.1, let WebSockets through. client_max_body_size raises Nginx's default 1 MB request body limit; set it to the largest upload you actually accept, or generate the whole block with the Nginx config generator. Enable the site, test, reload:

sudo ln -s /etc/nginx/sites-available/api /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Step 9: Open the firewall and point DNS (5 minutes, plus propagation)

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Allow SSH before enabling UFW, or you lock yourself out. Port 3000 is deliberately not opened. If your provider runs its own firewall or security group in front of the server, allow 80 and 443 there as well.

At your DNS provider, create an A record for api.example.com pointing to the server's public IPv4 address. Check it from your own machine with dig +short api.example.com before you request a certificate.

Step 10: Add HTTPS with Certbot (5 minutes)

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx --redirect -d api.example.com
sudo certbot renew --dry-run

Certbot obtains a Let's Encrypt certificate, adds the HTTPS listener to the server block, redirects HTTP to HTTPS, and installs a systemd timer that renews the certificate before it expires. The dry run proves renewal will work.

Step 11: Verify the deployment (3 minutes)

curl -fsS https://api.example.com/healthz
pm2 status
pm2 logs api --lines 50

From your own computer, also confirm the app port is not exposed: curl -m 5 http://your-server-ip:3000 should fail. If it answers, the app is bound to every interface and the firewall is open. Finally reboot with sudo reboot and check the health endpoint answers again without you doing anything.

Should you use PM2 cluster mode or fork mode?

Fork mode, the default, runs one Node process per app entry. Cluster mode uses Node's built-in cluster module: PM2 starts several workers that share one port and spreads incoming connections across them.

Fork modeCluster mode
ProcessesOne per app entryAs many workers as instances, sharing one port
CPU cores usedOneAs many as you configure
pm2 reloadBehaves like a restart, with a short outageReplaces workers one at a time without an outage
In-memory state (sessions, caches, rate-limit counters)Consistent, because there is one processSeparate per worker; move it to Redis or the database
Scheduled jobs inside the appRun onceRun once per worker unless guarded
WebSockets and Socket.IOWork as they areNeed sticky sessions or WebSocket-only transport, plus an adapter to broadcast across workers
Scripts that are not Node.jsSupportedNot supported

Use cluster mode for a stateless HTTP API, even on a small server, because it is what makes pm2 reload zero-downtime. Stay in fork mode for apps that keep state in memory or are not Node.js. If cron-style jobs live inside the app, run them only where process.env.NODE_APP_INSTANCE is '0', which PM2 sets on the first worker. Never set instances above 1 in fork mode for an app that listens on a port: the second process fails with EADDRINUSE. The PM2 cluster mode documentation has the details, and What is PM2? covers when plain systemd is enough instead.

How do you deploy updates without downtime?

With cluster mode, wait_ready and the shutdown handler in place, an update is:

cd ~/apps/api
git pull --ff-only
npm ci
npm run build
pm2 reload ecosystem.config.js --update-env
curl -fsS http://127.0.0.1:3000/healthz

pm2 reload starts a replacement worker, waits for it to send ready, then sends the old one SIGINT, and repeats for each worker, so one is always serving. --update-env makes PM2 apply environment changes instead of reusing what it captured at the first start. In fork mode the same command degrades to a plain restart.

The script still has a gap. npm ci deletes node_modules before reinstalling, and the build overwrites dist/ while the old workers are still running from them. A worker that loads a module lazily during that window, or crashes and restarts mid-build, meets a half-installed tree. For a small API the window is seconds and usually harmless. The robust version builds each deploy in its own release directory and switches only once the build and the health check pass, which also makes rollback a one-line operation. How to roll back a deployment on a VPS shows that layout.

Where do Node.js deployments break?

EADDRINUSE: address already in use

Error: listen EADDRINUSE: address already in use 127.0.0.1:3000 means something already holds the port:

sudo ss -ltnp 'sport = :3000'
pm2 list

The usual culprits are a node dist/main.js you started by hand in an SSH or tmux session and forgot, a second PM2 app configured with the same port, or instances above 1 in fork mode. Stop the stray process, remove duplicates with pm2 delete, and run pm2 save. In cluster mode the PM2 daemon holds the port for all its workers, so EADDRINUSE there almost always means a process outside PM2.

The app crashes on start

pm2 status shows the app as errored, or its restart counter climbs every few seconds. Read the reason:

pm2 logs api --lines 100
pm2 describe api

If the logs are not conclusive, stop the PM2 app and run the same command in the foreground, from the same directory:

pm2 stop api
cd ~/apps/api && NODE_ENV=production node dist/main.js

Most crash loops come from one of five causes: a missing environment variable, a missing dist/ because the build never ran or failed, a wrong script path in the ecosystem file, a database or Redis the app cannot reach at startup, or an ES module and CommonJS mismatch after a dependency update. After enough rapid crashes PM2 marks the app errored and stops restarting it; fix the cause, then pm2 restart api.

EACCES and other permission errors

EACCES: permission denied means the app's user cannot read or write a path, or bind a port.

  • Files owned by root. Something was cloned, built or installed with sudo. Fix it with sudo chown -R deploy:deploy ~/apps/api, and stop using sudo inside the app directory.
  • A port below 1024. Only root can bind 80 or 443. Do not run the app as root to get around it; that is Nginx's job.
  • Global npm installs. With the NodeSource package, npm install -g needs sudo; with nvm it must not have it.
  • Nginx cannot read your files. If Nginx serves static files or uploads directly, its www-data user needs execute permission on every directory in the path, and Ubuntu creates home directories as 750, which blocks it. Serve such files from a directory under /var/www instead, or grant access deliberately.

The site does not load from outside

If curl http://127.0.0.1:3000/healthz works on the server but the domain times out from your laptop, the request is not reaching Nginx:

sudo ufw status verbose
sudo ss -ltnp | grep nginx

Check that UFW allows Nginx Full, that Nginx is listening on 80 and 443, and that your cloud provider's firewall or security group allows 80 and 443 as well. Those sit in front of the VPS, and UFW knows nothing about them. A timeout points at a firewall; "connection refused" points at Nginx not running or not listening on that port.

413 Request Entity Too Large

Nginx rejects request bodies over 1 MB unless told otherwise. If uploads fail with a 413 page that has nginx in its footer, raise the limit in the server block, or in a location block for just the upload route, then test and reload:

client_max_body_size 25m;

If the 413 comes from the app instead, with PayloadTooLargeError in pm2 logs, the limit is in your code: express.json() accepts 100kb by default, and upload libraries such as Multer have limits of their own. Set both layers deliberately, and raise them only on the routes that need it, since a larger limit is also a larger target for abuse.

WebSockets fail, or drop after a minute

Three different problems look alike:

  1. The handshake fails, or the client falls back to polling. The Upgrade and Connection headers are not being forwarded. Check that the location block has both proxy_set_header lines and proxy_http_version 1.1.
  2. Connections drop after about 60 seconds of silence. Nginx closes a proxied connection when the app sends nothing for proxy_read_timeout, which defaults to 60 seconds. Send pings from the server more often than that, as Socket.IO does by default, or raise proxy_read_timeout in a location dedicated to the WebSocket path.
  3. Socket.IO reports "Session ID unknown" in cluster mode. The long-polling handshake and the requests that follow it landed on different workers. Use the WebSocket transport only, or run in fork mode, and add an adapter, Redis for example, so events reach clients connected to other workers.

The Nginx guide to WebSocket proxying explains the headers and the timeout.

How Shipown does the same job

Shipown is a desktop app for Windows and Linux that deploys Node apps, Express, Fastify and NestJS among them, to your own Linux server over SSH, with nothing of its own installed on the server. It covers the server-side mechanics of this guide:

  • Server setup inspects the server, reports what is missing, and offers PM2 preparation and a Domains & HTTPS installer for Nginx, Certbot and its Nginx plugin.
  • The source is a GitHub repository and one of its real branches, or a folder on your computer. Framework detection proposes the install and build commands, every value stays editable before it runs, and the build happens on the server or on your PC.
  • The app is started under PM2 with the port, health check and restart policy set. Each deploy lands in its own release directory, and the live pointer moves only after the build and the health check pass. A failed deploy restores the previous release by itself, and rolling back later is picking an earlier release.
  • The Nginx virtual host is written and tested before every reload, a configuration Nginx rejects is put back, and Certbot issues the certificate when you ask for HTTPS.
  • Server management installs and configures pm2-logrotate, manages UFW with the live SSH port allowed first, and creates swap after checking memory and disk. Shipown can also create dedicated deploy users with their own SSH keys and scoped sudo rules, which heads off most of the permission errors above.
  • PM2 apps are re-read every fifteen seconds while you are connected, so a process going down, coming back or restarting shows up in the live feed.
  • When something breaks, the AI terminal uses your own Anthropic, OpenAI or DeepSeek key to diagnose problems such as port conflicts and failing builds. It proposes one shell command at a time with its reason, you approve each one, and destructive commands are always held for confirmation.

What stays yours: the code changes in this guide, meaning the 127.0.0.1 binding, the health route, graceful shutdown and trust proxy; the choice between cluster and fork mode and anything that follows from it, such as sticky sessions; the environment values; the DNS records; and pressing deploy. Shipown shows how many commits a branch is ahead of what the server runs, but it does not deploy on push. Monitoring runs while the desktop app is running and the PC is awake. The deployments documentation describes the release model, and the installers are on the download page.

What should you do after the first deploy?

  • Script the update. Every deploy should be the same commands in the same order, ending with the health check. A deploy you type by hand is a deploy you will one day type differently.
  • Monitor from outside. PM2 restarting a crashing app hides the crash from you unless something is watching. Point an uptime checker at https://api.example.com/healthz.
  • Watch restarts and memory. pm2 monit shows live CPU and memory per worker. A restart count in pm2 status that keeps growing means crashes or max_memory_restart firing, and both deserve a look.
  • Back up the database, and test a restore. A backup you have never restored is a hope, not a backup.
  • Keep the system patched. Ubuntu Server installs security updates through unattended-upgrades by default. Run sudo apt upgrade for the rest, and reboot when /var/run/reboot-required exists; PM2's startup unit brings the app back.
  • Plan rollbacks before you need one. Know which commit is running and how to return to the previous one in a single step, rather than reconstructing it during an outage.

Frequently asked questions

What is the best way to run a Node.js app in production on a VPS?

Run it under a process manager such as PM2 or systemd, bound to 127.0.0.1 on a high port, with Nginx in front handling HTTPS and forwarding requests. The process manager restarts the app when it crashes and after reboots, and Nginx keeps TLS, request limits and public ports out of your application code.

Should I use PM2 cluster mode or fork mode?

Use cluster mode for stateless HTTP APIs: it runs several workers on one port and lets pm2 reload replace them without downtime. Use fork mode for apps that keep sessions, caches or WebSocket state in memory, run scheduled jobs inside the process, or are not Node.js, because cluster workers do not share memory.

How do I deploy a Node.js app with zero downtime?

Run it in PM2 cluster mode with wait_ready enabled, have the app call process.send("ready") once it is listening, and close the server cleanly on SIGINT. Then deploy with pm2 reload, which starts each new worker before stopping the old one. Building into a separate release directory removes the remaining risk of serving from half-installed files.

Should my Node.js app listen on 0.0.0.0 or localhost?

Behind Nginx on the same server, listen on 127.0.0.1 so the app can only be reached through Nginx. Use 0.0.0.0 inside a Docker container or when a load balancer on another machine must connect to the app directly. Calling listen with only a port binds to every interface.

How do I stop PM2 logs from filling the disk?

Install the pm2-logrotate module with pm2 install pm2-logrotate, then set a maximum size, a retention count and compression with pm2 set. PM2 does not rotate or delete its logs by itself. pm2 flush empties the current logs immediately if one has already grown too large.

Why does my Node.js app work on the server but not from my domain?

If the app answers on 127.0.0.1 but the domain does not, the request is not reaching Nginx or Nginx is not forwarding it. Check the DNS A record, that UFW allows Nginx Full, that the cloud provider firewall allows ports 80 and 443, and that the proxy_pass port matches the app.

Can Shipown deploy Express, Fastify and NestJS apps?

Yes. Shipown deploys Node apps including Express, Fastify and NestJS to your own Linux server over SSH, runs them under PM2 behind Nginx with a Certbot certificate, and keeps every deploy as a release you can roll back to. It does not deploy automatically on a git push; a person presses deploy.