Most slow WordPress sites are not slow because of the hardware, but because every request runs PHP and dozens of database queries that could have been cached. In this tutorial you will measure a WordPress site on Ubuntu 24.04, then speed it up layer by layer: PHP OPcache, PHP-FPM process settings, MariaDB, a Redis object cache, an Nginx FastCGI page cache and browser caching for static files. After each change you will check that it actually took effect.
Prerequisites
To follow this tutorial, you will need:
- A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS, and a non-root user with
sudoprivileges. - WordPress running on Nginx, PHP 8.3-FPM and MariaDB, installed in
/var/www/your_domainand served athttps://your_domain. - WP-CLI installed as
/usr/local/bin/wp. The WP-CLI commands below run aswww-datafrom/var/www/your_domain.
Replace your_domain with your own domain in every command and file.
Step 1 - Measuring the baseline
Measure before you change anything, so you know which step helped. curl can report the time to first byte (TTFB), which is dominated by PHP and database work:
for i in 1 2 3 4 5; do curl -so /dev/null -w '%{time_starttransfer}\n' https://your_domain/; done
0.842311
0.795204
0.811930
0.803118
0.790522
Note the typical value. An uncached WordPress page often takes 300 ms to 1 s; a page served from the Nginx cache should take a few milliseconds plus network latency. For the full front-end picture (images, JavaScript, Core Web Vitals), also run the site through PageSpeed Insights at https://pagespeed.web.dev/ and keep the report.
Step 2 - Tuning PHP OPcache
OPcache keeps compiled PHP scripts in memory so PHP does not re-parse WordPress, its plugins and theme on every request. It is enabled by default on Ubuntu, but the default memory size is small for sites with many plugins. Create a configuration file that PHP-FPM loads after the defaults:
sudo nano /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
With revalidate_freq=60, PHP checks for changed files at most once a minute, so plugin updates take effect within 60 seconds. Restart PHP-FPM:
sudo systemctl restart php8.3-fpm
Check the values PHP-FPM uses. The CLI has its own php.ini, so query the FPM binary instead of php:
php-fpm8.3 -i | grep -E 'opcache.memory_consumption|opcache.max_accelerated_files'
opcache.max_accelerated_files => 20000 => 20000
opcache.memory_consumption => 256 => 256
Step 3 - Sizing the PHP-FPM pool
Each PHP-FPM worker handles one request at a time. Too few workers queue requests; too many exhaust memory and push the server into swap, which is far worse. Size the pool from real memory usage.
Load a few pages of the site, then measure the average memory used by a PHP-FPM process:
ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1} END {printf "%.0f MB average over %d processes\n", sum/NR/1024, NR}'
72 MB average over 6 processes
Decide how much RAM PHP can use after leaving room for MariaDB, Redis and the OS. On a 4 GB server, reserving about 1.5 GB for the rest leaves 2.5 GB, and 2,500 MB / 72 MB gives roughly 34 workers. Round down to leave a margin. Edit the default pool:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Find and change these directives:
pm = dynamic
pm.max_children = 30
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
pm.max_requests recycles each worker after 500 requests, which contains slow memory leaks in plugins. Test the configuration and restart:
sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm
NOTICE: configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful
If pm.max_children is too low, PHP-FPM logs a warning. Check for it after a busy period:
sudo grep max_children /var/log/php8.3-fpm.log
No output means the limit was never reached.
Step 4 - Tuning MariaDB
The most important MariaDB setting is the InnoDB buffer pool, which keeps tables and indexes in memory. The default of 128 MB is too small for most sites. Check the size of your data first:
sudo mariadb -e "SELECT table_schema, ROUND(SUM(data_length + index_length)/1024/1024) AS size_mb FROM information_schema.tables GROUP BY table_schema;"
Set the buffer pool to hold the whole WordPress database with some headroom, without exceeding about a quarter of the RAM on a server that also runs PHP. Create a configuration file:
sudo nano /etc/mysql/mariadb.conf.d/60-wordpress.cnf
[mysqld]
innodb_buffer_pool_size = 512M
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mariadb-slow.log
long_query_time = 1
The slow query log records every query that takes longer than one second, which points you at the plugin causing the problem. Restart MariaDB and confirm the value:
sudo systemctl restart mariadb
sudo mariadb -e "SELECT @@innodb_buffer_pool_size/1024/1024 AS buffer_pool_mb;"
+----------------+
| buffer_pool_mb |
+----------------+
| 512.00000000 |
+----------------+
A common WordPress-specific problem is a large set of autoloaded options, which WordPress loads on every request. Check the total size:
sudo -u www-data wp db query "SELECT ROUND(SUM(LENGTH(option_value))/1024) AS autoload_kb FROM $(sudo -u www-data wp db prefix)options WHERE autoload IN ('yes','on','auto','auto-on');"
Values under 800 KB are fine. If it is much larger, list the biggest entries and look for leftovers from plugins you no longer use:
sudo -u www-data wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM $(sudo -u www-data wp db prefix)options WHERE autoload IN ('yes','on','auto','auto-on') ORDER BY bytes DESC LIMIT 10;"
Step 5 - Adding a Redis object cache
WordPress caches query results in memory only for the duration of one request. A persistent object cache stores them in Redis so the next request, including logged-in and WooCommerce requests that cannot be page cached, skips those queries. Install Redis and the PHP extension:
sudo apt install redis-server php8.3-redis
sudo systemctl restart php8.3-fpm
Ubuntu's Redis listens only on 127.0.0.1 by default. Confirm it responds:
redis-cli ping
PONG
Limit Redis memory so it evicts old keys instead of growing without bound. Open the configuration:
sudo nano /etc/redis/redis.conf
Set these two directives (search for them, they are commented out by default):
maxmemory 256mb
maxmemory-policy allkeys-lru
sudo systemctl restart redis-server
Install the Redis Object Cache plugin and enable its drop-in:
cd /var/www/your_domain
sudo -u www-data wp plugin install redis-cache --activate
sudo -u www-data wp redis enable
sudo -u www-data wp redis status
Status: Connected
Drop-in: Valid
Load a few pages and check that WordPress keys appear in Redis:
redis-cli dbsize
A number above zero that grows as you browse means the object cache is working.
Step 6 - Enabling the Nginx FastCGI page cache
A page cache stores the complete HTML of a page, so Nginx can answer anonymous visitors without calling PHP at all. This gives the largest single improvement. Define the cache zone in the http context by creating a file in /etc/nginx/conf.d/:
sudo nano /etc/nginx/conf.d/fastcgi-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;
Create the cache directory and give it to the www-data user that runs the Nginx workers:
sudo mkdir -p /var/cache/nginx/wordpress
sudo chown www-data:www-data /var/cache/nginx/wordpress
Logged-in users, POST requests, the admin area, and carts must never be served from the cache. Open your site's server block:
sudo nano /etc/nginx/sites-available/your_domain
Inside the server block that listens on port 443, add these rules before the location blocks:
set $skip_cache 0;
if ($request_method = POST) {
set $skip_cache 1;
}
if ($query_string != "") {
set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*\.php|/feed/|sitemap(_index)?\.xml|/cart/|/checkout/|/my-account/") {
set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in|woocommerce_items_in_cart") {
set $skip_cache 1;
}
Then replace your PHP location block with one that uses the cache:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache-Status $upstream_cache_status;
}
Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Request the home page twice. The first request fills the cache, the second is served from it:
curl -sI https://your_domain/ | grep -i x-cache-status
curl -sI https://your_domain/ | grep -i x-cache-status
x-cache-status: MISS
x-cache-status: HIT
A logged-in request or a URL with a query string should show BYPASS.
Cached pages stay for up to 60 minutes, so a new post may not appear immediately. To purge the cache when content changes, install the Nginx Helper plugin, choose the Delete local server cache files purge method in Settings > Nginx Helper, and tell it where the cache lives by adding this line to wp-config.php:
define( 'RT_WP_NGINX_HELPER_CACHE_PATH', '/var/cache/nginx/wordpress' );
sudo -u www-data wp plugin install nginx-helper --activate
PHP-FPM and Nginx both run as www-data on Ubuntu, so the plugin can delete the cache files. To clear the whole cache by hand at any time:
sudo rm -rf /var/cache/nginx/wordpress/*
Step 7 - Compression and browser caching for static files
Ubuntu's Nginx enables gzip, but only for HTML. Extend it to CSS, JavaScript and fonts with a file in conf.d. Do not repeat gzip on;, it is already set in nginx.conf and a duplicate stops Nginx from starting:
sudo nano /etc/nginx/conf.d/gzip.conf
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml application/rss+xml image/svg+xml font/ttf font/otf;
Let browsers cache images, CSS, JavaScript and fonts. Add this location to your 443 server block:
location ~* \.(?:css|js|jpg|jpeg|gif|png|webp|avif|svg|ico|woff2?|ttf)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
WordPress adds a ?ver= query string to CSS and JavaScript, so updated files still reach visitors. Reload Nginx and check the headers of a theme file:
sudo nginx -t && sudo systemctl reload nginx
curl -sI -H 'Accept-Encoding: gzip' https://your_domain/wp-includes/css/dist/block-library/style.min.css | grep -iE 'content-encoding|cache-control'
cache-control: max-age=2592000
content-encoding: gzip
Images are usually the largest part of a page. Upload images at the size they are displayed and prefer WebP or AVIF: WordPress accepts both formats (AVIF since version 6.5) when the server's image library supports them, and plugins such as EWWW Image Optimizer can convert existing media. For visitors far from your server, a CDN in front of the site caches static files closer to them.
Step 8 - Measuring the result
Repeat the baseline test from Step 1:
for i in 1 2 3 4 5; do curl -so /dev/null -w '%{time_starttransfer}\n' https://your_domain/; done
0.031207
0.028854
0.029913
0.030466
0.028120
With the page cache serving anonymous visitors, TTFB drops to network latency plus a few milliseconds. Run PageSpeed Insights again and compare it with your first report; any remaining issues will be front-end ones such as render-blocking scripts or oversized images, which you fix in the theme and plugins.
Troubleshooting
X-Cache-Statusnever appears: theadd_headeris in alocationthat did not handle the request, or alocationforindex.phpelsewhere in the server block takes precedence. Make sure only one PHPlocationexists.- Logged-in users see the anonymous version of a page: the cookie rule is missing or misspelled. Check it against the cookies your browser sends in the developer tools.
- 502 Bad Gateway after changing the pool: run
sudo php-fpm8.3 -tandsudo journalctl -u php8.3-fpm -n 50to find the configuration error. - The server starts swapping:
pm.max_childrenor the buffer pool is too large. Check withfree -hand lower the values from Steps 3 and 4.
Conclusion
You measured your WordPress site, tuned OPcache, PHP-FPM and MariaDB, added a Redis object cache for dynamic requests, and put an Nginx FastCGI cache in front of PHP for anonymous visitors. Next, review the slow query log after a few days of traffic, remove plugins that add heavy queries, and consider a CDN if a large part of your audience is far from your server's location.
