Deploying a Node.js App to Production: The 2026 Guide
From local development to live server. The complete deployment checklist.
Pre-Deployment Checklist
// 1. Environment variables
const requiredEnvs = ['DATABASE_URL', 'JWT_SECRET', 'PORT'];
for (const env of requiredEnvs) {
if (!process.env[env]) {
console.error(`Missing: ${env}`);
process.exit(1);
}
}
// 2. Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV,
});
});
// 3. Graceful shutdown
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
let isShuttingDown = false;
function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`\n${signal} received. Shutting down gracefully...`);
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
console.error('Forced shutdown');
process.exit(1);
}, 10000);
}
PM2 Process Manager
# Install globally
npm install -g pm2
# Start your app
pm2 start server.js --name "my-app"
# Useful commands
pm2 list # List all apps
pm2 logs my-app # View logs
pm2 monit # Monitor dashboard
pm2 restart my-app # Restart
pm2 stop my-app # Stop
pm2 delete my-app # Remove
# Auto-restart on crash
pm2 start server.js --name "my-app" --max-memory-restart 500M
# Cluster mode (utilize all CPU cores)
pm2 start server.js -i max --name "my-app-cluster"
# Startup script (survives reboot)
pm2 startup # Generates command to run as sudo
pm2 save # Save current process list
Nginx Reverse Proxy
# /etc/nginx/sites-available/my-app
upstream my_app {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name example.com www.example.com;
# Redirect HTTP → HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL certificates (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Proxy to Node.js app
location / {
proxy_pass http://my_app;
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;
# Buffer settings for large responses
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# Static files (serve directly, don't pass to Node)
location /static/ {
alias /var/www/my-app/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml image/svg+xml;
}
Systemd Service (Alternative to PM2)
# /etc/systemd/system/my-app.service
[Unit]
Description=My Node.js Application
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/my-app
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=10
Environment=NODE_ENV=production
EnvironmentFile=/var/www/my-app/.env.production
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadOnlyPaths=/usr /bin
[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable my-app
sudo systemctl start my-app
# Commands
sudo systemctl status my-app
sudo systemctl restart my-app
sudo journalctl -u my-app -f # Follow logs
Let's Encrypt SSL
# Install certbot
sudo apt install certbot python3-certbot-nginx
# Get certificate (auto-configures Nginx!)
sudo certbot --nginx -d example.com -d www.example.com
# Auto-renewal (certbot sets this up automatically)
sudo certbot renew --dry-run # Test renewal
sudo systemctl status certbot.timer # Check auto-renew timer
Monitoring & Logging
// Structured logging with pino (fast!)
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty', // Pretty-print in dev
options: { colorize: true }
},
});
logger.info({ userId: 123 }, 'User logged in');
logger.error({ err: error }, 'Database query failed');
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info(
{ method: req.method, url: req.url, statusCode: res.statusCode, duration: Date.now() - start },
'Request completed'
);
});
next();
});
Deployment Script
#!/bin/bash
# deploy.sh — One-command deployment
set -e # Exit on any error
echo "🚀 Starting deployment..."
APP_DIR="/var/www/my-app"
GIT_REPO="git@github.com:user/my-app.git"
BRANCH="main"
# 1. Pull latest code
cd $APP_DIR
git fetch origin
git reset --hard origin/$BRANCH
# 2. Install dependencies
npm ci --production # Faster than npm install, uses package-lock.json
# 3. Build assets (if needed)
npm run build
# 4. Run migrations (if applicable)
npx prisma migrate deploy
# 5. Restart application
if command -v pm2 &> /dev/null; then
pm2 restart my-app
else
sudo systemctl restart my-app
fi
# 6. Verify health
sleep 3
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✅ Deployment successful!"
else
echo "❌ Health check failed! Check logs."
exit 1
fi
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Process manager | PM2 | pm2 start server.js |
| Or use | systemd | systemctl start my-app |
| Reverse proxy | Nginx | Config in sites-available |
| SSL | Certbot | certbot --nginx |
| Logs | PM2/journalctl |
pm2 logs / journalctl -u my-app
|
| Restart | PM2/systemd |
pm2 restart / systemctl restart
|
| Zero-downtime | PM2 reload | pm2 reload my-app |
What's your deployment setup? Do you use PM2, Docker, or something else?
Follow @armorbreak for more DevOps content.
Top comments (0)