How to Set Up a Node.js Server from Scratch (2026)
Complete guide: fresh server → running Node.js app with SSL, auto-restart, and monitoring.
Prerequisites
- A VPS (DigitalOcean $4/mo, Hetzner, Linode, etc.)
- A domain name (optional but recommended)
- SSH access to the server
Step 1: Initial Server Setup
# SSH into your server
ssh root@your-server-ip
# Update everything
apt update && apt upgrade -y
# Create a non-root user (security best practice)
adduser deploy
usermod -aG sudo deploy
# Switch to new user (or use su - deploy)
su - deploy
# Install essential tools
sudo apt install -y curl wget git vim ufw software-properties-common build-essential
Step 2: Install Node.js via NVM
# Don't use apt for Node.js! It's outdated.
# Use nvm instead:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
source ~/.bashrc
nvm install 22 # Install latest LTS
nvm use 22 # Use it
nvm alias default 22 # Make default
node -v # v22.x.x
npm -v # 10.x.x
# Global packages I always need
npm install -g pm2 nodemon
Step 3: Firewall Setup
# Configure UFW firewall
sudo ufw allow OpenSSH # Port 22 (SSH)
sudo ufw allow 80 # HTTP
sudo ufw allow 443 # HTTPS
sudo ufw enable # Enable firewall
sudo ufw status verbose # Check rules
# Optional: Allow additional ports
# sudo ufw allow 3000 # If your app runs on port 3000 directly
Step 4: Deploy Your App
# Clone your repo (or copy files)
cd /var/www/
sudo mkdir myapp && cd myapp
sudo chown deploy:deploy .
git clone https://github.com/you/your-app.git .
# Install dependencies
npm ci --production # ci = clean install (respects package-lock.json)
# If TypeScript:
npm run build
# Test it works
npm start &
# Test with: curl http://localhost:3000
kill %1
Step 5: Process Manager (PM2)
# Start your app with PM2
pm2 start npm --name "myapp" -- start
# Check status
pm2 list
pm2 logs myapp # View logs
pm2 monit # Real-time monitoring dashboard
# Useful commands
pm2 restart myapp # Restart
pm2 stop myapp # Stop
pm2 delete myapp # Remove
pm2 info myapp # Detailed info
# Save process list (survives reboot)
pm2 startup # Shows command to run
# Run the command it shows:
sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v22.x/bin pm2 startup systemd -u deploy --hp /home/deploy
pm2 save # Save current process list
# Now PM2 starts automatically on boot!
Ecosystem Config (Recommended)
// ecosystem.config.js
module.exports = {
apps: [{
name: 'myapp',
script: 'npm',
args: 'start',
cwd: '/var/www/myapp',
// Auto-restart on crash
autorestart: true,
// Max restarts per second (prevent infinite loop)
max_restarts: 10,
// Restart if using too much memory
max_memory_restart: '500M',
// Environment variables
env: {
NODE_ENV: 'production',
PORT: 3000,
HOST: '127.0.0.1',
},
// Logging
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/var/log/myapp/error.log',
out_file: '/var/www/myapp/out.log',
merge_logs: true,
}],
};
// Usage:
// pm2 start ecosystem.config.js
// pm2 reload all // Zero-downtime reload!
Step 6: Nginx Reverse Proxy
# Install Nginx
sudo apt install nginx -y
# Create config file
sudo nano /etc/nginx/sites-available/myapp
server {
listen 80;
server_name your-domain.com www.your-domain.com; # Or your IP
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Headers
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 assets caching (if you serve any)
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
# Enable site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default # Remove default site
# Test config
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginx
# Your app is now live at http://your-server-ip or http://your-domain.com!
Step 7: Free SSL with Let's Encrypt
# Install Certbot
sudo apt install certbot python3-certbot-nginx -y
# Get certificate (auto-configures Nginx!)
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# Follow prompts:
# - Enter email
# - Agree to terms
# - Choose redirect (option 2: redirect HTTP to HTTPS)
# Done! Your site now has HTTPS.
# Test auto-renewal (certificates expire in 90 days)
sudo certbot renew --dry-run
# Auto-renewal is already set up via systemd timer
sudo systemctl status certbot.timer
Step 8: Security Hardening
# Add to your Nginx config (inside server block):
# Hide Nginx version
server_tokens off;
# 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;
# Block access to hidden files
location ~ /\. {
deny all;
}
# Limit request size (prevent huge uploads)
client_max_body_size 10M;
# Additional security steps:
# Disable root SSH login
sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
# Install fail2ban (auto-block brute force attempts)
sudo apt install fail2ban -y
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Automatic security updates
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure unattended-upgrades # Select "Yes"
Step 9: Monitoring & Log Rotation
# Create log directory
sudo mkdir -p /var/log/myapp
sudo chown deploy:deploy /var/log/myapp
# Log rotation (prevent disk fill!)
sudo nano /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 deploy deploy
}
# Quick health check script
cat > /var/www/myapp/health.sh << 'EOF'
#!/bin/bash
if pm2 pid myapp > /dev/null; then
echo "✅ App running (PID: $(pm2 pid myapp))"
else
echo "❌ App NOT running!"
exit 1
fi
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
echo "HTTP Status: $HTTP_STATUS"
DISK_USAGE=$(df -h / | awk 'NR==2{print $5}')
echo "Disk usage: $DISK_USAGE"
MEM_FREE=$(free -h | awk '/Mem:/{print $7}')
echo "Free memory: $MEM_FREE"
EOF
chmod +x /var/www/myapp/health.sh
# Run anytime: ./health.sh
Complete Deployment Checklist
□ Server updated (apt upgrade)
□ Non-root user created
□ Node.js installed via nvm
□ UFW firewall configured (SSH + HTTP + HTTPS only)
□ App code deployed to /var/www/myapp/
□ Dependencies installed (npm ci)
□ PM2 configured and running
□ PM2 startup saved (survives reboot)
□ Nginx installed and configured as reverse proxy
□ DNS pointing to server IP
□ SSL certificate installed (Let's Encrypt)
□ HTTP redirects to HTTPS
□ Security headers configured
□ Root SSH login disabled
□ Fail2Ban running
□ Log rotation configured
□ Health check script working
□ Tested: curl https://your-domain.com
What does your deployment setup look like? Anything you'd add?
Follow @armorbreak for more DevOps content.
Top comments (0)