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.
| Plan | RAM | Typical split, all on one server | max_children at 90 MB/worker |
|---|---|---|---|
| Spark | 2 GB | PHP 900 MB, DB 600 MB, rest OS | 10 |
| Boost | 4 GB | PHP 2 GB, DB 1.2 GB | 22 |
| Turbo | 8 GB | PHP 4 GB, DB 2.5 GB, Redis 256 MB | 44 |
| Warp | 16 GB | PHP 9 GB, DB 5 GB, Redis 512 MB | 100 |
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. Ifcache_fullis true or the hit rate is under 99 percent, raise memory or file count. - With atomic symlink deploys, set
validate_timestamps=0and reload FPM on each deploy, and use$realpath_rootin 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 setDISABLE_WP_CRON. - Watch the
wp_optionsautoload 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.