Node.js VPS Production Deployment Architecture
Home Blog Node.js VPS Deployment

How to Deploy a Node.js App to Production on a VPS with Nginx, PM2, and SSL

My step-by-step production setup — from server hardening and PM2 cluster mode to Nginx reverse proxy, Let's Encrypt SSL, and zero-downtime deployments.

Akshay Bangar
Written By
Akshay Bangar
Backend Engineer & DevOps

I deploy and manage production Node.js applications on VPS infrastructure. This guide shares the exact server configuration, process management, and deployment workflow I use for real production systems.

Every developer eventually faces the same question: "My app works on localhost — how do I actually put it on the internet?" Most guides online either give you a 5-minute Heroku walkthrough (not production-grade) or a 100-page DevOps textbook (overkill for a single VPS). Neither actually shows you what a real, battle-tested production deployment looks like.

I've deployed and managed multiple Node.js applications on VPS infrastructure in production — handling real traffic, real crashes, and real security threats. This guide shares the exact setup I use: from the initial server hardening to the PM2 ecosystem config, the Nginx configuration, SSL certificates, and the deployment script I run every time I push an update.

Who is this guide for? Developers who have a working Node.js/Express application locally and want to deploy it on a VPS (DigitalOcean, Hetzner, Linode, AWS Lightsail, etc.) for production use. I assume you have basic terminal/SSH knowledge. No Docker or Kubernetes required — this is a clean, proven single-server deployment.

1. The Production Architecture

Before touching any commands, let me show you the architecture we're building. Understanding why each component exists prevents blind copy-pasting and helps you debug issues later.

