Skip to content
All posts

1 October 202610 min read

How to Speed Up WordPress on a VPS

Measure first, then tune PHP-FPM, size OPcache, add a Redis object cache, Nginx FastCGI caching and HTTP/2, and check after each step that it actually helped.

How to Speed Up WordPress on a VPS

Moving WordPress to a VPS gives you full control, and you'd expect it to be faster right away. Often it isn't: a default PHP-FPM pool, an undersized OPcache and no caching layer can leave it feeling slower than the shared hosting you just left. This is the order I work in to speed up WordPress on a VPS: measure first, then tune PHP-FPM, OPcache, a Redis object cache, Nginx page caching and HTTP/2 or HTTP/3, checking after each change that it actually helped.

The examples assume Ubuntu 24.04 with PHP 8.3. Its stock Nginx 1.24 is too old for two things further down: the http2 on; directive (1.25.1+) and HTTP/3 (1.25.0+). On 1.24, use listen 443 ssl http2; and skip the QUIC lines, or install Nginx from the nginx.org repository. Debian 13 ships PHP 8.4 and Nginx 1.26, so there you swap 8.3 for 8.4 in paths and service names.

Measure WordPress performance before you change anything

When you set out to speed up WordPress on a VPS, tuning without a baseline is guesswork. You need three views: single-request time to first byte, behaviour under concurrent load, and what WordPress does inside a request.

Time to first byte with curl

Create a small format file. Keep the literal \n sequences: curl ignores real line breaks in a -w file.

bash
cat > curl-format.txt <<'FMT'
    dns: %{time_namelookup}s\n
connect: %{time_connect}s\n
    tls: %{time_appconnect}s\n
   ttfb: %{time_starttransfer}s\n
  total: %{time_total}s\n
FMT

curl -o /dev/null -s -w "@curl-format.txt" https://example.com/

A single request tells you very little, so take a small sample and look at the median:

bash
for i in $(seq 20); do
  curl -o /dev/null -s -w "%{time_starttransfer}\n" https://example.com/sample-page/
done | sort -n | awk '{a[NR]=$1} END {print "median TTFB:", a[int((NR+1)/2)]}'

Test a page that isn't served from any cache yet (add a unique query string such as ?nocache=$RANDOM once page caching is on), plus a cached page. They answer different questions.

Load testing with k6

k6 shows how the stack behaves when several visitors arrive at once, and that's where PHP-FPM settings matter. Run it against a staging copy, or at least outside peak hours, and never against a server you don't own.

JavaScript
// load-test.js
import http from 'k6/http'
import { check, sleep } from 'k6'

export const options = {
  stages: [
    { duration: '30s', target: 10 },
    { duration: '1m', target: 10 },
    { duration: '15s', target: 0 },
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<800'], // example target, pick your own
  },
}

const BASE = __ENV.BASE_URL || 'https://staging.example.com'
const PATHS = ['/', '/blog/', '/sample-page/']

export default function () {
  const path = PATHS[Math.floor(Math.random() * PATHS.length)]
  const res = http.get(`${BASE}${path}`)
  check(res, { 'status is 200': (r) => r.status === 200 })
  sleep(1)
}

Run it with k6 run -e BASE_URL=https://staging.example.com load-test.js and note the http_req_duration p95 and the failure rate.

Query Monitor for what happens inside WordPress

Install the Query Monitor plugin on staging. For any page it shows database queries (including slow and duplicate ones), which plugin or theme triggered them, hooks, and outgoing HTTP API calls. A plugin calling an external API on every page load won't be fixed by server tuning, and this is where you'll find it.

Record your baseline

Fill in this table after each step, with the same pages, the same k6 script and the same time of day:

Step

Median TTFB (uncached)

Median TTFB (cached)

k6 p95

Baseline

…

…

…

PHP-FPM pool tuned

…

…

…

OPcache sized

…

…

…

Redis object cache

…

…

…

Nginx FastCGI cache

…

…

…

HTTP/2 + HTTP/3

…

…

…

PHP-FPM pool tuning for WordPress

Every uncached request occupies one PHP-FPM worker. Too few workers and requests queue; too many and the VPS swaps, which is far worse.

First, measure how much memory a worker actually uses under realistic traffic:

bash
ps -o rss= -C php-fpm8.3 | awk '{s+=$1; n++} END {printf "%d processes, avg %.0f MB\n", n, s/n/1024}'

