On a PHP site, server response time comes down to three levels: does PHP compile the scripts on every request? Are there enough PHP processes for simultaneous visitors? And above all, do you need to run PHP for every page? This guide tunes all three, in that order. The examples target WordPress on Ubuntu 24.04 with PHP 8.3, but apply to Laravel or any other PHP application.
1. Measure before tuning
Without a baseline, you cannot tell whether a setting helped. Record the response time of a representative page, three times in a row:
for i in 1 2 3; do
curl -o /dev/null -s -w "TTFB %{time_starttransfer}s\n" https://mysite.com/
done
Above 800 ms, Google considers the response time poor. Also note the figure for a page that cannot be cached, such as a shop's cart: that is the one PHP-FPM and Redis will improve. To interpret these values, see slow WordPress: finding the real cause (in French).
2. Enable and size OPcache
OPcache keeps already-compiled PHP code in memory. It is installed with PHP, but its default values are too small for WordPress and its plugins. Create an override file:
cat > /etc/php/8.3/fpm/conf.d/99-opcache.ini <<'EOF'
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
EOF
systemctl restart php8.3-fpm
With revalidate_freq=60, PHP checks at most once a minute whether a file has changed: a good compromise for a WordPress site updated from the admin panel.
opcache.validate_timestamps=0 recommended. It is faster, but PHP no longer sees any file change until you reload PHP-FPM. Keep it for applications deployed by a script that runs systemctl reload php8.3-fpm on every release. On WordPress, a plugin update would not be picked up.3. Size PHP-FPM
Each PHP request occupies a process. When they are all busy, the next visitors wait. The key setting is pm.max_children, the maximum number of processes. Too low, and the site queues; too high, and the server runs out of memory and becomes very slow.
First measure the average memory of a process while the site is running:
ps --no-headers -o rss -C php-fpm8.3 | awk '{s+=$1; n++} END {printf "%.0f MB on average\n", s/n/1024}'
Then calculate: memory reserved for PHP ÷ memory per process. Example on a 4 GB VPS: keep about 1.5 GB for the system, Nginx and MariaDB, which leaves 2.5 GB for PHP. At 60 MB per process, that gives around forty; settle on 35 to keep a margin.
Set the pool in /etc/php/8.3/fpm/pool.d/www.conf:
pm = dynamic
pm.max_children = 35
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
pm.max_requests recycles each process after 500 requests, which neutralises the small memory leaks of some plugins. On a 2 GB VPS with little traffic, pm = ondemand frees memory when the site is quiet.
To know whether the limit is reached, watch for this message in the log:
grep "max_children" /var/log/php8.3-fpm.log
If it appears regularly, raise the value if memory allows, otherwise move to a bigger plan.
4. Set up the FastCGI page cache
This is the setting with the biggest effect: Nginx keeps a copy of the generated HTML and serves it directly, without starting PHP or querying the database. A cached page responds in a few milliseconds.
Declare the cache zone in /etc/nginx/conf.d/fastcgi-cache.conf:
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=WP: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;
Then, in the site's server block, define what must never be cached:
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 ($request_uri ~* "/panier/|/commande/|/mon-compte/|/cart/|/checkout/|/my-account/") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_logged_in|wp-postpass|woocommerce_items_in_cart|woocommerce_cart_hash") { set $skip_cache 1; }
And in the location ~ \.php$ block:
fastcgi_cache WP;
fastcgi_cache_valid 200 301 302 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status;
mkdir -p /var/cache/nginx/fastcgi
nginx -t && systemctl reload nginx
5. Clear the cache after a change
Nginx does not know that a post was edited: without purging, the old version is served until it expires, 60 minutes here. Two solutions:
- Simple: keep a short lifetime and clear manually after an important change with
rm -rf /var/cache/nginx/fastcgi/*. - Automatic: install the purge module (
apt install libnginx-mod-http-cache-purge) and the WordPress plugin Nginx Helper, which purges the page concerned on every publication.
6. Add a Redis object cache
The page cache is useless on dynamic pages: cart, customer account, admin. For those, an object cache avoids repeating the same SQL queries from one page to the next.
apt install -y redis-server php8.3-redis
systemctl enable --now redis-server
systemctl restart php8.3-fpm
Then install the WordPress plugin Redis Object Cache and enable it in Settings → Redis. By default, Redis only listens on the local address: never expose it to the Internet.
7. Enable compression
Gzip compression is already enabled in Ubuntu's Nginx configuration, but only for HTML. Complete the http section of /etc/nginx/nginx.conf:
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/css text/plain text/xml application/javascript application/json
application/xml application/rss+xml image/svg+xml font/ttf;
There is no need to add JPEG, PNG or WebP images: they are already compressed.
8. Check the result
Request the same page twice and look at the header added above:
curl -sI https://mysite.com/ | grep -i x-cache
curl -sI https://mysite.com/ | grep -i x-cache
The first response shows MISS, the second HIT. Then rerun the measurement from step 1: on a cached page, the response time should drop below 100 ms from France. On dynamic pages, OPcache, PHP-FPM and Redis make the difference.
Frequently asked questions
Do I need a caching plugin if I use the Nginx FastCGI cache?
No, not for the page cache: Nginx does it more efficiently than a plugin, since PHP does not start at all. A plugin is still useful for automatic purging, such as Nginx Helper, or for optimising CSS and JavaScript files. Avoid stacking two page caches; the results become unpredictable.
What value should I give pm.max_children?
Divide the memory you reserve for PHP by the average memory of a process, measured on your site. On a 4 GB VPS with processes of about 60 MB, a value around 35 is reasonable. There is no universal right value: it depends on your plugins.
Does the Nginx cache work with WooCommerce?
Yes, provided you exclude the cart, checkout, customer account and visitors with a cart in progress, as the rules in this guide do. Product and category pages stay cached for anonymous visitors, which takes a lot of load off the server.
Is Redis useful on a small brochure site?
Rarely. If almost all your pages are served from the Nginx cache, Redis adds almost nothing. It becomes useful as soon as a significant share of traffic is dynamic: shop, members' area, forum.



