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.
What you need
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:
// 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:
// 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:
npm install helmet
// app.js
const helmet = require('helmet');
app.use(helmet());CORS Configuration
Configure CORS for your frontend domain:
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:
const compression = require('compression');
app.use(compression());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:
// 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:
3Nginx reverse proxy with SSL
Nginx sits in front of PM2/Express, handling SSL termination, compression, and static file serving:
Basic Nginx Configuration
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
# 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)
# 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)
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):
# 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
// 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}`);
});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:
# 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:
Graceful Shutdown in Express
For true zero-downtime reloads, handle graceful shutdown in your Express app:
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:
// 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
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+
# 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
✅ 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.
✅ 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.
✅ Add a global error handler middleware (must be last). Also listen for unhandledRejection. This prevents crashes from unhandled promise rejections.
✅ 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.
✅ 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.
✅ 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:
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:
What happens after deployment
Your Express app is now:
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:
Open DeployWise dashboard and authenticate with your GitHub account
Enter your server's IP/hostname, SSH port, username and credentials. DeployWise tests the connection.
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