Tutorial

Deploy Node.js on a VPS: Nginx, PM2, SSL, and Production Best Practices

April 02, 2026

Back to Blog

Why Deploy Node.js on a VPS?

Shared hosting environments are designed for PHP applications — they run Apache or Nginx with mod_php or PHP-FPM, serve static files, and that is about it. Node.js applications require a persistent process, long-lived WebSocket connections, custom port bindings, and fine-grained control over the runtime environment. A VPS gives you all of this.

Shared Hosting Limitations

  • No persistent processes (Node.js gets killed)
  • No custom port binding
  • No WebSocket support
  • No SSH access for debugging
  • Shared resources with noisy neighbors

VPS Advantages

  • Full root access and process control
  • Any port, any protocol
  • Native WebSocket support
  • Dedicated CPU, RAM, and I/O
  • Custom Nginx reverse proxy configuration

Whether you are deploying an Express.js REST API, a Next.js application, a real-time Socket.io chat system, or a NestJS microservice, a VPS provides the foundation for a production-grade deployment.

Installing Node.js: nvm vs System Package

There are two primary approaches to installing Node.js on a server: using the Node Version Manager (nvm) or installing from the NodeSource repository. Each has trade-offs that matter in production.

nvm (Recommended)

Flexible

  • Multiple Node versions side by side
  • Per-project version switching
  • No root required for installation
  • Easy upgrades: nvm install --lts

NodeSource APT

Simple

  • Single system-wide version
  • Managed by apt (auto-updates)
  • Requires root for installation
  • Version switching requires repo change

Installing with nvm

$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
=> Installing nvm
=> Appending nvm source string to /root/.bashrc

$ source ~/.bashrc

$ nvm install --lts
Installing latest LTS version.
Downloading and installing node v22.14.0...
Now using node v22.14.0 (npm v10.9.2)

$ node --version
v22.14.0

$ npm --version
10.9.2
Important for PM2: When using nvm, the Node.js path includes the version number (e.g., /root/.nvm/versions/node/v22.14.0/bin/node). PM2 startup scripts need the full path, so always run pm2 startup after installing or changing Node.js versions.

Preparing Your Application

Before deploying, your application needs to be production-ready. This means installing only production dependencies, setting up environment variables, and ensuring the entry point is clearly defined.

1

Clone Your Repository

Create a dedicated directory for your application and clone the code:

$ mkdir -p /var/www/myapp
$ cd /var/www/myapp
$ git clone https://github.com/youruser/myapp.git .
2

Install Production Dependencies

Use npm ci instead of npm install for reproducible builds — it installs exact versions from package-lock.json:

$ npm ci --production
added 127 packages in 4.2s
3

Configure Environment Variables

Create a .env file with your production settings. Never commit this file to version control:

$ nano .env

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
SESSION_SECRET=your-random-secret-here
CORS_ORIGIN=https://example.com

PM2: The Production Process Manager

PM2 is the de facto process manager for Node.js applications in production. It handles process lifecycle management, automatic restarts on crash, log aggregation, cluster mode for multi-core utilization, and startup scripts for boot persistence.

$ npm install -g pm2
added 183 packages in 8.1s

$ pm2 --version
5.4.3

Starting Your Application with PM2

The simplest way to start your application is with pm2 start, but for production you should use an ecosystem configuration file that defines all runtime parameters:

$ nano ecosystem.config.js

module.exports = {
  apps: [{
    name: "myapp",
    script: "./src/index.js",
    instances: "max", // Use all CPU cores
    exec_mode: "cluster", // Cluster mode for load balancing
    env: {
      NODE_ENV: "production",
      PORT: 3000
    },
    max_memory_restart: "500M",
    log_date_format: "YYYY-MM-DD HH:mm:ss Z",
    error_file: "./logs/error.log",
    out_file: "./logs/output.log",
    merge_logs: true,
    watch: false, // Never watch in production
    autorestart: true,
    max_restarts: 10,
    restart_delay: 4000
  }]
};
$ pm2 start ecosystem.config.js
┌─────┬─────────┬──────┬──────┬────────┬─────────┬────────┐
│ id │ name │ mode │ pid │ status │ restart │ cpu │
├─────┼─────────┼──────┼──────┼────────┼─────────┼────────┤
│ 0 │ myapp │ cluster │ 1847 │ online │ 0 │ 0.2% │
│ 1 │ myapp │ cluster │ 1853 │ online │ 0 │ 0.1% │
│ 2 │ myapp │ cluster │ 1859 │ online │ 0 │ 0.2% │
│ 3 │ myapp │ cluster │ 1865 │ online │ 0 │ 0.1% │
└─────┴─────────┴──────┴──────┴────────┴─────────┴────────┘

PM2 Startup and Save

To ensure your application survives server reboots, PM2 can generate and install a systemd startup script:

$ pm2 startup systemd
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/root/.nvm/versions/node/v22.14.0/bin pm2 startup systemd -u root --hp /root

$ pm2 save
[PM2] Saving current process list...
[PM2] Successfully saved in /root/.pm2/dump.pm2
Key PM2 commands you will use daily in production:
CommandDescription
pm2 listShow all running processes
pm2 logs myappStream live application logs
pm2 monitReal-time monitoring dashboard
pm2 reload myappZero-downtime reload (cluster mode)
pm2 restart myappHard restart (brief downtime)
pm2 stop myappStop application
pm2 delete myappRemove from PM2 process list

Nginx Reverse Proxy Configuration

