How to Optimize PHP-FPM Workers for Limited RAM
Set PHP-FPM’s pm.max_children by dividing the RAM you can spare for PHP by the real memory each worker uses, measured as proportional set size (PSS) rather than RSS, then check the result against the PHP-FPM status page and log. Choose ondemand or dynamic depending on how steady your traffic is, and keep pm.max_requests set so workers are recycled. This guide walks through each step with commands, using real measurements from a 1 GB production server, and builds on the full 1 GB WordPress optimization guide.
The examples assume Ubuntu 24.04 with PHP 8.4 and Nginx. If you run a different PHP version, replace 8.4 in paths and service names.
Why PHP-FPM workers decide how a small server behaves
PHP-FPM handles every PHP request in a separate worker process. Two visitors loading uncached pages at the same moment need two workers, and each worker has its own memory. Too many workers and the server runs out of RAM, starts swapping, or has a process killed by the kernel. Too few and requests queue behind each other, pages slow down, and Nginx eventually returns 502 or 504 errors.
The pm.max_children setting is the ceiling between those two failures. Package defaults are not calculated for your hardware, so on a small server this one number is worth getting right.
Step 1: Measure real worker memory with PSS
The obvious tool is ps, which reports resident set size (RSS):
ps -C php-fpm8.4 -o pid,rss,cmd
The problem is that RSS counts every shared page in full for every process. PHP workers share the OPcache segment and shared libraries, so RSS makes each worker look far bigger than it really is. Proportional set size (PSS) divides each shared page between the processes that use it, which makes it the fairer number. You can read it without installing anything:
for p in $(pgrep -f 'php-fpm: pool www'); do echo -n "$p "; sudo grep '^Pss:' /proc/$p/smaps_rollup; done
If you prefer a package, sudo apt install smem and then sudo smem -t -k -P php-fpm shows the same PSS column with a total.
Here is what both measurements gave on a real 1 GB server that runs a Laravel application and a WordPress blog through one pool:
| Measure | Per worker |
|---|---|
RSS (ps) |
92 to 98 MB |
PSS (smaps_rollup) |
31 to 35 MB |
Same three processes, same moment, and about a threefold difference. Dividing your RAM by RSS would have suggested a third of the workers the server can actually hold.
Two cautions. Measure after the site has served some traffic, because a freshly started worker is smaller than a warmed-up one. And workers grow while handling heavy requests such as a WordPress admin screen, an import or a checkout, so also take a measurement while one of those runs. Size for the peak, not the idle average.
Step 2: Work out a memory budget and calculate pm.max_children
The calculation is simple:
pm.max_children = (RAM you can give to PHP) ÷ (peak memory of one worker)
“RAM you can give to PHP” is what remains after everything else has taken its share. Check the other consumers with:
free -h
ps -eo rss,comm --sort=-rss | head -10
Subtract the operating system and background services, the database, Nginx, Redis if you run it, and a safety margin for the filesystem cache and sudden spikes. As a worked example, using round numbers for a 911 MiB machine: take away about 150 MB for the system, 190 MB for the database, 20 MB for Nginx and a 200 MB margin, and roughly 350 MB is left for PHP.
- Divide by the measured idle PSS of about 33 MB and you get around 10 workers.
- Now suppose your heavy requests push a worker to 60 MB (a hypothetical figure; measure your own in Step 5). Then 350 ÷ 60 is about 5 workers.
The second number is the safer one, and it is also what this server actually runs with: pm.max_children = 5. If a burst of slow requests fills every worker, the server queues requests instead of running out of memory, which is the failure you can recover from.
Step 3: Choose the process manager
PHP-FPM can manage its workers in three ways:
| Mode | How it behaves | Idle memory | Best for |
|---|---|---|---|
static |
Always runs exactly pm.max_children workers |
Highest | Busy servers with spare RAM and predictable load |
dynamic |
Keeps a pool of workers between a minimum and maximum spare count | Moderate | Steady traffic where response time matters |
ondemand |
Starts workers when requests arrive, stops idle ones | Lowest | Low or bursty traffic on small servers |
On a very small server with a page cache in front, ondemand is a good default because idle workers cost nothing. The trade-off is a short delay when a worker has to start after a quiet spell. The server measured here uses dynamic, which keeps a couple of workers warm so the first request after a pause is not slower.
Avoid static on a 1 GB machine unless you have measured that the workers fit permanently. It reserves the memory whether or not anyone is visiting.
Step 4: Set the spare-server and recycling values
The pool configuration lives in /etc/php/8.4/fpm/pool.d/www.conf. This is the real configuration from the server measured above:
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
What each line does:
pm.start_serversis how many workers exist at startup.pm.min_spare_serversandpm.max_spare_serversare the fewest and most idle workers PHP-FPM will keep waiting. PHP-FPM refuses to start if the values contradict each other: the start value must sit between the two spare values, and the maximum spare count cannot exceedpm.max_children.pm.max_requestsmakes a worker exit and be replaced after that many requests, which contains slow memory growth from plugins or extensions that leak. Several hundred up to about a thousand is a common range. Set it too low and you waste CPU on constant respawning.
If you choose ondemand instead, the equivalent block is:
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 20s
pm.max_requests = 500
Always test the configuration before applying it, then reload, which restarts workers gracefully:
sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm
Step 5: Turn on the status page and read it
You cannot tune what you cannot see. PHP-FPM has a built-in status page. Enable it by adding this line to the pool:
pm.status_path = /fpm-status
Then expose it only on the loopback interface, using a small dedicated Nginx server block so it is never reachable from the internet:
server {
listen 127.0.0.1:8081;
location = /fpm-status {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
}
Reload both services, then read it from the server itself:
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl reload php8.4-fpm
curl -s http://127.0.0.1:8081/fpm-status
curl -s "http://127.0.0.1:8081/fpm-status?full"
These are the fields that matter:
| Field | What it tells you |
|---|---|
active processes / idle processes |
Workers busy now and waiting now |
max active processes |
The busiest the pool has been since it started |
max children reached |
How many times the limit was hit. Anything above zero means requests have had to wait |
listen queue |
Requests waiting for a free worker right now |
slow requests |
Requests slower than the slow-log threshold (Step 6) |
The ?full view lists every worker, including a last request memory value: the peak memory PHP used for that worker’s last request. Watching it while you use the WordPress admin or run an import is a practical way to find your real peak for the Step 2 calculation.
Step 6: Watch the log and find slow requests
When every worker is busy, PHP-FPM writes a warning to its log, which on Ubuntu is /var/log/php8.4-fpm.log:
sudo grep "max_children" /var/log/php8.4-fpm.log | tail
A warning that reads “server reached pm.max_children setting” means requests queued. What to do depends on the rest of the picture:
- If it happens rarely, during short bursts, and
free -hshows plenty of available memory, raise the limit by one and check again. - If it happens together with heavy swap use, more workers will make things worse. The requests are slow, and the fix is to find out why.
The PHP-FPM slow log names the plugin or function that is taking the time. Add this to the pool:
request_slowlog_timeout = 5s
slowlog = /var/log/php8.4-fpm-slow.log
After a reload, any request that runs longer than five seconds writes a backtrace to that file, and the file paths in it usually point straight at the guilty plugin or theme. You can also set request_terminate_timeout to stop runaway scripts, but choose a value comfortably above your longest legitimate task, such as imports or backups.
Step 7: Give each app its own pool when they share a server
If a Laravel application and a WordPress site share one pool, a slow WordPress admin request can use up the workers the other application needs. Separate pools give each application its own ceiling. Create /etc/php/8.4/fpm/pool.d/wordpress.conf:
[wordpress]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm-wordpress.sock
listen.owner = www-data
listen.group = www-data
pm = ondemand
pm.max_children = 3
pm.process_idle_timeout = 20s
pm.max_requests = 500
Create a second file for the other application in the same way, with its own name, socket and limit, and point each site’s Nginx fastcgi_pass at its own socket. Remember that the limits add up: 3 workers plus 4 workers can still be 7 workers at their peak size, so the total has to fit the budget from Step 2. Retire the default www pool once both sites use their own, then test and reload as before.
Common mistakes
- Dividing by RSS. It counts shared memory repeatedly and leads you to set the limit far too low.
- Raising
pm.max_childrenuntil the warnings stop. If the cause is slow requests, more workers just move the failure from queueing to swapping. - Confusing
memory_limitwith worker size. It caps what one PHP request may allocate, not how large the process becomes, and a worker can stay larger than that after a heavy request. That is one reasonpm.max_requestsexists. - Forgetting to reload. Pool changes only apply after
systemctl reload php8.4-fpm. - Tuning workers before adding a page cache. Serving anonymous visitors from a cache means far fewer requests reach PHP at all, which is covered in the 1 GB WordPress guide.
When tuning is not enough
If the status page keeps showing a nonzero max children reached, the queue is regularly non-empty, and memory is already tight, the site has outgrown the server. Your options are more RAM, a separate database server, or moving to managed WordPress hosting where the stack is tuned for you. For sustained high traffic, a dedicated server removes the shared-resource ceiling entirely.
Frequently asked questions
How many PHP-FPM workers do I need per GB of RAM?
There is no fixed number, because worker size depends on your plugins and theme, and the database and operating system take their share first. Use the calculation in Step 2 with your own PSS measurement. As one data point, a 1 GB server running a Laravel application and a WordPress blog runs comfortably with five workers.
Should I use ondemand or dynamic?
Use ondemand when traffic is low or bursty and every megabyte counts. Use dynamic when traffic is steady and you want a few workers always ready. Both need a sensible pm.max_children.
Does OPcache make each worker bigger?
OPcache keeps compiled scripts in one shared memory segment used by all workers. Tools that report RSS count that segment in every worker, which is why RSS looks inflated. PSS splits the shared pages between the workers, so the OPcache cost appears once across the pool instead of once per worker.
What is a safe value for pm.max_requests?
Several hundred to about a thousand works for most sites. Lower it if worker memory keeps growing between restarts, and raise it if you see CPU spent on respawning workers.
The short version: measure PSS, budget the RAM, set a ceiling you can afford, and let the status page and log tell you when the number is wrong.