WordPress on a UK VPS: a speed and security checklist

You moved the agency's WordPress site off shared hosting last weekend. It runs on a 4 GB VPS now, and the box is yours to run. No control panel is tuning PHP for you, and no support team is watching the logs. This is the checklist for making that site fast and hard to break into, with the commands and the numbers behind each step. Every figure here comes from a source you can open or a measurement you can run.
Size PHP-FPM from your RAM, not a copied config
The first thing people paste from a blog is pm.max_children = 50. On a 4 GB box that number will swap you to death under load. The correct value depends on two things: how much RAM you can spare for PHP, and how big one worker gets in practice.
Measure the worker first. Warm the site, then read the resident set size:
ps -ylC php-fpm --sort:rss
Look at the RSS column. A WordPress worker with the usual plugins sits somewhere between 40 and 100 MB, per Kinamo's sizing notes. Say yours averages 80 MB.
Now budget the RAM. On a 4 GB VPS running MariaDB and nginx, subtract the OS, the database and the web server first, then leave 10–20% free. If MariaDB takes 1 GB, nginx and the OS take roughly 500 MB, and you keep 500 MB in reserve, that leaves about 2 GB for PHP. The rest is division:
pm.max_children = RAM for PHP / average worker size
= 2000 MB / 80 MB
≈ 25
Set the pool to match, in dynamic mode:
pm = dynamic
pm.max_children = 25
pm.start_servers = 6
pm.min_spare_servers = 6
pm.max_spare_servers = 18
start_servers and min_spare land near 25% of the maximum, max_spare near 75%. Those ratios come from the common tuning guides and they keep a few workers warm without hoarding memory. If the site is busy and steady, pm = static with pm.max_children = 25 removes the fork and reap churn, at the cost of holding all 25 workers whether traffic needs them or not. Watch /var/log/php-fpm.log for the line server reached pm.max_children. If it never appears, your setting is fine. If it shows up under normal traffic, you need more RAM before more children.
OPcache and a Redis object cache: the settings that move the needle
OPcache stores compiled PHP bytecode so every request stops recompiling the same files. It ships with PHP, but the defaults are sized for a small app. A WordPress install with a theme and a dozen plugins can hold 5,000 or more PHP files. The PHP manual lists the defaults as opcache.memory_consumption 128 MB and opcache.max_accelerated_files 10000. A production pool wants headroom:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
The interesting knob is validate_timestamps. At the default 1, OPcache checks each file's mtime every revalidate_freq seconds. Set it to 0 and it never checks, which is faster, but a deploy then ships nothing until you run opcache_reset() or reload PHP-FPM. If you deploy through git and can add a cache reset to the pipeline, validate_timestamps=0 is worth it. If people edit files over SFTP, leave it at 1, or you will spend an afternoon wondering why your change is not live.
OPcache caches code. Redis caches the database work: WordPress's object cache turns repeated queries into memory reads. Install the redis-server package and the Redis Object Cache plugin, then enable it. The setting that bites people is eviction. Redis defaults to maxmemory-policy noeviction, which means once it fills up it refuses new writes. For an object cache that is the worst possible behaviour: every wp_cache_set() starts failing and PHP falls back to MySQL exactly when the box is already busy. Configure it as a pure cache in /etc/redis/redis.conf:
maxmemory 256mb
maxmemory-policy allkeys-lru
allkeys-lru evicts the least recently used key when memory is full, which is what you want from a cache. Confirm it took:
redis-cli config get maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
A full-page cache, and the pages that must never touch it
Object cache saves database work. A full-page cache skips PHP entirely for anonymous visitors: nginx's FastCGI cache stores the rendered HTML and serves it without waking PHP-FPM. The danger is caching the wrong response. Cache a logged-in admin's page once and every visitor gets the toolbar. Cache a WooCommerce cart and the next shopper inherits the last one's basket.
So bypass the cache for anything personal or stateful. That means POST requests, any URL with a query string, the admin and login paths, and any request carrying a WordPress or WooCommerce cookie. The exclusion list below follows SpinupWP's reference nginx config:
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/wp-json/|/xmlrpc.php|wp-.*.php|/feed/|sitemap") {
set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in|woocommerce_items_in_cart|edd_items_in_cart") {
set $skip_cache 1;
}
Then read $skip_cache in the PHP location with fastcgi_cache_bypass and fastcgi_no_cache. For a store, add /cart/, /checkout/ and /my-account/ to the bypass regex so they never cache. Add a debug header so you can see what is happening:
add_header X-Cache $upstream_cache_status;
Reload, then curl your homepage twice. The first response shows X-Cache: MISS, the second X-Cache: HIT. Curl /wp-admin/ and it should read BYPASS every time. If wp-admin ever says HIT, stop and fix the cookie rule before you log in again.
TLS and HTTP/2 without the deprecation warning
Get a certificate with certbot, then set the protocol floor to TLS 1.2 and 1.3. This is one place to copy from a source: the Mozilla SSL Configuration Generator's Intermediate profile is the sane default for a public site, and TLS 1.3 needs nginx 1.13+ and OpenSSL 1.1.1+.
ssl_protocols TLSv1.2 TLSv1.3;
HTTP/2 is where a lot of copied configs now throw a warning. On nginx 1.25.1 and later the old listen 443 ssl http2; form is deprecated, and nginx -t tells you so:
nginx: [warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead
Split it out:
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
On an older nginx the deprecated form still works, so check your version with nginx -v before you touch it.
The security layer that is actually load-bearing
Most WordPress hardening plugins add noise. Four things carry the weight. None of them is a plugin.
Unattended security upgrades
An unpatched kernel or OpenSSL is how boxes get owned. Ubuntu ships unattended-upgrades. Turn it on:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
It applies security updates once a day, configured in /etc/apt/apt.conf.d/50unattended-upgrades. Set an email address in that file so you hear about anything that needs a reboot. This does not touch WordPress core or plugins; keep those on WordPress's own auto-updates or your deploy pipeline.
fail2ban on wp-login
SSH brute force is handled by the standard sshd jail, covered in our VPS firewall and fail2ban guide. WordPress login is a separate front door. Add a filter at /etc/fail2ban/filter.d/wordpress.conf:
[Definition]
failregex = ^<HOST> .*"(GET|POST) /(wp-login\.php|xmlrpc\.php)
and a jail that watches the nginx access log:
[wordpress]
enabled = true
filter = wordpress
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 600
bantime = 3600
Five hits on wp-login.php or xmlrpc.php within ten minutes earns a one-hour ban. This filter matches every request to those files, including your own, so it bans on request volume rather than on failed passwords. That is fine for a login page nobody but you should be hitting. If your team logs in from one office IP, whitelist it with ignoreip.
Kill the file editor
WordPress ships a theme and plugin editor in the admin. A stolen admin password plus that editor equals a shell on your server. Turn it off in wp-config.php:
define('DISALLOW_FILE_EDIT', true);
The stricter DISALLOW_FILE_MODS also blocks plugin and theme installs and updates from the dashboard. That is right for a site you deploy from git, and wrong for a client who updates their own plugins. Pick per site.
Backups that leave the box
A backup on the same VPS survives a fat-fingered DROP TABLE. It does not survive the disk failing or the box being wiped after a compromise. Push a nightly database and uploads dump to object storage or another host. The test that matters is the restore: pull last night's dump to a scratch VPS and bring the site up. A backup you have never restored is a guess.
Keep the personal data lawful, and stop leaking visitor IPs
A WooCommerce store or a contact form holds personal data: names, addresses, order histories, IP addresses. Under UK GDPR you are the controller, and where that data sits is yours to document. Running the VPS in the UK keeps orders and form entries in-country and makes the residency question a short answer instead of a chapter about transfer mechanisms. Our UK data residency guide covers what that means for the paperwork, and a Coventry VPS keeps the data on this side of the Channel.
The subtler leak is in the page itself. If your theme loads fonts from Google's CDN, every visitor's browser sends their IP address to Google before a word renders. In January 2022 the Regional Court of Munich (Az. 3 O 17493/20) ruled that exactly this breached the GDPR and awarded the visitor €100, on the reasoning that an IP address is personal data and Google Fonts can be served locally with no loss of function. It is one junior court and it is not binding in the UK, so do not panic. But an IP address is treated as personal data under UK GDPR too, following the CJEU's Breyer decision, and the fix costs half an hour.
Self-host the fonts. Download the woff2 files, serve them from your own domain, and drop the fonts.googleapis.com link. Do the same with analytics: a self-hosted install, or a log-based analyser, keeps visitor IPs on your server instead of shipping them to a third party you then have to name in your privacy notice. There is a speed dividend too. A self-hosted font removes a third-party DNS lookup and TLS handshake from the critical render path.
Where to start
If you do this in one sitting, order it by blast radius. Turn on unattended-upgrades and fail2ban first, because an unpatched, brute-forced box makes everything else moot. Size PHP-FPM and add OPcache next, since that is what stops the site falling over under load. Redis and the page cache are the speed win after that. Self-host the fonts and confirm a backup restores before you call it done. None of these needs a plugin, and none of it is guesswork.
Sources
- PHP Manual: OPcache configuration directives (unknown)
- Determining the correct number of child processes for PHP-FPM (Kinamo) (unknown)
- SpinupWP wordpress-nginx: fastcgi-cache.conf bypass rules (unknown)
- Redis documentation: Key eviction and maxmemory-policy (unknown)
- Ubuntu Server documentation: Automatic updates (unattended-upgrades) (unknown)
- nginx documentation: ngx_http_v2_module (http2 directive) (unknown)
- Mozilla SSL Configuration Generator (unknown)
- German Court Fines Website Owner for Using Google-Hosted Fonts (WP Tavern) (2022-01)
- Google Fonts, an IP address, and the GDPR (decoded.legal) (2022-02)
Frequently asked questions
How do I calculate pm.max_children for PHP-FPM?
Divide the RAM you can spare for PHP by the size of one worker. Measure the worker with ps -ylC php-fpm --sort:rss; a WordPress process runs about 40 to 100 MB. On a 4 GB box with roughly 2 GB left for PHP after MariaDB, nginx and the OS, and 80 MB workers, that is around 25 children. Do not paste a number from a blog.
Why did my Redis object cache stop working under load?
Almost always the eviction policy. Redis defaults to maxmemory-policy noeviction, so once it fills up it refuses new writes, every wp_cache_set() fails and WordPress falls back to MySQL under load. Set maxmemory to a fixed size and maxmemory-policy to allkeys-lru in /etc/redis/redis.conf, then confirm with redis-cli config get maxmemory-policy.
What must never be cached by an nginx FastCGI page cache?
POST requests, any URL with a query string, /wp-admin/, wp-login.php, /wp-json/, and any request carrying a WordPress or WooCommerce cookie. For a store also bypass /cart/, /checkout/ and /my-account/. Caching a logged-in or cart page and serving it to the next visitor leaks the admin toolbar or someone else's basket.
Do I have to self-host Google Fonts under UK GDPR?
It is not strictly mandated by a UK ruling, but it is the safe move. Loading fonts from Google's CDN sends every visitor's IP to Google, and an IP is treated as personal data under UK GDPR following Breyer. A 2022 Munich court fined an operator €100 for exactly this. Self-hosting the woff2 files removes the transfer and also cuts a third-party lookup from your render path.
Why does nginx warn about the http2 directive being deprecated?
On nginx 1.25.1 and later the http2 parameter of the listen directive is deprecated. Replace listen 443 ssl http2; with listen 443 ssl; on its own line plus a separate http2 on; directive in the server block. On older nginx the old form still works, so check nginx -v first.