Then do the arithmetic: take the RAM you can give PHP (total minus MariaDB/MySQL, Redis, Nginx and a safety margin) and divide it by the average worker size. Say you can give PHP 1.5 GB and a worker averages 120 MB: that's about 12 workers, and that's your pm.max_children. These figures only illustrate the arithmetic; use your own measurements.

Give WordPress its own pool instead of editing www.conf:

ini
; /etc/php/8.3/fpm/pool.d/wordpress.conf
[wordpress]
user = wordpress
group = wordpress
listen = /run/php/php8.3-fpm-wordpress.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 12
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500

pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php8.3-fpm-wordpress-slow.log
request_terminate_timeout = 60s

php_admin_value[memory_limit] = 256M

A few notes:

  • pm = dynamic suits most single-site VPSes. ondemand saves RAM on a server hosting many quiet sites, at the cost of a slower first request after idle periods.

  • pm.max_requests recycles workers periodically, which contains slow memory leaks in plugins.

  • The slowlog is the most useful line in the file. It records a PHP stack trace for any request slower than 5 seconds, so you can see exactly which plugin function is hanging.

  • If the error log shows server reached pm.max_children setting, you're queueing. Raise the limit only if RAM allows it; otherwise the fix is caching.

Check the config with php-fpm8.3 -t before running systemctl reload php8.3-fpm.

Enable and size OPcache

OPcache keeps compiled PHP bytecode in shared memory so core, theme and plugins aren't re-parsed on every request. It's usually on by default, but sized small for a site with dozens of plugins.

ini
; /etc/php/8.3/fpm/conf.d/99-opcache-tuning.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60

revalidate_freq=60 means PHP checks for changed files at most once a minute. If you deploy with a script, you can set validate_timestamps=0 and reload PHP-FPM on each deploy for a small extra gain. Just remember that plugin updates made from wp-admin won't take effect until that reload.

To see whether OPcache is big enough, check its status through PHP-FPM, not the CLI, because the CLI has its own separate cache. Put this in a temporary file protected by IP or basic auth, open it once, then delete it:

PHP
<?php
// opcache-check.php: delete after use
$s = opcache_get_status(false);
if ($s === false) {
    exit("OPcache is disabled\n");
}
$m = $s['memory_usage'];
$st = $s['opcache_statistics'];
printf("Used: %.1f MB, free: %.1f MB, wasted: %.1f%%\n",
    $m['used_memory'] / 1048576, $m['free_memory'] / 1048576, $m['current_wasted_percentage']);
printf("Cached scripts: %d, hits: %d, misses: %d, OOM restarts: %d\n",
    $st['num_cached_scripts'], $st['hits'], $st['misses'], $st['oom_restarts']);

If free memory is close to zero or oom_restarts keeps growing, increase memory_consumption.

Add a Redis object cache

WordPress has an object cache API, but without a persistent backend it only lasts for a single request. A persistent object cache stores the results of repeated database lookups (options, post meta, transients) in Redis between requests. The biggest wins are on uncached, logged-in and WooCommerce pages, exactly the requests a page cache can't help with.

bash
sudo apt install redis-server php8.3-redis

Cap Redis memory so it can never starve PHP or MariaDB. In /etc/redis/redis.conf:

ini
maxmemory 256mb
maxmemory-policy allkeys-lru

The Debian and Ubuntu packages bind Redis to localhost with protected mode on. Keep it that way: a Redis instance reachable from the internet without a password is a classic way to lose a server.

Install the Redis Object Cache plugin, then add this to wp-config.php above the "stop editing" line:

PHP
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_PREFIX', 'example_com:');

Older tutorials use WP_CACHE_KEY_SALT; I stick to WP_REDIS_PREFIX, which is what the Redis Object Cache plugin documents. A prefix matters as soon as more than one site shares the same Redis instance.

Enable it with wp redis enable and check with wp redis status. Then reload a few pages and look at Query Monitor: the number of database queries on repeat visits should drop. That's your proof it works.

Page caching with Nginx FastCGI cache

For anonymous visitors, the fastest PHP request is the one that never happens. Nginx's FastCGI cache stores the full HTML response and serves it without touching PHP. Put the shared parts in the http context:

nginx
# /etc/nginx/conf.d/wordpress-cache.conf
fastcgi_cache_path /var/cache/nginx/wordpress levels=1:2 keys_zone=WORDPRESS:100m
                   inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_lock on;

