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
=> 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
/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.
Clone Your Repository
Create a dedicated directory for your application and clone the code:
$ cd /var/www/myapp
$ git clone https://github.com/youruser/myapp.git .
Install Production Dependencies
Use npm ci instead of npm install for reproducible builds — it installs exact versions from package-lock.json:
added 127 packages in 4.2s
Configure Environment Variables
Create a .env file with your production settings. Never commit this file to version control:
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.
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:
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
}]
};
┌─────┬─────────┬──────┬──────┬────────┬─────────┬────────┐
│ 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] 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
| Command | Description |
|---|---|
pm2 list | Show all running processes |
pm2 logs myapp | Stream live application logs |
pm2 monit | Real-time monitoring dashboard |
pm2 reload myapp | Zero-downtime reload (cluster mode) |
pm2 restart myapp | Hard restart (brief downtime) |
pm2 stop myapp | Stop application |
pm2 delete myapp | Remove 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.
Browser
:443 SSL
:3000
Back to client
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";
}
}
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 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 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 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:
$ 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) ✓
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 chown -R nodeapp:nodeapp /var/www/myapp
$ sudo -u nodeapp pm2 start ecosystem.config.js
Application-Level Security
// 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 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:
┌─ 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
Production Checklist
- Node.js installed via nvm with LTS version
- Application uses
npm ci --productionfor 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