Deploying a Node.js App to Production: The Complete 2026 Guide
From local development to live server with HTTPS, monitoring, and auto-restarts. Everything you need.
What You'll End Up With
Your Domain (https://app.example.com)
↓ Cloudflare (CDN + SSL)
↓ Nginx (reverse proxy)
↓ Node.js (your app)
↓ SQLite / PostgreSQL (database)
↓ PM2 (process manager)
↓ Systemd (auto-restart on boot)
Total cost: $5/month for a VPS. No Heroku, no Railway, no Render fees.
Step 1: Get a Server
I use a $5/month VPS (2 cores, 4GB RAM). Any provider works:
- DigitalOcean — Developer-friendly, good docs ($5-20/mo)
- Hetzner — Best price/performance in Europe (~€4/mo)
- Linode (Akamai) — Reliable, global network ($5/mo)
- Vultr — Many locations, hourly billing ($5/mo)
# After creating your VPS, SSH in:
ssh root@your-server-ip
# First things first:
apt update && apt upgrade -y
# Create a non-root user (security best practice)
adduser deploy
usermod -aG sudo deploy
su - deploy
Step 2: Install Node.js
# Don't use apt's old Node! Use nvm or NodeSource:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version # v22.x
npm --version # 10.x
# Global tools you'll need:
sudo npm i -g pm2
Step 3: Prepare Your App
# Clone your repo
cd /var/www
git clone https://github.com/you/your-app.git
cd your-app
npm ci --production # Only production deps
# Environment variables
cp .env.example .env
nano .env # Fill in your secrets
Step 4: PM2 Process Manager
PM2 keeps your app running, restarts it if it crashes, and handles log management.
# Start your app with PM2
pm2 start server.js --name "myapp"
# Or use an ecosystem file for multiple apps:
pm2 ecosystem init # Generates ecosystem.config.js
// ecosystem.config.js
module.exports = {
apps: [{
name: 'myapp',
script: 'server.js',
cwd: '/var/www/your-app',
// Auto-restart on crash
autorestart: true,
max_restarts: 10,
max_memory_restart: '512M',
// Environment
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
// Logging
error_file: '/var/log/myapp/error.log',
out_file: '/var/log/myapp/out.log',
merge_logs: true,
// Cluster mode (optional, for multi-core)
instances: 'max',
exec_mode: 'cluster',
}]
};
pm2 start ecosystem.config.js --env production
pm2 save # Save process list (survives reboot)
pm2 startup # Generate systemd startup script
# Run the command it outputs to enable auto-start on boot
Step 5: Nginx Reverse Proxy
Nginx sits in front of your app. It handles SSL, static files, rate limiting, and proxies requests to Node.js.
sudo apt install nginx
# /etc/nginx/sites-available/your-app.conf
server {
listen 80;
server_name app.example.com;
# Redirect HTTP → HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
# SSL certificates (Let's Encrypt, see below)
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=general:10r/s;
limit_req zone=general burst=20 nodelay;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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;
}
# Static files (if any) — serve directly without hitting Node
location /static/ {
alias /var/www/your-app/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
sudo ln -s /etc/nginx/sites-available/your-app.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Step 6: Free SSL with Let's Encrypt
sudo apt install certbot python3-certbot-nginx
# Automatic SSL setup (handles Nginx config too):
sudo certbot --nginx -d app.example.com
# Test auto-renewal:
sudo certbot renew --dry-run
It sets up automatic renewal via cron. Your SSL just works forever.
Step 7: Firewall & Hardening
# UFW firewall
sudo ufw allow OpenSSH # Port 22
sudo ufw allow 'Nginx Full' # Ports 80 + 443
sudo ufw enable # Block everything else
# Fail2Ban (auto-ban brute force attempts)
sudo apt install fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Disable root login (after setting up deploy user)
sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
Step 8: Monitoring
PM2 Monitoring
pm2 monit # Terminal dashboard
pm2 list # List all processes
pm2 logs myapp # View logs
pm2 show myapp # Detailed info
Basic Health Check Endpoint
Add this to your app:
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString(),
});
});
Uptime Monitor (Free)
Set up UptimeRobot (free tier: 50 monitors):
- Monitor
https://app.example.com/health - Get email alerts when down
- Track uptime percentage
Step 9: Deployment Script
Stop manually copying files. Use a deployment script:
#!/bin/bash
# deploy.sh — run this to deploy latest code
set -e
APP_DIR="/var/www/your-app"
APP_NAME="myapp"
echo "🔄 Pulling latest code..."
cd "$APP_DIR"
git pull origin main
echo "📦 Installing dependencies..."
npm ci --production
echo "🔄 Restarting app..."
pm2 restart "$APP_NAME"
echo "✅ Deployed!"
pm2 logs "$APP_NAME" --lines 10 --nostream
chmod +x deploy.sh
./deploy.sh
For zero-downtime deployments with PM2:
# Instead of pm2 restart, use reload (graceful):
pm2 reload myapp # Reloads cluster workers one by one
Step 10: Daily Backups
#!/bin/bash
# /opt/backup.sh
BACKUP_DIR="/opt/backups"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# Backup database (SQLite example)
if [ -f "/var/www/your-app/data.db" ]; then
cp "/var/www/your-app/data.db" "$BACKUP_DIR/data_$DATE.db"
fi
# Backup app code (optional)
cd /var/www
tar czf "$BACKUP_DIR/app_$DATE.tar.gz" your-app/
# Keep only last 7 days
find "$BACKUP_DIR" -mtime +7 -delete
echo "Backup complete: $(date)"
# Add to cron (runs daily at 3 AM):
crontab -e
# Add line: 0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
The Complete Checklist
- [ ] Server created, non-root user set up
- [ ] Node.js v22+ installed (via NodeSource)
- [ ] App cloned,
npm ci --productionran - [ ]
.envfile configured (not committed to git) - [ ] PM2 running with
ecosystem.config.js - [ ] PM2 startup script installed (
pm2 startup) - [ ] Nginx installed and configured as reverse proxy
- [ ] Let's Encrypt SSL certificate active
- [ ] UFW firewall enabled (only 22, 80, 443 open)
- [ ] Fail2Ban running
- [ ] Health check endpoint working
- [ ] Deployment script ready
- [ ] Daily backups configured
- [ ] Uptime monitor set up
Cost Breakdown
| Item | Monthly Cost |
|---|---|
| VPS (2 cores, 4GB) | $5 |
| Domain name | ~$1 |
| SSL (Let's Encrypt) | $0 |
| CDN (Cloudflare free tier) | $0 |
| Monitoring (UptimeRobot free) | $0 |
| Total | ~$6/month |
Compare that to:
- Heroku: $7-50+/month (dyno costs)
- Railway: $5-20/month (free tier ends fast)
- Render: $7+/month (free tier limited)
- Vercel: Free for frontend, $20+/month for backend
Self-hosting saves money AND gives you full control.
Need help with deployment? Drop a comment or find me on GitHub: @armorbreak001
Follow @armorbreak for more practical deployment guides.
Top comments (0)