DeployWise
HomeGuidesDeploy Express.js to VPS
Express.jsNode.jsVPSPM2NginxSSL

Deploy Express.js to a VPS — Complete Guide 2026

Step-by-step tutorial: deploy a production-ready Express.js app to any Ubuntu VPS. Covers production checklist, PM2 cluster mode, Nginx reverse proxy, environment variables, health checks, and free SSL with DeployWise.

12 min read
Updated 2026

What you need

A VPS running Ubuntu 20.04 or later (DigitalOcean, Hetzner, Vultr, etc.)
SSH access — either password or private key
A GitHub account with your Express.js repo
A domain name (optional, but recommended for SSL)
Node.js 16+ installed locally for development
Basic familiarity with command line

Why deploy Express.js to a VPS?

Express.js powers some of the world's most popular backends. Traditional PaaS platforms like Heroku charge $50+/month per app and restrict you to their ecosystem. A $5/month VPS from Hetzner or DigitalOcean gives you unlimited control, no bandwidth limits, and can host 10+ Express apps simultaneously.

The challenge is deployment complexity — but that's exactly what DeployWise solves. One click and your Express app is live with PM2 process management, Nginx reverse proxy, free SSL, and zero-downtime reloads.

1Express.js production checklist

Before deploying your Express app, ensure it follows these production best practices:

Trust Proxy Setting

When running behind Nginx, enable trust proxy so Express reads the real client IP from headers:

javascript
// app.js
app.set('trust proxy', 1);

Without this, req.ip and req.connection.remoteAddress will be Nginx's IP, not the client's.

Error Handling

Add a global error handler to prevent crashes:

javascript
// Error handling middleware (must be last)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message,
  });
});

// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

Helmet.js for Security Headers

Use Helmet to set security headers automatically:

javascript
npm install helmet

// app.js
const helmet = require('helmet');
app.use(helmet());

CORS Configuration

Configure CORS for your frontend domain:

javascript
const cors = require('cors');

const corsOptions = {
  origin: process.env.FRONTEND_URL || 'http://localhost:3000',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
};

app.use(cors(corsOptions));

Compression

Enable gzip compression to reduce response sizes:

javascript
const compression = require('compression');
app.use(compression());
Production checklist

Always use environment variables for sensitive config (API keys, database URLs). Never commit .env files. Set NODE_ENV=production to optimize Express performance.

2PM2 ecosystem config for Express

Create an ecosystem.config.js file in your Express repo root to manage multiple instances and cluster mode:

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'express-api',
      script: './src/index.js', // or ./app.js
      instances: 'max',
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
      error_file: './logs/err.log',
      out_file: './logs/out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      merge_logs: true,
      autorestart: true,
      watch: false,
      max_memory_restart: '500M',
      ignore_watch: ['node_modules', '.git', 'logs'],
      env_production: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};

Key settings explained:

instances: 'max'Run one process per CPU core for optimal throughput
exec_mode: 'cluster'Use cluster mode for load balancing across instances
max_memory_restart: '500M'Restart process if it exceeds 500 MB RAM (prevents memory leaks)
autorestart: trueAutomatically restart on crash
watch: falseDon't restart on file changes in production

3Nginx reverse proxy with SSL

Nginx sits in front of PM2/Express, handling SSL termination, compression, and static file serving:

Basic Nginx Configuration

nginx
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    client_max_body_size 50M;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';

        # Pass original request info
        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;

        # Caching
        proxy_cache_bypass $http_upgrade;
    }

    # Serve static files directly (bypass Node.js)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Installation Steps

bash
# Install Nginx
sudo apt update && sudo apt install -y nginx

# Create site config
sudo nano /etc/nginx/sites-available/express-api

# Enable the site
sudo ln -s /etc/nginx/sites-available/express-api /etc/nginx/sites-enabled/

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

SSL with Let's Encrypt (Certbot)

bash
# Install Certbot
sudo apt install -y certbot python3-certbot-nginx

# Get SSL certificate (automatically configures Nginx)
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Check auto-renewal
sudo certbot renew --dry-run

# Auto-renewal is set up automatically

Certbot automatically updates your Nginx config to redirect HTTP to HTTPS and manages certificate renewal.

4Environment variables and .env handling

Never commit sensitive data. Use environment variables instead:

.env file (local development only)

bash
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=your-super-secret-key
FRONTEND_URL=http://localhost:3001
API_KEY=sk-1234567890

.env.production (on server)

Set these on your VPS before deploying (never commit to git):

bash
# Via PM2
pm2 set NODE_ENV production
pm2 set DATABASE_URL postgresql://user:pass@prod-db.com:5432/mydb
pm2 set JWT_SECRET your-production-secret-key

# Or create /home/user/express-app/.env
cat > /home/user/express-app/.env << EOF
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@prod-db.com:5432/mydb
JWT_SECRET=your-production-secret-key
FRONTEND_URL=https://yourdomain.com
EOF

chmod 600 /home/user/express-app/.env

Load env in your Express app

javascript
// At the very top of app.js or index.js
require('dotenv').config();

const express = require('express');
const app = express();

const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
Never expose secrets

Always add .env to .gitignore. Secrets in environment variables stay private on the VPS. When deploying, ensure your build step doesn't log sensitive variables.

