Server Deployment (Linux/Nginx)
Self-managed server deployment gives you full control over infrastructure, configuration, and costs. This guide covers setting up a production-grade Linux server (Ubuntu) with Nginx as a reverse proxy, PM2 for process management, Let's Encrypt for SSL, and security hardening.
While platform-as-a-service solutions offer convenience, understanding server deployment is essential for debugging production issues, optimizing performance, and managing infrastructure that PaaS platforms cannot handle.
A Virtual Private Server (VPS) from providers like DigitalOcean, Linode, Vultr, or AWS EC2 gives you a root-accessible Linux machine. Start with Ubuntu 24.04 LTS.
| 1 | # SSH into your server (replace with your IP) |
| 2 | ssh root@203.0.113.10 |
| 3 | |
| 4 | # Update system packages |
| 5 | apt update && apt upgrade -y |
| 6 | |
| 7 | # Create a non-root user with sudo privileges |
| 8 | adduser deploy |
| 9 | usermod -aG sudo deploy |
| 10 | |
| 11 | # Copy your SSH key for passwordless login |
| 12 | rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/ |
| 13 | |
| 14 | # Disable root SSH login (edit /etc/ssh/sshd_config): |
| 15 | # PermitRootLogin no |
| 16 | # PasswordAuthentication no |
| 17 | systemctl restart sshd |
| 18 | |
| 19 | # Set hostname |
| 20 | hostnamectl set-hostname app-server-01 |
| 21 | |
| 22 | # Configure timezone |
| 23 | timedatectl set-timezone UTC |
| 24 | |
| 25 | # Install essential packages |
| 26 | apt install -y curl wget git ufw fail2ban unattended-upgrades |
danger
UFW (Uncomplicated Firewall) blocks all incoming traffic except explicitly allowed ports. SSH hardening prevents brute-force attacks.
| 1 | # Configure UFW firewall |
| 2 | ufw default deny incoming |
| 3 | ufw default allow outgoing |
| 4 | |
| 5 | # Allow SSH (change port first if using custom port) |
| 6 | ufw allow 22/tcp |
| 7 | |
| 8 | # Allow HTTP and HTTPS |
| 9 | ufw allow 80/tcp |
| 10 | ufw allow 443/tcp |
| 11 | |
| 12 | # Enable firewall |
| 13 | ufw enable |
| 14 | ufw status verbose |
| 15 | |
| 16 | # Configure Fail2ban for SSH protection |
| 17 | cat > /etc/fail2ban/jail.local << 'EOF' |
| 18 | [sshd] |
| 19 | enabled = true |
| 20 | port = 22 |
| 21 | maxretry = 3 |
| 22 | bantime = 3600 |
| 23 | findtime = 600 |
| 24 | EOF |
| 25 | |
| 26 | systemctl restart fail2ban |
| 27 | |
| 28 | # SSH hardening (/etc/ssh/sshd_config): |
| 29 | # Port 2222 (change to non-standard port) |
| 30 | # PermitRootLogin no |
| 31 | # PubkeyAuthentication yes |
| 32 | # PasswordAuthentication no |
| 33 | # ClientAliveInterval 300 |
| 34 | # ClientAliveCountMax 2 |
| 35 | # AllowUsers deploy |
pro tip
Nginx sits in front of your application server, handling TLS termination, static file serving, load balancing, and request routing. It is significantly more efficient than handling these concerns in application code.
| 1 | # Install Nginx |
| 2 | apt install -y nginx |
| 3 | |
| 4 | # Create Nginx config for your app |
| 5 | cat > /etc/nginx/sites-available/my-app << 'EOF' |
| 6 | server { |
| 7 | listen 80; |
| 8 | server_name example.com www.example.com; |
| 9 | |
| 10 | # Redirect all HTTP to HTTPS (after SSL setup) |
| 11 | return 301 https://$server_name$request_uri; |
| 12 | } |
| 13 | |
| 14 | server { |
| 15 | listen 443 ssl http2; |
| 16 | server_name example.com www.example.com; |
| 17 | |
| 18 | ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; |
| 19 | ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; |
| 20 | |
| 21 | # SSL configuration |
| 22 | ssl_protocols TLSv1.2 TLSv1.3; |
| 23 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; |
| 24 | ssl_prefer_server_ciphers on; |
| 25 | ssl_session_cache shared:SSL:10m; |
| 26 | ssl_session_timeout 10m; |
| 27 | |
| 28 | # Security headers |
| 29 | add_header X-Frame-Options "DENY" always; |
| 30 | add_header X-Content-Type-Options "nosniff" always; |
| 31 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; |
| 32 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; |
| 33 | |
| 34 | # Gzip compression |
| 35 | gzip on; |
| 36 | gzip_types text/plain text/css application/json application/javascript; |
| 37 | |
| 38 | # Static files (Next.js public folder) |
| 39 | location /_next/static/ { |
| 40 | alias /var/www/my-app/.next/static/; |
| 41 | expires max; |
| 42 | add_header Cache-Control "public, immutable"; |
| 43 | } |
| 44 | |
| 45 | location /public/ { |
| 46 | alias /var/www/my-app/public/; |
| 47 | expires 30d; |
| 48 | } |
| 49 | |
| 50 | # Reverse proxy to Node.js app |
| 51 | location / { |
| 52 | proxy_pass http://127.0.0.1:3000; |
| 53 | proxy_http_version 1.1; |
| 54 | proxy_set_header Upgrade $http_upgrade; |
| 55 | proxy_set_header Connection 'upgrade'; |
| 56 | proxy_set_header Host $host; |
| 57 | proxy_set_header X-Real-IP $remote_addr; |
| 58 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 59 | proxy_set_header X-Forwarded-Proto $scheme; |
| 60 | proxy_cache_bypass $http_upgrade; |
| 61 | } |
| 62 | } |
| 63 | EOF |
| 64 | |
| 65 | # Enable the site |
| 66 | ln -s /etc/nginx/sites-available/my-app /etc/nginx/sites-enabled/ |
| 67 | rm /etc/nginx/sites-enabled/default |
| 68 | |
| 69 | # Test configuration |
| 70 | nginx -t |
| 71 | |
| 72 | # Restart Nginx |
| 73 | systemctl restart nginx |
best practice
Certbot automates SSL certificate issuance and renewal from Let's Encrypt, a free certificate authority trusted by all major browsers.
| 1 | # Install Certbot |
| 2 | apt install -y certbot python3-certbot-nginx |
| 3 | |
| 4 | # Obtain certificate (automatically modifies Nginx config) |
| 5 | certbot --nginx -d example.com -d www.example.com |
| 6 | |
| 7 | # Test auto-renewal |
| 8 | certbot renew --dry-run |
| 9 | |
| 10 | # Check expiration dates |
| 11 | certbot certificates |
| 12 | |
| 13 | # Manual certificate renewal (if auto-renewal fails) |
| 14 | certbot renew |
| 15 | |
| 16 | # Auto-renewal is configured by default via systemd timer: |
| 17 | systemctl list-timers | grep certbot |
| 18 | |
| 19 | # Or via cron (if not using systemd): |
| 20 | # 0 3 * * * /usr/bin/certbot renew --quiet |
info
PM2 is a production process manager for Node.js applications. It keeps your app alive forever, reloads without downtime, and provides a built-in load balancer for multi-core systems.
| 1 | # Install PM2 globally |
| 2 | npm install -g pm2 |
| 3 | |
| 4 | # Start your application |
| 5 | pm2 start npm --name "my-app" -- start |
| 6 | |
| 7 | # Start with specific options |
| 8 | pm2 start ecosystem.config.js |
| 9 | |
| 10 | # PM2 ecosystem file |
| 11 | cat > ecosystem.config.js << 'EOF' |
| 12 | module.exports = { |
| 13 | apps: [{ |
| 14 | name: 'my-app', |
| 15 | script: 'npm', |
| 16 | args: 'start', |
| 17 | cwd: '/var/www/my-app', |
| 18 | instances: 'max', // Use all CPU cores |
| 19 | exec_mode: 'cluster', // Cluster mode for load balancing |
| 20 | env: { |
| 21 | NODE_ENV: 'production', |
| 22 | PORT: 3000, |
| 23 | }, |
| 24 | max_memory_restart: '500M', // Restart if memory exceeds 500MB |
| 25 | log_date_format: 'YYYY-MM-DD HH:mm:ss Z', |
| 26 | error_file: '/var/log/pm2/my-app-error.log', |
| 27 | out_file: '/var/log/pm2/my-app-out.log', |
| 28 | merge_logs: true, |
| 29 | }], |
| 30 | }; |
| 31 | EOF |
| 32 | |
| 33 | # Save PM2 process list (auto-restart on reboot) |
| 34 | pm2 save |
| 35 | pm2 startup systemd |
| 36 | |
| 37 | # Useful PM2 commands |
| 38 | pm2 status # List all processes |
| 39 | pm2 logs my-app # View logs |
| 40 | pm2 monit # Monitor CPU/memory |
| 41 | pm2 reload my-app # Zero-downtime reload |
| 42 | pm2 restart my-app # Restart (brief downtime) |
| 43 | pm2 stop my-app # Stop process |
| 44 | pm2 delete my-app # Remove from PM2 |
pro tip
Zero-downtime deployment ensures your application remains available during updates. The process: build on the server, then use PM2's reload (graceful restart) which rotates workers one at a time.
| 1 | #!/bin/bash |
| 2 | # deploy.sh — Zero-downtime deployment script |
| 3 | |
| 4 | set -e |
| 5 | |
| 6 | APP_DIR="/var/www/my-app" |
| 7 | REPO_URL="git@github.com:myorg/my-app.git" |
| 8 | BRANCH="main" |
| 9 | |
| 10 | echo "=== Starting deployment ===" |
| 11 | |
| 12 | # 1. Pull latest code |
| 13 | cd $APP_DIR |
| 14 | git pull origin $BRANCH |
| 15 | |
| 16 | # 2. Install dependencies |
| 17 | npm ci --only=production |
| 18 | |
| 19 | # 3. Build the application |
| 20 | npm run build |
| 21 | |
| 22 | # 4. Run database migrations (if applicable) |
| 23 | # npm run migrate |
| 24 | |
| 25 | # 5. Reload application (zero-downtime) |
| 26 | pm2 reload ecosystem.config.js |
| 27 | |
| 28 | # 6. Run smoke tests |
| 29 | sleep 5 |
| 30 | curl -f http://localhost:3000/api/health |
| 31 | |
| 32 | # 7. Clean up old builds |
| 33 | # Keep last 2 builds for rollback |
| 34 | pm2 list |
| 35 | |
| 36 | echo "=== Deployment complete ===" |
warning
Unattended-upgrades automatically install critical security patches without manual intervention. This is essential for keeping your server protected against known vulnerabilities.
| 1 | # Install unattended-upgrades |
| 2 | apt install -y unattended-upgrades |
| 3 | |
| 4 | # Configure |
| 5 | dpkg-reconfigure -plow unattended-upgrades |
| 6 | |
| 7 | # Configuration file: /etc/apt/apt.conf.d/50unattended-upgrades |
| 8 | # Key settings: |
| 9 | Unattended-Upgrade::Allowed-Origins { |
| 10 | "${distro_id}:${distro_codename}-security"; |
| 11 | "${distro_id}ESMApps:${distro_codename}-apps-security"; |
| 12 | }; |
| 13 | |
| 14 | Unattended-Upgrade::AutoFixInterruptedDpkg "true"; |
| 15 | Unattended-Upgrade::MinimalSteps "true"; |
| 16 | Unattended-Upgrade::Mail "admin@example.com"; |
| 17 | Unattended-Upgrade::AutomaticReboot "false"; |
| 18 | Unattended-Upgrade::Remove-Unused-Dependency "true"; |
| 19 | |
| 20 | # View auto-update log |
| 21 | cat /var/log/unattended-upgrades/unattended-upgrades.log |