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_cacheis 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
| Item | Typical 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.