Tools: How to Set Up a Node.js Server from Scratch (2026)

Tools: How to Set Up a Node.js Server from Scratch (2026)

How to Set Up a Node.js Server from Scratch (2026)

Prerequisites

Step 1: Initial Server Setup

Step 2: Install Node.js via NVM

Step 3: Firewall Setup

Step 4: Deploy Your App

Step 5: Process Manager (PM2)

Ecosystem Config (Recommended)

Step 6: Nginx Reverse Proxy

Step 7: Free SSL with Let's Encrypt

Step 8: Security Hardening

Step 9: Monitoring & Log Rotation

Complete Deployment Checklist Complete guide: fresh server → running Node.js app with SSL, auto-restart, and monitoring. What does your deployment setup look like? Anything you'd add? Follow @armorbreak for more DevOps content. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# SSH into your server ssh root@your-server-ip # Update everything -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y # Create a non-root user (security best practice) adduser deploy usermod -aG -weight: 600;">sudo deploy # Switch to new user (or use su - deploy) su - deploy # Install essential tools -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">wget -weight: 500;">git vim ufw software-properties-common build-essential # SSH into your server ssh root@your-server-ip # Update everything -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y # Create a non-root user (security best practice) adduser deploy usermod -aG -weight: 600;">sudo deploy # Switch to new user (or use su - deploy) su - deploy # Install essential tools -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">wget -weight: 500;">git vim ufw software-properties-common build-essential # SSH into your server ssh root@your-server-ip # Update everything -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y # Create a non-root user (security best practice) adduser deploy usermod -aG -weight: 600;">sudo deploy # Switch to new user (or use su - deploy) su - deploy # Install essential tools -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">wget -weight: 500;">git vim ufw software-properties-common build-essential # Don't use -weight: 500;">apt for Node.js! It's outdated. # Use nvm instead: -weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/-weight: 500;">install.sh | bash source ~/.bashrc nvm -weight: 500;">install 22 # Install latest LTS nvm use 22 # Use it nvm alias default 22 # Make default node -v # v22.x.x -weight: 500;">npm -v # 10.x.x # Global packages I always need -weight: 500;">npm -weight: 500;">install -g pm2 nodemon # Don't use -weight: 500;">apt for Node.js! It's outdated. # Use nvm instead: -weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/-weight: 500;">install.sh | bash source ~/.bashrc nvm -weight: 500;">install 22 # Install latest LTS nvm use 22 # Use it nvm alias default 22 # Make default node -v # v22.x.x -weight: 500;">npm -v # 10.x.x # Global packages I always need -weight: 500;">npm -weight: 500;">install -g pm2 nodemon # Don't use -weight: 500;">apt for Node.js! It's outdated. # Use nvm instead: -weight: 500;">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/-weight: 500;">install.sh | bash source ~/.bashrc nvm -weight: 500;">install 22 # Install latest LTS nvm use 22 # Use it nvm alias default 22 # Make default node -v # v22.x.x -weight: 500;">npm -v # 10.x.x # Global packages I always need -weight: 500;">npm -weight: 500;">install -g pm2 nodemon # Configure UFW firewall -weight: 600;">sudo ufw allow OpenSSH # Port 22 (SSH) -weight: 600;">sudo ufw allow 80 # HTTP -weight: 600;">sudo ufw allow 443 # HTTPS -weight: 600;">sudo ufw -weight: 500;">enable # Enable firewall -weight: 600;">sudo ufw -weight: 500;">status verbose # Check rules # Optional: Allow additional ports # -weight: 600;">sudo ufw allow 3000 # If your app runs on port 3000 directly # Configure UFW firewall -weight: 600;">sudo ufw allow OpenSSH # Port 22 (SSH) -weight: 600;">sudo ufw allow 80 # HTTP -weight: 600;">sudo ufw allow 443 # HTTPS -weight: 600;">sudo ufw -weight: 500;">enable # Enable firewall -weight: 600;">sudo ufw -weight: 500;">status verbose # Check rules # Optional: Allow additional ports # -weight: 600;">sudo ufw allow 3000 # If your app runs on port 3000 directly # Configure UFW firewall -weight: 600;">sudo ufw allow OpenSSH # Port 22 (SSH) -weight: 600;">sudo ufw allow 80 # HTTP -weight: 600;">sudo ufw allow 443 # HTTPS -weight: 600;">sudo ufw -weight: 500;">enable # Enable firewall -weight: 600;">sudo ufw -weight: 500;">status verbose # Check rules # Optional: Allow additional ports # -weight: 600;">sudo ufw allow 3000 # If your app runs on port 3000 directly # Clone your repo (or copy files) cd /var/www/ -weight: 600;">sudo mkdir myapp && cd myapp -weight: 600;">sudo chown deploy:deploy . -weight: 500;">git clone https://github.com/you/your-app.-weight: 500;">git . # Install dependencies -weight: 500;">npm ci --production # ci = clean -weight: 500;">install (respects package-lock.json) # If TypeScript: -weight: 500;">npm run build # Test it works -weight: 500;">npm -weight: 500;">start & # Test with: -weight: 500;">curl http://localhost:3000 kill %1 # Clone your repo (or copy files) cd /var/www/ -weight: 600;">sudo mkdir myapp && cd myapp -weight: 600;">sudo chown deploy:deploy . -weight: 500;">git clone https://github.com/you/your-app.-weight: 500;">git . # Install dependencies -weight: 500;">npm ci --production # ci = clean -weight: 500;">install (respects package-lock.json) # If TypeScript: -weight: 500;">npm run build # Test it works -weight: 500;">npm -weight: 500;">start & # Test with: -weight: 500;">curl http://localhost:3000 kill %1 # Clone your repo (or copy files) cd /var/www/ -weight: 600;">sudo mkdir myapp && cd myapp -weight: 600;">sudo chown deploy:deploy . -weight: 500;">git clone https://github.com/you/your-app.-weight: 500;">git . # Install dependencies -weight: 500;">npm ci --production # ci = clean -weight: 500;">install (respects package-lock.json) # If TypeScript: -weight: 500;">npm run build # Test it works -weight: 500;">npm -weight: 500;">start & # Test with: -weight: 500;">curl http://localhost:3000 kill %1 # Start your app with PM2 pm2 -weight: 500;">start -weight: 500;">npm --name "myapp" -- -weight: 500;">start # Check -weight: 500;">status pm2 list pm2 logs myapp # View logs pm2 monit # Real-time monitoring dashboard # Useful commands pm2 -weight: 500;">restart myapp # Restart pm2 -weight: 500;">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: -weight: 600;">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! # Start your app with PM2 pm2 -weight: 500;">start -weight: 500;">npm --name "myapp" -- -weight: 500;">start # Check -weight: 500;">status pm2 list pm2 logs myapp # View logs pm2 monit # Real-time monitoring dashboard # Useful commands pm2 -weight: 500;">restart myapp # Restart pm2 -weight: 500;">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: -weight: 600;">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! # Start your app with PM2 pm2 -weight: 500;">start -weight: 500;">npm --name "myapp" -- -weight: 500;">start # Check -weight: 500;">status pm2 list pm2 logs myapp # View logs pm2 monit # Real-time monitoring dashboard # Useful commands pm2 -weight: 500;">restart myapp # Restart pm2 -weight: 500;">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: -weight: 600;">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.js module.exports = { apps: [{ name: 'myapp', script: '-weight: 500;">npm', args: '-weight: 500;">start', cwd: '/var/www/myapp', // Auto--weight: 500;">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 -weight: 500;">start ecosystem.config.js // pm2 reload all // Zero-downtime reload! // ecosystem.config.js module.exports = { apps: [{ name: 'myapp', script: '-weight: 500;">npm', args: '-weight: 500;">start', cwd: '/var/www/myapp', // Auto--weight: 500;">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 -weight: 500;">start ecosystem.config.js // pm2 reload all // Zero-downtime reload! // ecosystem.config.js module.exports = { apps: [{ name: 'myapp', script: '-weight: 500;">npm', args: '-weight: 500;">start', cwd: '/var/www/myapp', // Auto--weight: 500;">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 -weight: 500;">start ecosystem.config.js // pm2 reload all // Zero-downtime reload! # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Create config file -weight: 600;">sudo nano /etc/nginx/sites-available/myapp # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Create config file -weight: 600;">sudo nano /etc/nginx/sites-available/myapp # Install Nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install nginx -y # Create config file -weight: 600;">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 '-weight: 500;">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"; } } 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 '-weight: 500;">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"; } } 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 '-weight: 500;">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 -weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ -weight: 600;">sudo rm /etc/nginx/sites-enabled/default # Remove default site # Test config -weight: 600;">sudo nginx -t # Reload Nginx -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Your app is now live at http://your-server-ip or http://your-domain.com! # Enable site -weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ -weight: 600;">sudo rm /etc/nginx/sites-enabled/default # Remove default site # Test config -weight: 600;">sudo nginx -t # Reload Nginx -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Your app is now live at http://your-server-ip or http://your-domain.com! # Enable site -weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ -weight: 600;">sudo rm /etc/nginx/sites-enabled/default # Remove default site # Test config -weight: 600;">sudo nginx -t # Reload Nginx -weight: 600;">sudo -weight: 500;">systemctl reload nginx # Your app is now live at http://your-server-ip or http://your-domain.com! # Install Certbot -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Get certificate (auto-configures Nginx!) -weight: 600;">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) -weight: 600;">sudo certbot renew --dry-run # Auto-renewal is already set up via systemd timer -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer # Install Certbot -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Get certificate (auto-configures Nginx!) -weight: 600;">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) -weight: 600;">sudo certbot renew --dry-run # Auto-renewal is already set up via systemd timer -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer # Install Certbot -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install certbot python3-certbot-nginx -y # Get certificate (auto-configures Nginx!) -weight: 600;">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) -weight: 600;">sudo certbot renew --dry-run # Auto-renewal is already set up via systemd timer -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer # 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; # 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; # 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 -weight: 600;">sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd # Install fail2ban (auto-block brute force attempts) -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start fail2ban # Automatic security updates -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure unattended-upgrades # Select "Yes" # Additional security steps: # Disable root SSH login -weight: 600;">sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd # Install fail2ban (auto-block brute force attempts) -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start fail2ban # Automatic security updates -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure unattended-upgrades # Select "Yes" # Additional security steps: # Disable root SSH login -weight: 600;">sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart sshd # Install fail2ban (auto-block brute force attempts) -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install fail2ban -y -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">enable fail2ban -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">start fail2ban # Automatic security updates -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install unattended-upgrades -y -weight: 600;">sudo dpkg-reconfigure unattended-upgrades # Select "Yes" # Create log directory -weight: 600;">sudo mkdir -p /var/log/myapp -weight: 600;">sudo chown deploy:deploy /var/log/myapp # Log rotation (prevent disk fill!) -weight: 600;">sudo nano /etc/logrotate.d/myapp # Create log directory -weight: 600;">sudo mkdir -p /var/log/myapp -weight: 600;">sudo chown deploy:deploy /var/log/myapp # Log rotation (prevent disk fill!) -weight: 600;">sudo nano /etc/logrotate.d/myapp # Create log directory -weight: 600;">sudo mkdir -p /var/log/myapp -weight: 600;">sudo chown deploy:deploy /var/log/myapp # Log rotation (prevent disk fill!) -weight: 600;">sudo nano /etc/logrotate.d/myapp /var/log/myapp/*.log { daily rotate 14 compress delaycompress missingok notifempty create 0640 deploy deploy } /var/log/myapp/*.log { daily rotate 14 compress delaycompress missingok notifempty create 0640 deploy deploy } /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=$(-weight: 500;">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 # 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=$(-weight: 500;">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 # 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=$(-weight: 500;">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 □ Server updated (-weight: 500;">apt -weight: 500;">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 (-weight: 500;">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: -weight: 500;">curl https://your-domain.com □ Server updated (-weight: 500;">apt -weight: 500;">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 (-weight: 500;">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: -weight: 500;">curl https://your-domain.com □ Server updated (-weight: 500;">apt -weight: 500;">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 (-weight: 500;">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: -weight: 500;">curl https://your-domain.com - A VPS (DigitalOcean $4/mo, Hetzner, Linode, etc.) - A domain name (optional but recommended) - SSH access to the server