webs.me.uk UK web hosting directory

HomeGuides → Self-hosting WordPress on your own VPS: a complete walkthrough

Advanced 25 min read

Self-hosting WordPress on your own VPS: a complete walkthrough

Ubuntu 24.04, nginx, PHP-FPM 8.3, MariaDB, Let's Encrypt, firewall, automatic backups and updates — from a bare £4/month server to a hardened WordPress site.

A £4/month VPS from Hetzner or DigitalOcean will outperform most £10/month shared hosting. The trade is that everything below is now your job: patching, firewalling, backups, and getting up when it breaks. If that sentence worries you, use managed hosting instead — that is a legitimate choice, not a cop-out.

0. Before you start

  • A VPS with at least 2 GB RAM (1 GB works with swap, 2 GB is comfortable). Hetzner CX22, DigitalOcean 2 GB Droplet, or Akamai/Linode 2 GB — pick a London or Falkenstein/Helsinki region for UK visitors.
  • A domain, with an A record pointing at the server's IPv4 and an AAAA record at its IPv6.
  • An SSH key pair on your own machine (ssh-keygen -t ed25519). Never use password login.
  • An SMTP provider for outgoing mail — your VPS cannot reliably send email itself, and most providers block port 25. Use Mailgun, Postmark, Brevo or Amazon SES with the WP Mail SMTP plugin.

1. First login and a non-root user

ssh root@YOUR_SERVER_IP
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
apt update && apt upgrade -y

Now lock SSH down. Edit /etc/ssh/sshd_config (or a file in /etc/ssh/sshd_config.d/):

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
systemctl restart ssh

Open a second terminal and confirm ssh deploy@YOUR_SERVER_IP works before closing the first one. Locking yourself out of a fresh server is a rite of passage best skipped.

2. Firewall

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

3. Install the stack

sudo apt install -y nginx mariadb-server \
  php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring \
  php8.3-xml php8.3-zip php8.3-intl php8.3-imagick php8.3-bcmath \
  certbot python3-certbot-nginx unzip

Secure MariaDB:

sudo mysql_secure_installation

Create the database and a user with a long random password:

sudo mysql -e "CREATE DATABASE wp_site DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'PUT_A_LONG_RANDOM_PASSWORD_HERE';"
sudo mysql -e "GRANT ALL PRIVILEGES ON wp_site.* TO 'wp_user'@'localhost'; FLUSH PRIVILEGES;"

4. Tune PHP

In /etc/php/8.3/fpm/php.ini:

upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 120
expose_php = Off

Enable OPcache in /etc/php/8.3/fpm/conf.d/10-opcache.ini:

opcache.enable=1
opcache.memory_consumption=192
opcache.max_accelerated_files=20000
opcache.revalidate_freq=2
sudo systemctl restart php8.3-fpm

5. Install WordPress with WP-CLI

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar && sudo mv wp-cli.phar /usr/local/bin/wp

sudo mkdir -p /var/www/example.com
sudo chown -R deploy:www-data /var/www/example.com
cd /var/www/example.com

wp core download
wp config create --dbname=wp_site --dbuser=wp_user --dbpass='YOUR_DB_PASSWORD'
wp core install --url=https://example.com --title="My Site" \
  --admin_user=notadmin --admin_email=you@example.com --prompt=admin_password

Note --admin_user=notadmin: never create a user literally called admin.

Set ownership and permissions properly — this is where most self-hosted sites go wrong:

sudo chown -R deploy:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
sudo chmod 640 /var/www/example.com/wp-config.php
sudo chown -R www-data:www-data /var/www/example.com/wp-content/uploads

6. nginx server block

Create /etc/nginx/sites-available/example.com:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/example.com;
    index index.php;

    client_max_body_size 64M;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    # Static assets: cache hard
    location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff2)$ {
        expires 30d;
        access_log off;
        add_header Cache-Control "public";
    }

    # Block the usual probes
    location ~ /\.(?!well-known) { deny all; }
    location = /xmlrpc.php { deny all; }
    location ~* /(?:uploads|files)/.*\.php$ { deny all; }
    location = /wp-config.php { deny all; }
}
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

7. HTTPS with Let's Encrypt

sudo certbot --nginx -d example.com -d www.example.com
sudo systemctl status certbot.timer   # renewal is automatic

Then add security headers inside the HTTPS server block certbot created:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

8. Hardening WordPress itself

Add to wp-config.php, above the "That's all, stop editing" line:

define('DISALLOW_FILE_EDIT', true);   // no plugin/theme editor in admin
define('WP_AUTO_UPDATE_CORE', 'minor');
define('FORCE_SSL_ADMIN', true);
define('WP_DEBUG', false);
define('WP_POST_REVISIONS', 10);
define('EMPTY_TRASH_DAYS', 14);

Regenerate the security salts if you have not already: wp config shuffle-salts.

Install Fail2ban to slow down brute-force attempts:

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

And enable unattended security updates for the OS:

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

9. Backups that actually exist

A backup on the same server is not a backup. Push to object storage — Hetzner Storage Box, Backblaze B2, or Cloudflare R2 — with restic or rclone. A minimal nightly script, /usr/local/bin/wp-backup.sh:

#!/bin/bash
set -euo pipefail
SITE=/var/www/example.com
STAMP=$(date +%F)
TMP=/tmp/backup-$STAMP
mkdir -p "$TMP"

wp --path="$SITE" db export "$TMP/db.sql" --allow-root
tar -czf "$TMP/files.tar.gz" -C "$SITE" wp-content

rclone copy "$TMP" remote:mysite-backups/$STAMP
rm -rf "$TMP"
sudo chmod +x /usr/local/bin/wp-backup.sh
sudo crontab -e
# 0 3 * * * /usr/local/bin/wp-backup.sh >> /var/log/wp-backup.log 2>&1

Then restore one. An untested backup is a rumour. Spin up a second cheap VPS once a quarter and prove you can rebuild from the archive.

10. Caching and the finishing touches

  • Install a page cache plugin — LiteSpeed Cache (if you run OpenLiteSpeed instead of nginx), W3 Total Cache, or WP Super Cache. On nginx, fastcgi_cache is faster still if you are comfortable configuring it.
  • Add Redis for object caching: sudo apt install redis-server php8.3-redis, then the Redis Object Cache plugin.
  • Put Cloudflare (free tier) in front for DDoS protection, edge caching and a second layer of TLS.
  • Set up uptime monitoring — UptimeRobot's free tier or Better Stack — so you find out before your customers do.

Ongoing running costs

ItemTypical cost
VPS (2 GB, London or Falkenstein)£4–£10/mo
Domain (.co.uk)£8–£12/yr
Backup storage (object storage, 50 GB)£0.25–£1/mo
Transactional email (SMTP relay)Free tier usually sufficient
Mailboxes (if you need name@yourdomain)£1–£5/mailbox/mo
Total~£6–£15/month plus your time

Be honest about the last line. Self-hosting costs perhaps two hours of setup and fifteen minutes a month if nothing breaks — and a stressful evening when something does. Managed hosting is the same money for many small sites once you price your time at anything above zero.