flowchart LR User([User Browser]) -->|HTTPS :443| CF["Cloudflare CDN
(optional)"] CF -->|HTTPS| Nginx["Nginx
Reverse Proxy
:80 / :443"] subgraph VPS ["Ubuntu VPS"] Nginx -->|proxy_pass
http://127.0.0.1:3000| PM2["PM2 Process Manager"] PM2 --> W1["Worker 1
Node.js :3000"] PM2 --> W2["Worker 2
Node.js :3000"] PM2 --> W3["Worker 3
Node.js :3000"] PM2 --> W4["Worker N
Node.js :3000"] end Nginx -.->|Serves directly| Static["Static Files
CSS / JS / Images"] W1 --> DB[(MongoDB / PostgreSQL)]

Here's why each layer matters:

ComponentRoleWhy You Need It
NginxReverse proxy & SSL terminationHandles HTTPS, serves static files 10x faster than Node.js, buffers slow clients, provides rate limiting
PM2Process managerAuto-restarts crashed processes, runs cluster mode across all CPU cores, manages logs, starts on boot
Let's EncryptFree SSL certificatesHTTPS is mandatory — browsers mark HTTP as "Not Secure", Google penalizes HTTP in rankings
UFWFirewallBlocks all ports except SSH/HTTP/HTTPS — your Node.js port (3000) is never exposed to the internet

2. Initial Server Setup & Hardening

Start with a fresh Ubuntu 22.04 LTS or 24.04 LTS VPS. I recommend at least 2 GB RAM and 1 vCPU for a basic app, or 4 GB RAM / 2 vCPU if you're running a database on the same server.

Step 1: Connect via SSH

# Connect to your VPS as root (first time only)
ssh root@YOUR_SERVER_IP

Step 2: Create a Deploy User (Never Run Apps as Root)

Critical Security Rule: Never run your Node.js application as root. A vulnerability in your app would give an attacker full server access. Always create a dedicated user with limited privileges.
# Create a new user called 'deploy'
adduser deploy

# Give it sudo privileges
usermod -aG sudo deploy

# Copy your SSH key so you can login as this user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

# Test the new user — open a NEW terminal and connect
ssh deploy@YOUR_SERVER_IP

# If it works, disable root SSH login for security
sudo sed -i 's/^PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd

Step 3: Configure the Firewall

# Allow only the ports we need
sudo ufw allow OpenSSH        # Port 22 — SSH access
sudo ufw allow 'Nginx Full'   # Ports 80 & 443 — HTTP & HTTPS

# Enable the firewall
sudo ufw enable

# Verify — should show SSH and Nginx only
sudo ufw status

# Expected output:
# To                         Action      From
# --                         ------      ----
# OpenSSH                    ALLOW       Anywhere
# Nginx Full                 ALLOW       Anywhere
Notice: We are NOT opening port 3000 (or whatever port your Node.js app runs on). Your app is only accessible through Nginx's reverse proxy on ports 80/443. This means nobody can bypass Nginx and hit your Node.js process directly — a critical security measure.

Step 4: System Updates & Essential Packages

# Update the system
sudo apt update && sudo apt upgrade -y

# Install essential build tools (needed for native npm modules)
sudo apt install -y build-essential git curl wget

3. Installing Node.js (The Right Way)

Do NOT install Node.js via sudo apt install nodejs — the default Ubuntu repository has severely outdated versions. Use the NodeSource repository to get the latest LTS version:

# Install Node.js 22.x LTS (current LTS as of 2026)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

# Verify installation
node --version    # Should show v22.x.x
npm --version     # Should show 10.x.x

# Set npm to not use sudo for global installs
mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

4. Deploying Your Application Code

Clone & Install

# Create a directory for your apps
sudo mkdir -p /var/www
sudo chown deploy:deploy /var/www

# Clone your repository
cd /var/www
git clone https://github.com/YOUR_USERNAME/YOUR_APP.git myapp
cd myapp

# Install ONLY production dependencies (no devDependencies)
npm ci --omit=dev
Why npm ci instead of npm install? The ci command does a clean install from your package-lock.json — it's faster, deterministic, and guaranteed to match your development environment exactly. The --omit=dev flag skips installing development tools (testing libraries, linters) that have no business on a production server.

Set Up Environment Variables

# Create the production .env file
nano /var/www/myapp/.env
# Example .env for production
NODE_ENV=production
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-long-random-secret-here
CORS_ORIGIN=https://yourdomain.com
Security: Never commit .env files to Git. Add .env to your .gitignore. For production secrets, consider using environment variables set directly in PM2's ecosystem config or a secrets manager.

Quick Test

# Test that your app starts without errors
node app.js   # or node server.js, node index.js — whatever your entry point is

# You should see: "Server running on port 3000" (or similar)
# Press Ctrl+C to stop it — we'll use PM2 from now on

5. PM2 — Production Process Management

Running node app.js directly in production is a critical mistake. If your app crashes (and it will eventually — an unhandled promise rejection, a database timeout, an out-of-memory error), the process dies and your site goes offline with no one to restart it. PM2 solves every one of these problems.

Install PM2

# Install PM2 globally
npm install -g pm2

Create the Ecosystem Config File

Instead of passing flags to the PM2 command every time, create a configuration file. This is the exact ecosystem.config.js pattern I use in production:

// /var/www/myapp/ecosystem.config.js

module.exports = {
  apps: [
    {
      name: 'myapp',                    // Name shown in PM2 process list
      script: './app.js',               // Your app's entry point
      cwd: '/var/www/myapp',            // Working directory

      // === CLUSTER MODE (use all CPU cores) ===
      instances: 'max',                 // Spawns one worker per CPU core
      exec_mode: 'cluster',            // Required for multi-instance

      // === ENVIRONMENT ===
      env: {
        NODE_ENV: 'production',
        PORT: 3000
      },

      // === RELIABILITY ===
      max_memory_restart: '512M',      // Auto-restart if memory exceeds 512MB
      restart_delay: 5000,             // Wait 5s before restarting a crashed process
      max_restarts: 10,                // Stop restarting after 10 consecutive crashes
      min_uptime: '10s',               // Process must run 10s to be considered "started"

      // === LOGGING ===
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      error_file: '/var/log/pm2/myapp-error.log',
      out_file: '/var/log/pm2/myapp-out.log',
      merge_logs: true,                // Merge cluster worker logs into one file

      // === GRACEFUL SHUTDOWN ===
      kill_timeout: 5000,             // Give the app 5s to finish in-flight requests
      listen_timeout: 8000,           // Max time to wait for app to signal "ready"
      wait_ready: false               // Set true if you use process.send('ready')
    }
  ]
};
Why Cluster Mode? Node.js is single-threaded by default. On a 4-core VPS, a single Node.js process uses only 25% of your CPU. PM2's cluster mode spawns one worker per core, so all 4 cores handle requests — effectively quadrupling your throughput at zero extra cost.

Start & Persist

# Create the log directory
sudo mkdir -p /var/log/pm2
sudo chown deploy:deploy /var/log/pm2

# Start the application
cd /var/www/myapp
pm2 start ecosystem.config.js

# Verify it's running
pm2 status

# Expected output:
# ┌─────┬──────────┬─────────────┬──────┬───────────┬──────────┬──────────┐
# │ id  │ name     │ mode        │ ↺    │ status    │ cpu      │ memory   │
# ├─────┼──────────┼─────────────┼──────┼───────────┼──────────┼──────────┤
# │ 0   │ myapp    │ cluster     │ 0    │ online    │ 0%       │ 58.0mb   │
# │ 1   │ myapp    │ cluster     │ 0    │ online    │ 0%       │ 56.2mb   │
# │ 2   │ myapp    │ cluster     │ 0    │ online    │ 0%       │ 55.8mb   │
# │ 3   │ myapp    │ cluster     │ 0    │ online    │ 0%       │ 57.1mb   │
# └─────┴──────────┴─────────────┴──────┴───────────┴──────────┴──────────┘

# CRITICAL: Save the process list so PM2 auto-starts on server reboot
pm2 save

# Generate the startup script (run the command it outputs)
pm2 startup

# PM2 will print a command like:
# sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u deploy --hp /home/deploy
# COPY AND RUN that exact command

Essential PM2 Commands

CommandPurpose
pm2 statusShow all running processes
pm2 logs myappStream real-time logs (Ctrl+C to exit)
pm2 logs myapp --lines 200Show last 200 log lines
pm2 monitInteractive dashboard (CPU, memory, logs)
pm2 reload myappZero-downtime restart (graceful reload)
pm2 restart myappHard restart (causes brief downtime)
pm2 stop myappStop the application
pm2 delete myappRemove from PM2's process list

6. Nginx — Reverse Proxy Configuration

Install Nginx

sudo apt install -y nginx

# Verify it's running
sudo systemctl status nginx
# You should see "active (running)"

Create the Site Configuration

# Remove the default Nginx page
sudo rm /etc/nginx/sites-enabled/default

# Create your app's config file
sudo nano /etc/nginx/sites-available/myapp

Paste this production-grade Nginx configuration:

# /etc/nginx/sites-available/myapp

# Rate limiting zone — prevents abuse (10 requests/second per IP)
limit_req_zone $binary_remote_addr zone=app_limit:10m rate=10r/s;

# Upstream block — points to your Node.js cluster
upstream nodejs_backend {
    server 127.0.0.1:3000;

    # Keep connections alive between Nginx and Node.js
    keepalive 64;
}

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # === 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 Referrer-Policy "strict-origin-when-cross-origin" always;

    # === LOGGING ===
    access_log /var/log/nginx/myapp-access.log;
    error_log  /var/log/nginx/myapp-error.log;

    # === STATIC FILES (served directly by Nginx — much faster) ===
    location /public/ {
        alias /var/www/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # === MAIN APPLICATION ===
    location / {
        # Rate limiting — burst allows 20 queued requests
        limit_req zone=app_limit burst=20 nodelay;

        proxy_pass http://nodejs_backend;

        # Required proxy headers
        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;

        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # Buffering — protects Node.js from slow clients
        proxy_buffering on;
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;

        # Don't cache dynamic responses
        proxy_cache_bypass $http_upgrade;
    }

    # === GZIP COMPRESSION ===
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml;

    # === FILE UPLOAD LIMIT ===
    client_max_body_size 10M;
}

Enable & Test

# Enable the site by creating a symlink
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/

# TEST the configuration for syntax errors (ALWAYS do this before reloading)
sudo nginx -t

# Expected output:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# Only if the test passes — reload Nginx
sudo systemctl reload nginx
Always run nginx -t before reloading. A single syntax error in the config file will cause nginx reload to fail silently and your previous config stays active. In the worst case, a nginx restart with a broken config will take your entire site offline. The -t flag catches this before it happens.

At this point, if your DNS is pointed to your VPS IP, visiting http://yourdomain.com should show your Node.js application. Now let's secure it with HTTPS.

7. SSL/HTTPS with Let's Encrypt

# Install Certbot and the Nginx plugin
sudo apt install -y certbot python3-certbot-nginx

# Obtain and install the SSL certificate
# Certbot will automatically modify your Nginx config to use HTTPS
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Follow the prompts:
# - Enter your email
# - Agree to terms
# - Choose "Redirect HTTP to HTTPS" when asked (recommended)

Certbot automatically:

  • Obtains a free SSL certificate from Let's Encrypt
  • Modifies your Nginx config to listen on port 443 with SSL
  • Adds a redirect from HTTP (port 80) to HTTPS (port 443)
  • Sets up a systemd timer to auto-renew the certificate every 90 days
# Verify the auto-renewal timer is active
sudo systemctl status certbot.timer

# You can also test renewal manually (dry run — doesn't actually renew)
sudo certbot renew --dry-run

8. Handling Graceful Shutdown in Your App

This is the step that 99% of deployment guides skip, and it's the difference between amateur and production-grade. When PM2 reloads your app (during a deployment), it sends a SIGINT signal. Your app must handle this to finish processing in-flight requests before exiting.

// Add this to your app.js / server.js entry point

const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

// === GRACEFUL SHUTDOWN ===
// When PM2 sends SIGINT (during reload/restart), finish in-flight requests
process.on('SIGINT', () => {
  console.log('SIGINT received. Shutting down gracefully...');
  server.close(() => {
    console.log('All connections closed. Exiting.');
    // Close database connections
    mongoose.connection.close(false, () => {
      process.exit(0);
    });
  });

  // Force exit if graceful shutdown takes too long
  setTimeout(() => {
    console.error('Forced shutdown — connections did not close in time.');
    process.exit(1);
  }, 10000);  // 10 second timeout
});

9. The Deployment Script (Zero-Downtime Updates)

Every time I push an update to production, I run this script. It pulls the latest code, installs dependencies, and does a graceful PM2 reload — zero downtime.

#!/bin/bash
# /var/www/myapp/deploy.sh — Run this on the server to deploy updates

set -e  # Exit immediately if any command fails

APP_DIR="/var/www/myapp"
BRANCH="main"

echo "========================================="
echo "  Deploying to production..."
echo "========================================="

cd $APP_DIR

# 1. Pull latest code
echo "[1/4] Pulling latest code from $BRANCH..."
git pull origin $BRANCH

# 2. Install production dependencies
echo "[2/4] Installing dependencies..."
npm ci --omit=dev

# 3. Run database migrations (if applicable)
# echo "[3/4] Running migrations..."
# npx prisma migrate deploy   # Uncomment if using Prisma
# npm run migrate              # Uncomment if using custom migrations

# 4. Graceful reload — zero downtime
echo "[4/4] Reloading PM2 (zero-downtime)..."
pm2 reload ecosystem.config.js

echo "========================================="
echo "  Deployment complete!"
echo "  Run 'pm2 logs myapp --lines 50' to verify"
echo "========================================="
# Make the script executable
chmod +x /var/www/myapp/deploy.sh

# To deploy, simply run:
./deploy.sh
Why pm2 reload instead of pm2 restart? The restart command kills all workers simultaneously, causing a brief period where no process is listening — users get connection errors. The reload command starts new workers first, then gracefully kills old ones only after the new workers are ready. Zero dropped requests.

10. Monitoring & Log Management

# Real-time monitoring dashboard
pm2 monit

# Stream application logs
pm2 logs myapp

# Check Nginx access logs for traffic patterns
sudo tail -f /var/log/nginx/myapp-access.log

# Check Nginx error logs for proxy issues
sudo tail -f /var/log/nginx/myapp-error.log

# Check server resource usage
htop         # Interactive process viewer
df -h        # Disk space
free -m      # Memory usage

Set Up Log Rotation (Prevent Disk from Filling Up)

# Install PM2 log rotation module
pm2 install pm2-logrotate

# Configure: keep 30 days of logs, max 50MB per file
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 30
pm2 set pm2-logrotate:compress true

11. Common Mistakes That Will Break Your Production Server

I've made (or seen) every one of these mistakes. Here's how to avoid them:

MistakeWhy It's DangerousThe Fix
Running as root Any app vulnerability = full server compromise Create a deploy user, run Node.js under that user
Exposing port 3000 in the firewall Bypasses Nginx — no SSL, no rate limiting, no security headers Only open ports 22, 80, 443 in UFW
Using node app.js directly One crash = site is down until you manually restart Always use PM2 with cluster mode
Forgetting pm2 save Server reboot = all PM2 processes are gone Always run pm2 save after any process change
Using npm install in production Can install different versions than development, causing bugs Use npm ci --omit=dev for deterministic installs
Not handling SIGINT PM2 reload kills connections mid-request, users see errors Add graceful shutdown handler in your app
Committing .env to Git Your database passwords and API keys are exposed publicly Add .env to .gitignore, create it manually on server
Not testing Nginx config before reload Broken config = site goes offline on reload/restart Always run sudo nginx -t before sudo systemctl reload nginx

12. Production Deployment Checklist

Before you consider your deployment "production-ready," verify every item on this checklist:

  • Non-root user — App runs under a dedicated deploy user
  • Firewall enabled — Only SSH, HTTP, HTTPS open (ufw status)
  • Node.js LTS — Installed via NodeSource, not apt default
  • PM2 cluster mode — Using all CPU cores (pm2 status shows multiple workers)
  • PM2 startup — Process auto-starts on server reboot (pm2 startup + pm2 save)
  • Nginx reverse proxy — Port 3000 is NOT exposed, only 80/443
  • Security headers — X-Frame-Options, X-Content-Type-Options set in Nginx
  • SSL/HTTPS — Let's Encrypt certificate installed, HTTP redirects to HTTPS
  • SSL auto-renewalcertbot renew --dry-run succeeds
  • Gzip compression — Enabled in Nginx for text/JS/CSS/JSON
  • Rate limiting — Configured in Nginx to prevent abuse
  • Graceful shutdown — App handles SIGINT and closes connections cleanly
  • Log rotation — pm2-logrotate installed, logs won't fill disk
  • Environment variablesNODE_ENV=production, secrets not in Git
  • Deploy script — Automated pull → install → reload workflow

Frequently Asked Questions

Can I run multiple Node.js apps on the same VPS?

Yes. Run each app on a different port (3000, 3001, 3002) with separate PM2 processes and ecosystem configs. Create separate Nginx server blocks for each domain, each proxying to the correct port.

How much traffic can this setup handle?

On a 4-core / 4GB VPS with PM2 cluster mode, this setup can comfortably handle 1,000–5,000+ concurrent connections depending on your app's complexity. Nginx alone can handle 10,000+ concurrent connections. The bottleneck is usually your database, not Node.js or Nginx.

Should I use Docker instead?

For a single application on one server, this direct deployment is simpler, faster, and uses fewer resources than Docker. Docker adds value when you need reproducible environments across multiple servers, microservices orchestration, or CI/CD pipeline consistency. Start without Docker — migrate to it when the complexity justifies it.

What VPS provider do you recommend?

I've used DigitalOcean, Hetzner, and AWS Lightsail. Hetzner offers the best price-to-performance ratio. DigitalOcean has the best documentation and developer experience. For production workloads where you need the absolute cheapest option with great performance, go with Hetzner.

Conclusion

You now have a production-grade Node.js deployment that matches what real companies use. Not a Heroku hobby project, not a Docker-compose tutorial that falls apart under load — a battle-tested, single-server deployment with proper security, process management, and zero-downtime updates.

The architecture is simple by design: Nginx → PM2 → Node.js. It has powered countless production applications at scale, and it's the exact setup I rely on for the systems I manage.

Have a suggestion or found an issue?
I'm always looking to improve these guides. If you have any feedback, corrections, or just want to discuss Node.js deployments, please reach out to me here.