5Zero-downtime reloads with PM2 cluster mode

When you deploy new code, you want updates without dropping user connections. PM2's reload command does this gracefully:

bash
# Graceful reload (zero downtime)
pm2 reload express-api

# Hard restart (allows brief downtime)
pm2 restart express-api

# View logs in real-time
pm2 logs express-api

# Monitor all processes
pm2 monit

# View status
pm2 status

How it works:

1.pm2 reload starts new instances without stopping old ones
2.New instances begin accepting requests immediately
3.Old instances finish handling in-flight requests gracefully
4.Once complete, old instances are terminated
5.Users never experience downtime

Graceful Shutdown in Express

For true zero-downtime reloads, handle graceful shutdown in your Express app:

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

// Graceful shutdown (PM2 sends SIGTERM)
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully...');

  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });

  // Force exit after 30 seconds if connections won't close
  setTimeout(() => {
    console.error('Could not close connections in time, forcefully shutting down');
    process.exit(1);
  }, 30000);
});

6Health checks and monitoring

Add a simple health check endpoint so Nginx and monitoring services know your app is alive:

javascript
// Simple health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  });
});

// More comprehensive health check with database
const db = require('./db'); // your database connection

app.get('/health', async (req, res) => {
  try {
    // Check database connectivity
    await db.query('SELECT 1');

    res.status(200).json({
      status: 'healthy',
      database: 'connected',
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message,
    });
  }
});

Nginx Health Check Configuration

nginx
upstream express_backend {
    server localhost:3000;
    server localhost:3001;
    server localhost:3002;

    # Health check (Nginx Plus feature or use external monitoring)
}

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://express_backend;
    }
}

Monitor with PM2+

bash
# PM2 Plus (free tier) provides monitoring dashboard
npm install -g pm2
pm2 install pm2-auto-pull  # Auto-pull latest code
pm2 install pm2-logrotate  # Auto-rotate logs
pm2 link                   # Link to PM2 monitoring dashboard

Common Express deployment mistakes

App listens on wrong port

Always use process.env.PORT in your code (Express default is 3000, but your deployment may require a different port). Set it in ecosystem.config.js and verify with netstat -tuln.

Not using cluster mode in PM2

Without cluster mode, only one CPU core is used. Set exec_mode: 'cluster' and instances: 'max' in ecosystem.config.js to utilize all cores and handle more concurrent requests.

Missing error handlers crash the app

Add a global error handler middleware (must be last). Also listen for unhandledRejection. This prevents crashes from unhandled promise rejections.

Nginx returns 502 Bad Gateway

Your Express app isn't running or isn't listening on the expected port. Run pm2 logs to see the error. Most common: missing NODE_ENV=production or a missing environment variable causing a startup failure.

Slow requests or timeouts

Add longer proxy_read_timeout in Nginx (60s or more). Also ensure your Express endpoints don't block the event loop. Use clustering to distribute load across CPU cores.

Memory leaks cause gradual slowdown

Use max_memory_restart in PM2 to restart processes that exceed memory limits. Monitor with pm2 monit. Look for unresolved promises or unclosed database connections.

How DeployWise automates Express deployment

Every step above — environment setup, PM2 ecosystem config, Nginx configuration, SSL — is executed automatically by DeployWise when you click Deploy. You never touch the terminal:

1.Install Node.js via NVM (if not present)
2.Install PM2 globally
3.Clone your Express repo from GitHub
4.Create ecosystem.config.js from your settings
5.Set environment variables securely
6.Run npm ci to install dependencies
7.Run build command (if needed)
8.Start/restart the app with PM2 cluster mode
9.Configure Nginx reverse proxy with WebSocket support
10.Issue Let's Encrypt SSL certificate (if domain configured)
11.Set up automatic log rotation
12.Enable auto-startup on server reboot

The entire process typically takes 30–60 seconds. You can watch every step live in the deployment log panel.

Subsequent Deployments

On future pushes to your branch, DeployWise runs a zero-downtime update:

1.Pull latest code from GitHub
2.Install new/changed dependencies
3.Run build command (if configured)
4.Reload PM2 processes (zero-downtime)
5.Verify health check passes

What happens after deployment

Your Express app is now:

Running under PM2 cluster mode — auto-restarts if it crashes, uses all CPU cores
Proxied through Nginx — handles HTTP/HTTPS, gzip, static files, and headers
Protected with SSL — free Let's Encrypt certificate, auto-renewing
Accessible at your domain or via server IP
Monitored and logged — check status with pm2 list, logs with pm2 logs

Future deploys are one click away via GitHub push (if auto-deploy enabled). If something breaks, roll back to any previous commit from the deployment history.

Getting started with DeployWise

Three simple steps to deploy your Express app to any VPS:

1. Sign in with GitHub

Open DeployWise dashboard and authenticate with your GitHub account

2. Add your VPS

Enter your server's IP/hostname, SSH port, username and credentials. DeployWise tests the connection.

3. Create & deploy

Select your Express repo, configure environment, choose a domain, then click Deploy. Done in 30-60 seconds.

Ready to deploy Express?

Sign in with GitHub, add your VPS, and have your Express.js app live with PM2, Nginx and SSL — all in minutes, for free.

Open DeployWise Dashboard

Related guides