map $request_method $wp_skip_method { default 1; GET 0; HEAD 0; }
map $query_string   $wp_skip_query  { default 1; "" 0; }
map $request_uri $wp_skip_uri {
    default 0;
    ~*^/wp-admin/ 1;
    ~*/wp-login\.php 1;
    ~*/xmlrpc\.php 1;
    ~*^/wp-json/ 1;
    ~*/(cart|checkout|my-account)/ 1;
}
map $http_cookie $wp_skip_cookie {
    default 0;
    ~*wordpress_logged_in_ 1;
    ~*comment_author_ 1;
    ~*wp-postpass_ 1;
    ~*woocommerce_items_in_cart 1;
}
map "$wp_skip_method$wp_skip_query$wp_skip_uri$wp_skip_cookie" $wp_skip_cache {
    default 1;
    "0000"  0;
}

Skipping all query strings is deliberately conservative (utm_source links bypass the cache too); refine it later. WooCommerce page slugs follow the site language, so on a Polish shop the cart, checkout and account pages are usually koszyk, zamowienie and moje-konto. Adjust the cart|checkout|my-account line to match your shop.

Cached pages don't know you just published a post, so either keep fastcgi_cache_valid short (minutes) or install a purge plugin such as Nginx Helper and configure it to match your setup.

Enable HTTP/2 and HTTP/3 in Nginx

Since Nginx 1.25.1, HTTP/2 is switched on with its own http2 on; directive. The old listen 443 ssl http2; syntax is deprecated. HTTP/3 runs over QUIC on UDP port 443. It needs Nginx 1.25.0 or newer built with --with-http_v3_module (check nginx -V), and the Nginx docs still label the module experimental. Here's the server block with the cache wired in:

nginx
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
    http2 on;

    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    root /var/www/wordpress;
    index index.php;

    add_header Alt-Svc 'h3=":443"; ma=86400' always;

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

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

        fastcgi_cache WORDPRESS;
        fastcgi_cache_valid 200 301 302 10m;
        fastcgi_cache_bypass $wp_skip_cache;
        fastcgi_no_cache $wp_skip_cache;

        # add_header here replaces server-level headers, so repeat Alt-Svc
        add_header X-Cache-Status $upstream_cache_status always;
        add_header Alt-Svc 'h3=":443"; ma=86400' always;
    }
}

Three things to watch:

  1. add_header inheritance. A location with its own add_header drops all headers set at server level. Without the repeated Alt-Svc line, PHP pages would never advertise HTTP/3.

  2. reuseport can appear only once per address and port across all server blocks. Put it on your default server.

  3. The firewall. Open UDP 443 as well (sudo ufw allow 443/udp), or browsers will quietly stay on HTTP/2.

Test with sudo nginx -t, reload, then check with curl -sI --http2 https://example.com/ and look for alt-svc and x-cache-status. Request a page twice: the second response should say HIT.

Other ways to speed up WordPress on a VPS

  • Real cron. Add define('DISABLE_WP_CRON', true); and run wp cron event run --due-now from system cron every few minutes, so visitors don't trigger scheduled tasks.

  • Autoloaded options. Plugins you removed long ago may still leave large autoloaded rows in wp_options. Site Health warns about this, and Query Monitor helps you trace them.

If you run a WooCommerce shop and would rather have someone go through this for you, see WooCommerce shops and websites.

FAQ

What is the best PHP-FPM pm.max_children value for WordPress?

There's no universal number. Divide the RAM you can give PHP by the average worker memory measured with ps, and leave headroom for the database and Redis.

Do I need Redis if I already use a page cache?

A page cache only helps anonymous visitors. Redis speeds up everything the page cache skips: wp-admin, logged-in users, carts and REST requests.

Is HTTP/3 worth enabling for WordPress?

It can help visitors on high-latency or lossy mobile connections. It's cheap to add if your Nginx build supports it, but the module is still marked experimental, so keep HTTP/2 as the fallback and measure.

Why don't my OPcache stats change when I run php -i?

The CLI uses its own OPcache instance. Check opcache_get_status() through a PHP-FPM request instead.

How do I know if the FastCGI cache is working?

Look for the X-Cache-Status header. The first request shows MISS, repeat requests from a logged-out browser show HIT, and logged-in requests show BYPASS.

Write