Choosing a VPS Provider

Before you can deploy your web application, you need a Linux server. Virtual Private Servers (VPS) give you full root access at a fraction of the cost of dedicated hardware. Here is how the major providers compare in 2025:

ProviderCheapest PlanBest ForStandout Feature
DigitalOcean$4/mo (512MB)Beginners, simple appsExcellent docs and tutorials
Hetzner€3.79/mo (2GB)Price-performance ratioBest specs per dollar in EU
Linode (Akamai)$5/mo (1GB)Reliability, supportFree DDoS protection
AWS EC2Free tier (t2.micro)Enterprise, scalingMassive ecosystem
Vultr$2.50/mo (512MB)Budget projectsMany global locations

Initial Ubuntu 22.04 Server Setup

Step 1: Update the System

# Connect to your server
ssh root@your_server_ip

# Update package lists and upgrade all packages
sudo apt update && sudo apt upgrade -y

# Install essential tools
sudo apt install -y curl wget git unzip software-properties-common apt-transport-https ca-certificates gnupg lsb-release

Step 2: Create a Non-Root User

# Create a new user
adduser deploy

# Add to sudo group
usermod -aG sudo deploy

# Test sudo access
su - deploy
sudo whoami  # Should output: root

Step 3: Set Up SSH Key Authentication

# On your LOCAL machine — generate SSH key pair
ssh-keygen -t ed25519 -C "your_email@example.com"

# Copy public key to server
ssh-copy-id deploy@your_server_ip

# Or manually on the server:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "your-public-key-content" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Step 4: Disable Password Authentication

# Edit SSH configuration
sudo nano /etc/ssh/sshd_config

# Change these settings:
# PasswordAuthentication no
# PubkeyAuthentication yes
# PermitRootLogin no
# ChallengeResponseAuthentication no

# Restart SSH
sudo systemctl restart sshd

# IMPORTANT: Test SSH access in a NEW terminal before closing current session!
ssh deploy@your_server_ip

Firewall Configuration with UFW

# Check status
sudo ufw status

# Set defaults
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow essential services
sudo ufw allow OpenSSH        # Port 22
sudo ufw allow 80/tcp         # HTTP
sudo ufw allow 443/tcp        # HTTPS

# Allow specific port (e.g., for Node.js development)
sudo ufw allow 3000/tcp

# Allow from specific IP only
sudo ufw allow from 203.0.113.50 to any port 22

# Enable firewall
sudo ufw enable

# Check rules
sudo ufw status verbose

Nginx Installation and Configuration

Install Nginx

sudo apt install nginx -y
sudo systemctl start nginx
sudo systemctl enable nginx

# Verify
sudo nginx -t
curl http://localhost

Server Blocks (Virtual Hosts)

# /etc/nginx/sites-available/myapp.com
server {
    listen 80;
    listen [::]:80;
    server_name myapp.com www.myapp.com;

    root /var/www/myapp.com/public;
    index index.html index.htm;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;

    # Caching static assets
    location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

Reverse Proxy for Node.js

# /etc/nginx/sites-available/nodeapp.com
server {
    listen 80;
    server_name nodeapp.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

Reverse Proxy for Python (Gunicorn)

# /etc/nginx/sites-available/pythonapp.com
server {
    listen 80;
    server_name pythonapp.com;

    location / {
        proxy_pass http://unix:/var/www/pythonapp/app.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /static/ {
        alias /var/www/pythonapp/static/;
        expires 30d;
    }
}

WebSocket Proxy Configuration

location /ws/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400s;  # Keep WebSocket alive for 24h
}

Rate Limiting

# In http block (/etc/nginx/nginx.conf)
http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
}

# In server block
location /api/ {
    limit_req zone=general burst=20 nodelay;
    proxy_pass http://127.0.0.1:3000;
}

location /api/auth/login {
    limit_req zone=login burst=5;
    proxy_pass http://127.0.0.1:3000;
}
# Enable the site
sudo ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

SSL with Certbot (Let's Encrypt)

# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Obtain and install SSL certificate
sudo certbot --nginx -d myapp.com -d www.myapp.com

# Auto-renewal (Certbot adds this automatically, but verify)
sudo certbot renew --dry-run

# Check the auto-renewal timer
sudo systemctl list-timers | grep certbot

# Manual renewal cron (if needed)
# sudo crontab -e
# 0 0 1 * * certbot renew --post-hook "systemctl reload nginx"

Node.js Deployment with PM2

# Install Node.js via nvm (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm alias default 20
node --version

# Install PM2 globally
npm install -g pm2

# Start your app
cd /var/www/myapp
npm install --production
pm2 start server.js --name "myapp"

# PM2 ecosystem file for advanced configuration
// ecosystem.config.js
module.exports = {
  apps: [{
    name: "myapp",
    script: "./dist/server.js",
    instances: "max",     // Cluster mode — use all CPU cores
    exec_mode: "cluster",
    env: {
      NODE_ENV: "production",
      PORT: 3000,
    },
    max_memory_restart: "500M",
    log_date_format: "YYYY-MM-DD HH:mm:ss Z",
    error_file: "/var/log/pm2/myapp-error.log",
    out_file: "/var/log/pm2/myapp-out.log",
    merge_logs: true,
    autorestart: true,
    watch: false,
    max_restarts: 10,
    restart_delay: 4000,
  }]
};
# Start with ecosystem file
pm2 start ecosystem.config.js

# PM2 commands
pm2 list                   # List all processes
pm2 logs myapp             # View logs
pm2 monit                  # Real-time monitoring
pm2 reload myapp           # Zero-downtime reload
pm2 stop myapp             # Stop
pm2 delete myapp           # Remove
pm2 save                   # Save process list
pm2 startup                # Generate startup script

PHP Deployment (PHP-FPM)

# Install PHP 8.3 with common extensions
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
sudo apt install -y php8.3-fpm php8.3-mysql php8.3-xml php8.3-mbstring php8.3-curl php8.3-zip php8.3-gd php8.3-redis php8.3-bcmath

# Check PHP-FPM status
sudo systemctl status php8.3-fpm
# /etc/php/8.3/fpm/pool.d/www.conf — PHP-FPM Tuning
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock

; Process manager settings
pm = dynamic
pm.max_children = 50        ; Max number of child processes
pm.start_servers = 10       ; Number of children at startup
pm.min_spare_servers = 5    ; Min idle children
pm.max_spare_servers = 15   ; Max idle children
pm.max_requests = 500       ; Respawn after N requests (prevents memory leaks)

; OPcache settings (/etc/php/8.3/fpm/conf.d/10-opcache.ini)
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 0      ; 0 for production (never recheck files)
opcache.validate_timestamps = 0  ; 0 for production
opcache.jit = tracing
opcache.jit_buffer_size = 128M
# Nginx config for PHP
server {
    listen 80;
    server_name phpapp.com;
    root /var/www/phpapp/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ .php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffer_size 32k;
        fastcgi_buffers 16 16k;
    }

    location ~ /.ht {
        deny all;
    }
}

Python Deployment (Gunicorn + Supervisor)

# Set up Python virtual environment
sudo apt install python3-venv python3-pip -y
cd /var/www/pythonapp
python3 -m venv venv
source venv/bin/activate
pip install gunicorn flask  # or django

# Test gunicorn
gunicorn --bind 0.0.0.0:8000 app:app
# /etc/supervisor/conf.d/pythonapp.conf
[program:pythonapp]
directory=/var/www/pythonapp
command=/var/www/pythonapp/venv/bin/gunicorn --workers 4 --bind unix:/var/www/pythonapp/app.sock app:app
user=deploy
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/pythonapp-error.log
stdout_logfile=/var/log/supervisor/pythonapp-out.log
environment=FLASK_ENV="production",DATABASE_URL="mysql://user:pass@localhost/mydb"
# Install and start supervisor
sudo apt install supervisor -y
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status pythonapp

MySQL and PostgreSQL Installation

# === MySQL ===
sudo apt install mysql-server -y
sudo mysql_secure_installation
# Set root password, remove anonymous users, disable remote root login

# Create database and user
sudo mysql -u root -p
CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
GRANT ALL PRIVILEGES ON myapp.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
# === PostgreSQL ===
sudo apt install postgresql postgresql-contrib -y
sudo -u postgres psql
CREATE USER app_user WITH PASSWORD 'StrongPassword123!';
CREATE DATABASE myapp OWNER app_user;
GRANT ALL PRIVILEGES ON DATABASE myapp TO app_user;
q

Redis Setup

# Install Redis
sudo apt install redis-server -y

# Configure Redis
sudo nano /etc/redis/redis.conf
# Change: supervised systemd
# Change: maxmemory 256mb
# Change: maxmemory-policy allkeys-lru

# Set password
# requirepass YourRedisPassword

sudo systemctl restart redis
sudo systemctl enable redis

# Test
redis-cli ping  # Should return PONG

Fail2Ban for Security

# Install fail2ban
sudo apt install fail2ban -y

# Create local config
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600          # Ban for 1 hour
findtime = 600          # Within 10 minute window
maxretry = 5            # After 5 failures
destemail = admin@example.com
action = %(action_mwl)s # Mail with logs

[sshd]
enabled = true
port = ssh
maxretry = 3

[nginx-http-auth]
enabled = true
port = http,https

[nginx-botsearch]
enabled = true
port = http,https
# Restart and check status
sudo systemctl restart fail2ban
sudo fail2ban-client status
sudo fail2ban-client status sshd

# Unban an IP
sudo fail2ban-client set sshd unbanip 203.0.113.50

Automated Backups with Cron + Rclone

# Install rclone
curl https://rclone.org/install.sh | sudo bash

# Configure S3 remote
rclone config
# Follow prompts: name=s3backup, type=s3, provider=AWS...

# Create backup script
sudo nano /opt/scripts/backup.sh
#!/bin/bash
# /opt/scripts/backup.sh

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/tmp/backups/$TIMESTAMP"
S3_BUCKET="s3backup:my-backups"

mkdir -p "$BACKUP_DIR"

# Backup MySQL databases
mysqldump -u root --all-databases --single-transaction | gzip > "$BACKUP_DIR/mysql_all_$TIMESTAMP.sql.gz"

# Backup application files
tar -czf "$BACKUP_DIR/www_$TIMESTAMP.tar.gz" /var/www/

# Backup Nginx configs
tar -czf "$BACKUP_DIR/nginx_$TIMESTAMP.tar.gz" /etc/nginx/

# Upload to S3
rclone copy "$BACKUP_DIR" "$S3_BUCKET/$TIMESTAMP" --progress

# Cleanup local backups
rm -rf "$BACKUP_DIR"

# Delete backups older than 30 days on S3
rclone delete "$S3_BUCKET" --min-age 30d

echo "Backup completed: $TIMESTAMP"
# Make executable and schedule
sudo chmod +x /opt/scripts/backup.sh

# Add to crontab (daily at 2 AM)
sudo crontab -e
# 0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Monitoring and Troubleshooting

# System monitoring
htop                           # Interactive process viewer
df -h                          # Disk usage
free -m                        # Memory usage
uptime                         # Load average

# Network
ss -tulnp                      # Active listening ports
sudo netstat -tlnp             # Active connections
curl -I https://myapp.com      # Check HTTP headers

# Logs
sudo journalctl -u nginx -f              # Nginx logs (live)
sudo journalctl -u mysql -n 50           # Last 50 MySQL log entries
sudo tail -f /var/log/syslog             # System log
sudo tail -f /var/log/nginx/error.log    # Nginx error log

# Log rotation
sudo nano /etc/logrotate.d/myapp
# /etc/logrotate.d/myapp
/var/log/pm2/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

Common Server Errors and Fixes

502 Bad Gateway

Problem: Nginx cannot reach the upstream server.

Cause: Backend application crashed or is not running.

Solution:

# Check if backend is running
pm2 list                          # For Node.js
sudo systemctl status php8.3-fpm  # For PHP
sudo supervisorctl status         # For Python

# Check Nginx error log
sudo tail -20 /var/log/nginx/error.log

# Restart services
pm2 restart myapp
sudo systemctl restart php8.3-fpm
sudo systemctl restart nginx

504 Gateway Timeout

Problem: Backend takes too long to respond.

Cause: Slow database query, external API timeout, or heavy computation.

Solution:

# Increase Nginx timeouts
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_read_timeout 300s;
    proxy_connect_timeout 75s;
    proxy_send_timeout 300s;
}

Permission Denied

Problem: Nginx cannot access files or directories.

Cause: Wrong file ownership or permissions.

Solution:

# Fix ownership
sudo chown -R www-data:www-data /var/www/myapp

# Fix permissions
sudo find /var/www/myapp -type d -exec chmod 755 {} ;
sudo find /var/www/myapp -type f -exec chmod 644 {} ;

# Check Nginx user
grep "user" /etc/nginx/nginx.conf  # Should be www-data

Quick Reference Cheat Sheet

TaskCommand
Update systemsudo apt update && sudo apt upgrade -y
Check disk spacedf -h
Check memoryfree -m
Check open portsss -tulnp
Nginx test configsudo nginx -t
Nginx reloadsudo systemctl reload nginx
Certbot renewsudo certbot renew
PM2 statuspm2 list
PM2 logspm2 logs appname
View system logsudo journalctl -xe
Check fail2bansudo fail2ban-client status sshd
Firewall rulessudo ufw status verbose