Skip to content

guide

PHP-FPM, OPcache and Redis tuning

Most slow PHP sites are not slow because of PHP. They are queueing for too few workers, or swapping because of too many. Measure one worker, then do the arithmetic.

Measure a worker

Run the site under normal traffic for a while, then look at the resident memory of each PHP-FPM worker:

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

A lean framework app might average 40 MB per worker. A WordPress site with a page builder and a dozen plugins is often 80 to 120 MB. WooCommerce on a busy shop can be more. Use your number, not a blog's.

Do the arithmetic

pm.max_children = (RAM for PHP) / (average worker size). RAM for PHP is total minus the OS, nginx, the database and Redis.

PlanRAMTypical split, all on one servermax_children at 90 MB/worker
Spark2 GBPHP 900 MB, DB 600 MB, rest OS10
Boost4 GBPHP 2 GB, DB 1.2 GB22
Turbo8 GBPHP 4 GB, DB 2.5 GB, Redis 256 MB44
Warp16 GBPHP 9 GB, DB 5 GB, Redis 512 MB100

More workers than vCPUs is fine when requests spend time waiting on the database or remote APIs. If requests are CPU-bound, workers far beyond the core count just queue inside the CPU instead of in FPM, which looks better in logs and feels the same to users.

Pool configuration

; /etc/php/8.3/fpm/pool.d/www.conf   (Turbo, mixed server)
pm = dynamic
pm.max_children = 44
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 14
pm.max_requests = 800          ; recycle workers to contain slow leaks

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

pm.status_path = /fpm-status

The slow log is the hidden gem. Any request that runs longer than five seconds gets a PHP stack trace written to it, which tells you exactly which plugin or function is stuck. Expose /fpm-status to localhost only in nginx and read it during a busy period:

$ curl -s 'http://127.0.0.1/fpm-status'
pool:                 www
active processes:     41
idle processes:       3
listen queue:         7
max children reached: 12

A non-zero listen queue and a growing max children reached mean requests are waiting. Then check free -m before raising the limit: running out of RAM is worse than queueing.

OPcache

OPcache stores compiled PHP in shared memory so files are not parsed on every request. It is on by default but undersized for large codebases.

; /etc/php/8.3/fpm/conf.d/90-opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=40000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
opcache.jit=off
  • Check fill with opcache_get_status() from a local-only script. If cache_full is true or the hit rate is under 99 percent, raise memory or file count.
  • With atomic symlink deploys, set validate_timestamps=0 and reload FPM on each deploy, and use $realpath_root in nginx so the cache is keyed by the real release path.
  • JIT helps maths-heavy code. For typical web apps it adds memory and little speed; leave it off unless you measure a gain.

Redis object cache

For WordPress and WooCommerce, an object cache keeps repeated database lookups, options, sessions and transients in memory. On frameworks, point the cache and session drivers at Redis.

sudo apt install redis-server php8.3-redis
# /etc/redis/redis.conf
bind 127.0.0.1 -::1
maxmemory 256mb
maxmemory-policy allkeys-lru
sudo systemctl restart redis-server php8.3-fpm

Keep Redis bound to localhost; an exposed Redis is one of the most commonly abused services on the internet. allkeys-lru makes it behave as a cache that evicts the oldest entries instead of returning errors when full. Then enable a Redis object cache plugin in WordPress and check its status page reports connected.

nginx side

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    fastcgi_read_timeout 60s;
}

Match fastcgi_read_timeout to request_terminate_timeout so nginx and FPM agree on when a request has failed. For anonymous traffic on content sites, add nginx fastcgi_cache so most visitors never reach PHP at all; bypass it for cookies such as logged-in, cart and checkout sessions. TLS and HTTP/2 set-up is in the nginx and certbot guide.

WooCommerce specifics

  • Carts and checkouts are uncacheable per user, so worker count and object cache matter more than for a blog.
  • Replace WP-Cron's page-load trigger with a real systemd timer running wp cron event run --due-now, and set DISABLE_WP_CRON.
  • Watch the wp_options autoload size; a bloated autoload adds latency to every request.
  • Outbound mail on port 25 is blocked by default on our network. Use your mail provider's submission port 587, or request an unblock in the portal if you run your own mail server.

Questions

What does 'server reached pm.max_children' mean?

Every PHP worker was busy and new requests had to wait. Either raise max_children if you have RAM to spare, or find out why requests take so long, often slow database queries or external API calls.

pm = dynamic, ondemand or static?

static on a dedicated app server where RAM is reserved for PHP anyway; dynamic for a mixed server; ondemand for many small, rarely used sites where idle memory matters more than the first request's latency.

Does a Redis object cache replace page caching?

No. Page caching serves whole pages to anonymous visitors without running PHP. An object cache speeds up the PHP that does run, which is what logged-in users, carts and checkouts need. Busy shops usually want both.

Which PHP version should I run?

A version still receiving security support from the PHP project. Ubuntu 24.04 ships PHP 8.3 and Debian 13 ships 8.4, which suit current WordPress and most frameworks.