Every system I deliver ends up on a VPS. Not shared hosting — a VPS with root access, a real IP, and full control over the stack. That control comes with responsibility: I'm the one who configured it, so I'm the one who needs to do it right.
Below is the full checklist I work through on every new server, roughly in order.
1. Initial server setup
Create a non-root user immediately. Add it to the sudo group.
Copy your SSH public key to the new user's authorized_keys.
Then disable root SSH login and password authentication entirely.
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Enable UFW. Allow only SSH (port 22), HTTP (80), and HTTPS (443).
Block everything else. Run ufw enable and verify with ufw status.
2. Install the LAMP stack
Apache, MySQL, PHP, and the required PHP extensions. I use Ubuntu LTS on all client servers for the long support window and predictable package availability.
apt install -y apache2 mysql-server php8.2 \
php8.2-mysql php8.2-curl php8.2-mbstring \
php8.2-xml php8.2-zip php8.2-gd php8.2-intl
a2enmod rewrite ssl headers
systemctl restart apache2
3. SSL with Let's Encrypt
Install Certbot, obtain a certificate, and enable automatic renewal. Let's Encrypt certificates expire every 90 days — the renewal cron handles this automatically, but verify it runs.
apt install -y certbot python3-certbot-apache
certbot --apache -d example.com -d www.example.com
systemctl enable certbot.timer
4. Apache VirtualHost hardening
Force HTTPS redirect. Add security headers. Disable directory listing.
Restrict .htaccess to the webroot only.
<VirtualHost *:443>
ServerName example.com
DocumentRoot /var/www/app/public
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'"
<Directory /var/www/app/public>
Options -Indexes
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
# HTTP → HTTPS redirect
<VirtualHost *:80>
ServerName example.com
Redirect permanent / https://example.com/
</VirtualHost>
5. MySQL hardening
Run mysql_secure_installation — this disables the anonymous user,
removes the test database, and sets a root password. Then create a dedicated database
user for each application with only the privileges it needs.
CREATE DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'strong_password_here';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
Never use the root MySQL user in application connection strings. If the application is ever compromised, a limited user limits the damage.
6. Automated daily backups
A cron job that dumps the database, compresses it, and copies it to a remote location. I use rclone to sync to a cloud storage bucket. Retention: 30 days of daily backups, 12 months of monthly snapshots.
0 2 * * * www-data mysqldump -u app_user -p'password' app_db \
| gzip > /backups/app_db_$(date +\%Y\%m\%d).sql.gz \
&& rclone copy /backups/ remote:backups/ --min-age 1s
7. Zero-downtime deployment
I use a simple Git pull + symlink swap strategy. The webroot is a symlink pointing to the current release directory. A deploy script pulls the new code into a timestamped directory, runs migrations, then atomically swaps the symlink. Apache keeps serving the old release until the swap completes — zero downtime.
#!/bin/bash
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d_%H%M%S)"
git clone git@github.com:zvikko/app.git "$RELEASE_DIR"
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader
php migrate.php
ln -sfn "$RELEASE_DIR" /var/www/app/current
echo "Deployed: $RELEASE_DIR"
8. Monitoring
I install fail2ban to automatically ban IPs that fail SSH or login
attempts repeatedly. I set up uptime monitoring with a free tier tool (UptimeRobot
or Better Uptime) that pings the application every 5 minutes and emails me if it goes down.
A server you don't monitor is a server you'll find out about when a client calls you on a Sunday morning.
This checklist takes about two hours on a fresh VPS. The two hours you spend now save days of incident response later.