Speed Up WordPress with nginx, Redis and OPcache: Practical Guide
A slow WordPress site on a VPS is almost always one that runs PHP and hits the database on every single visit. The fix is to stack three caches: nginx's page cache (fastcgi_cache) for anonymous visitors, Redis as an object cache for whatever stays dynamic, and OPcache so PHP isn't recompiled on every request. After that you tidy up the PHP version, images and database, and you measure the result with TTFB.
This guide gives you working configs for Ubuntu 24.04 with nginx and PHP-FPM, the commands to check them, and at the end, how Koapanel applies the same setup out of the box.
Where the time goes
Without caching, every page view goes like this: nginx hands the request to PHP-FPM, WordPress loads core, the theme and every plugin, runs dozens (sometimes hundreds) of queries against MariaDB and builds the HTML. On a plugin-heavy site that easily adds up to a few hundred milliseconds of CPU per page, and under load you run out of PHP workers and requests start queueing.
Each cache layer removes part of that work:
| Layer | What it skips | Who benefits |
|---|---|---|
| Page cache (nginx) | PHP and the database entirely | Logged-out visitors |
| Object cache (Redis) | Many repeated queries | Everyone, including admin and cart |
| OPcache | Reading and compiling PHP files | Every PHP request |
1. Page caching with nginx fastcgi_cache
This is the layer with the biggest payoff: a ready-made page is served by nginx straight from a file, and PHP never runs. You don't need a caching plugin for this; nginx does it with its fastcgi module.
First define where the cache lives, in the http context. On Ubuntu, files in /etc/nginx/conf.d/ are already included inside http, so create /etc/nginx/conf.d/fastcgi-cache.conf:
fastcgi_cache_path /var/cache/nginx/wordpress levels=1:2 keys_zone=WORDPRESS:100m max_size=1g inactive=60m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
Then, in the site's server block, decide what must not be cached and switch the cache on in the PHP location:
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(_index)?\.xml|/cart/|/checkout/|/my-account/") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in|woocommerce_items_in_cart|woocommerce_cart_hash") { set $skip_cache 1; }
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 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
add_header X-Cache $upstream_cache_status;
}
Check the syntax, reload, and look at the X-Cache header: the first request returns MISS, the second HIT.
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://www.example.com/ | grep -i x-cache
curl -sI https://www.example.com/ | grep -i x-cache
How long should pages stay cached?
Every page cache has the same weak spot: invalidation. You publish a post and the cached homepage still shows the old list. Open source nginx has no built-in command to purge a single URL, so you have two options:
- a short lifetime (30 seconds to a few minutes, often called microcaching): content refreshes on its own, and under load nearly every request is still served from cache;
- a long lifetime plus a manual purge (or a plugin that deletes the cache files) whenever you publish.
To wipe the whole cache by hand:
sudo find /var/cache/nginx/wordpress -type f -delete
For most sites the short lifetime is the safer choice: stale content never lingers for long and you don't depend on a plugin.
2. Object caching with Redis
The page cache does nothing for logged-in users, shoppers with a cart or wp-admin. That's where Redis comes in: WordPress keeps query results and options in memory instead of asking MariaDB every time.
sudo apt install redis-server php8.3-redis
sudo systemctl restart php8.3-fpm
Cap Redis memory and let it evict the least used keys when it's full, in /etc/redis/redis.conf:
maxmemory 256mb
maxmemory-policy allkeys-lru
A cache doesn't need to persist to disk, so you can turn snapshots off by commenting out the save lines or setting save "". Then run sudo systemctl restart redis-server.
In wp-config.php, above the "That's all, stop editing!" line, tell WordPress where Redis is and give each site its own prefix so sites don't overwrite each other's keys:
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_PREFIX', 'example_com:' );
Install and enable the Redis Object Cache plugin with WP-CLI, running as the site's user (here www-data):
sudo -u www-data wp --path=/var/www/example.com plugin install redis-cache --activate
sudo -u www-data wp --path=/var/www/example.com redis enable
sudo -u www-data wp --path=/var/www/example.com redis status
A security note: one shared Redis for sites owned by different customers lets any site read the others' keys. On a multi-tenant server, run one instance per site, reachable only over a local socket.
3. OPcache
OPcache keeps compiled PHP code in memory. It's installed and enabled on Ubuntu by default, but the defaults are small for a WordPress site with lots of plugins. Create /etc/php/8.3/fpm/conf.d/99-opcache.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
sudo systemctl restart php8.3-fpm
With revalidate_freq=60, PHP checks for changed files at most once a minute, so a file you edit by hand may take up to a minute to show up. PHP 8's JIT mainly helps CPU-heavy number crunching; WordPress spends most of its time waiting on the database and network, so it usually makes little difference.
4. PHP version and PHP-FPM
Newer PHP releases are generally faster, and old ones stop getting security fixes. WordPress recommends PHP 8.3 or later and MariaDB 10.11 or later. According to PHP's official schedule, 8.2 only gets security fixes until 31 December 2026. Ubuntu 24.04 ships PHP 8.3; 8.4 and 8.5 come from the ondrej/php archive. Test on a copy of the site before switching: an older plugin can throw errors.
Look at the PHP-FPM pool too (/etc/php/8.3/fpm/pool.d/www.conf): size pm.max_children as free RAM divided by the average memory of one PHP worker (often 60-100 MB for WordPress). Too low and requests queue; too high and the server starts swapping.
5. Images and static files
Images are usually the heaviest part of a page and affect Core Web Vitals more than TTFB does.
- Upload images at the size you display them and use modern formats: WordPress supports WebP and, since 6.5, AVIF.
- Lazy loading (
loading="lazy") is already added automatically by WordPress for images below the fold. - Let browsers cache static files:
location ~* \.(?:css|js|jpg|jpeg|gif|png|webp|avif|svg|ico|woff2)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
- If your visitors are far from the server, a CDN in front of the site shortens the trip for images, CSS and JavaScript.
6. Database
- Autoloaded options: uninstalled plugins often leave data in
wp_optionsthat gets loaded on every page. Check how big it is:
sudo -u www-data wp --path=/var/www/example.com option list --autoload=on --format=total_bytes
- Revisions: cap them with
define( 'WP_POST_REVISIONS', 10 );inwp-config.php. - Expired transients:
wp transient delete --expired. - WP-Cron: by default it runs during page views. Disable it with
define( 'DISABLE_WP_CRON', true );and run it from the system cron every 5 minutes instead. - MariaDB:
innodb_buffer_pool_sizein/etc/mysql/mariadb.conf.d/50-server.cnfshould hold your hot data; on a VPS that also runs PHP, give it a sensible share of RAM, not all of it. - To find slow queries, use the Query Monitor plugin or MariaDB's slow query log.
How to measure: TTFB and PageSpeed
Measure before and after, always from a browser or machine that is not logged in to wp-admin (logged-in users bypass the page cache).
TTFB (time to first byte) tells you how long the server takes to answer. With curl:
for i in 1 2 3 4 5; do curl -o /dev/null -s -w '%{time_starttransfer}\n' https://www.example.com/; done
Look at the median, not a single run. web.dev considers 0.8 seconds or less a good TTFB; with page caching on and a server close to you, you'll normally land well below that.
PageSpeed Insights measures the full experience: LCP, CLS and INP. Keep lab data (a simulated run) separate from field data from real Chrome users, which only appears for sites with enough traffic. If TTFB is good but the score is still poor, the problem is usually front-end: images, fonts or third-party JavaScript.
How Koapanel does it
If you'd rather not maintain these files by hand on every site, Koapanel applies the same setup from its WordPress section (docs):
- nginx page cache preconfigured, with a default lifetime of 60 seconds. It never caches POST requests, URLs with query strings, logged-in users, cart, checkout and account pages (WooCommerce included), the admin area, the REST API, feeds or sitemaps. Purge all clears the site's cache, and the hit rate is calculated from the last 24 hours of the access log.
- A private Redis per site, reachable only over a local socket (no network port), with capped memory (64 MB by default) and no disk persistence; the panel installs and enables the Redis Object Cache plugin.
- OPcache on for the whole server, with the hit rate and memory of the site's PHP version shown in the panel.
- TTFB measurement of the homepage with and without the cache (median of 5 runs), with a history of results.
- PHP 7.4 to 8.5, chosen per site, with memory and limits adjustable site by site (PHP versions).
- WP-Cron on the system cron, every 5 minutes.
What it doesn't do: Koapanel is nginx only, so there's no Apache and no .htaccess rules, and no LiteSpeed with LSCache. If your stack already runs on LiteSpeed and you're happy with it, LSCache is a solid alternative to this setup. To build the server from scratch, see the WordPress on a VPS guide.
FAQ
Do I need a caching plugin if I use nginx fastcgi_cache?
Not for page caching: nginx handles that. A plugin can still help with CSS, JavaScript and image optimisation, or to purge the cache when you publish.
Does Redis replace the page cache?
No, they work together. The page cache skips PHP entirely for anonymous visitors; Redis speeds up pages that still have to go through PHP, such as wp-admin and the cart.
Will page caching break WooCommerce?
Not if you exclude cart, checkout, account and WooCommerce's cookies, as in the example above. Always place a full test order after turning it on.
Which PHP version should I pick?
A supported one that your plugins work with: 8.3 or 8.4 are safe choices today, and 8.5 is the newest. Test on a copy of the site first.
Why is my PageSpeed score low when TTFB is fine?
Because PageSpeed also measures what happens in the browser: large images, fonts and external scripts weigh more than the server.
Try the ready-made setup
You can install Koapanel for free on a fresh Ubuntu 24.04 server (up to 3 sites for personal use) and compare TTFB before and after from the WordPress tab, or try the public demo at https://demo.koapanel.app/. Plans and limits are on the pricing page.