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.

/etc/ssh/sshd_config
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.

bash
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.

bash
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.

/etc/apache2/sites-available/app.conf
<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.

mysql
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.

/etc/cron.d/db-backup
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.

deploy.sh
#!/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.