Node.js should never be directly exposed to the internet. Nginx sits in front as a reverse proxy, handling SSL termination, static file serving, request buffering, and connection management. This architecture provides both security and performance benefits.

Client
Browser
Nginx
:443 SSL
Node.js
:3000
Response
Back to client
$ sudo nano /etc/nginx/sites-available/myapp

upstream nodejs_backend {
  server 127.0.0.1:3000;
  keepalive 64;
}

server {
  listen 80;
  server_name example.com www.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

  # Security headers
  add_header X-Frame-Options "SAMEORIGIN" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-XSS-Protection "1; mode=block" always;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

  # Proxy settings
  location / {
    proxy_pass http://nodejs_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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_cache_bypass $http_upgrade;
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;
  }

  # Serve static files directly
  location /static/ {
    alias /var/www/myapp/public/static/;
    expires 30d;
    add_header Cache-Control "public, immutable";
  }
}
WebSocket support: The Upgrade and Connection "upgrade" headers are essential for WebSocket connections. Without them, Socket.io, ws, and other WebSocket libraries will fall back to long-polling, significantly degrading performance.
$ sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx

SSL with Let's Encrypt

Every production deployment must use HTTPS. Certbot makes it straightforward to obtain and auto-renew free SSL certificates from Let's Encrypt:

$ sudo apt install certbot python3-certbot-nginx -y
$ sudo certbot --nginx -d example.com -d www.example.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for example.com and www.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem

$ sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded

Log Management

In production, log management is critical. PM2 stores logs in ~/.pm2/logs/ by default, and they can grow unbounded. Set up logrotate to prevent disk exhaustion:

$ pm2 install pm2-logrotate
$ pm2 set pm2-logrotate:max_size 50M
$ pm2 set pm2-logrotate:retain 14
$ pm2 set pm2-logrotate:compress true
$ pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss

This configuration rotates logs when they reach 50MB, keeps 14 rotated files, and compresses old logs with gzip. With this setup, your disk usage stays predictable even under heavy traffic.

Zero-Downtime Deployments

One of PM2 cluster mode's greatest strengths is zero-downtime reloading. When you deploy a new version of your code, PM2 gracefully restarts workers one at a time, ensuring at least one worker is always handling requests:

# Deploy script example
$ cd /var/www/myapp
$ git pull origin main
$ npm ci --production
$ pm2 reload myapp
[PM2] Applying action reloadProcessId on app [myapp](ids: [0,1,2,3])
[PM2] [myapp](0) ✓
[PM2] [myapp](1) ✓
[PM2] [myapp](2) ✓
[PM2] [myapp](3) ✓
reload vs restart: pm2 reload performs a graceful zero-downtime reload — new workers start and old workers finish their current requests before shutting down. pm2 restart kills all workers immediately and starts new ones, causing a brief interruption.

Security Hardening

A production Node.js deployment requires multiple layers of security beyond SSL. Here are the essential hardening steps:

Run as a Non-Root User

$ sudo useradd -m -s /bin/bash nodeapp
$ sudo chown -R nodeapp:nodeapp /var/www/myapp
$ sudo -u nodeapp pm2 start ecosystem.config.js

Application-Level Security

$ npm install helmet express-rate-limit cors

// In your Express application:
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");

app.use(helmet());                  // Security headers
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,        // 15 minutes
  max: 100,                        // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false
}));
app.disable("x-powered-by");      // Hide Express fingerprint

Firewall Configuration

$ sudo ufw allow 22/tcp
$ sudo ufw allow 80/tcp
$ sudo ufw allow 443/tcp
$ sudo ufw enable
Firewall is active and enabled on system startup

# Node.js port 3000 is NOT opened — only Nginx accesses it via localhost

Monitoring Your Application

PM2 includes built-in monitoring capabilities that give you real-time visibility into your application's health:

$ pm2 monit
┌─ Process List ────────────────────────────────────────────┐
│ myapp │ Heap Size: 48.2 MB
│ Cluster: 4/4 online│ Used Heap: 32.1 MB │
│ Restarts: 0 │ Event Loop: 1.2ms
│ Uptime: 14d 3h │ Active Req: 12 │
│ CPU: 2.3% │ Active Handles: 847 │
└───────────────────────────────────────────────────────────┘

Node.js Deployment with Panelica

With Panelica, deploy Node.js apps using Docker containers with one-click app templates, or configure a domain in reverse proxy mode to point to your Node.js port — Nginx config, SSL, and WebSocket headers are handled automatically. The Docker module provides pre-built Node.js templates with PM2 included, environment variable management through the panel, and integrated log viewing. For simpler deployments, adding a domain in reverse proxy mode takes just seconds: specify your application's port, and Panelica generates the Nginx configuration with proper proxy headers, WebSocket support, security headers, and auto-issued SSL certificates.

Production Checklist

  • Node.js installed via nvm with LTS version
  • Application uses npm ci --production for clean installs
  • PM2 ecosystem file with cluster mode and memory limits
  • PM2 startup script enabled for boot persistence
  • Nginx reverse proxy with WebSocket support configured
  • SSL certificate from Let's Encrypt with auto-renewal
  • Security headers (HSTS, X-Frame-Options, CSP) in Nginx
  • Application runs as non-root user
  • Helmet.js and rate limiting in Express middleware
  • Log rotation configured (pm2-logrotate or system logrotate)
  • Firewall allows only ports 22, 80, and 443
  • Environment variables in .env (not in code, not in git)
  • Zero-downtime deploy script using pm2 reload
Share: