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:
| Provider | Cheapest Plan | Best For | Standout Feature |
|---|---|---|---|
| DigitalOcean | $4/mo (512MB) | Beginners, simple apps | Excellent docs and tutorials |
| Hetzner | €3.79/mo (2GB) | Price-performance ratio | Best specs per dollar in EU |
| Linode (Akamai) | $5/mo (1GB) | Reliability, support | Free DDoS protection |
| AWS EC2 | Free tier (t2.micro) | Enterprise, scaling | Massive ecosystem |
| Vultr | $2.50/mo (512MB) | Budget projects | Many 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
| Task | Command |
|---|---|
| Update system | sudo apt update && sudo apt upgrade -y |
| Check disk space | df -h |
| Check memory | free -m |
| Check open ports | ss -tulnp |
| Nginx test config | sudo nginx -t |
| Nginx reload | sudo systemctl reload nginx |
| Certbot renew | sudo certbot renew |
| PM2 status | pm2 list |
| PM2 logs | pm2 logs appname |
| View system log | sudo journalctl -xe |
| Check fail2ban | sudo fail2ban-client status sshd |
| Firewall rules | sudo ufw status verbose |