Most of my freelance work ends on a VPS: a Laravel app, a WordPress site or an OpenCart store moving off shared hosting. This is the baseline I set up on a fresh Ubuntu 24.04 server before deploying anything.
1. First login: create a user and lock down SSH
adduser deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
Then in /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Restart with systemctl restart ssh, and test the new login in a second terminal before closing the first one.
2. Firewall and automatic security updates
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
apt install unattended-upgrades fail2ban -y
dpkg-reconfigure --priority=low unattended-upgrades
3. Nginx, PHP-FPM and MariaDB
apt update && apt upgrade -y
apt install nginx mariadb-server -y
apt install php8.3-fpm php8.3-mysql php8.3-xml php8.3-mbstring \
php8.3-curl php8.3-zip php8.3-gd php8.3-intl -y
mysql_secure_installation
Create a separate database user for each site, and never connect an app as root:
CREATE DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'shop'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON shop.* TO 'shop'@'localhost';
4. A clean Nginx server block
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~* \.(jpg|jpeg|png|webp|svg|css|js|woff2)$ {
expires 30d;
access_log off;
}
location ~ /\.(?!well-known) { deny all; }
}
5. Free SSL with Certbot
apt install certbot python3-certbot-nginx -y
certbot --nginx -d example.com -d www.example.com
Certbot installs a renewal timer; check it with systemctl list-timers | grep certbot.
6. PHP-FPM and OPcache tuning
For a 2 GB VPS running one or two sites, start with conservative pm values and enable OPcache:
; /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.validate_timestamps=0 ; reload php-fpm on deploy
7. Backups you have actually tested
A nightly database dump plus files, copied off the server:
#!/bin/bash
STAMP=$(date +%F)
mysqldump --single-transaction shop | gzip > /backups/shop-$STAMP.sql.gz
tar -czf /backups/files-$STAMP.tar.gz /var/www/example
rclone copy /backups remote:server-backups
find /backups -mtime +14 -delete
A backup you have never restored is only a hope. Do a test restore to a staging server at least once.
Prefer a control panel?
For clients who want a UI, I often use CloudPanel, Virtualmin or CyberPanel on top of the same hardening. The panel handles vhosts and SSL, but SSH keys, the firewall and backups are still your job.