PHP-FPM on cPanel ships with a single global default that is wrong for almost every server
that ever runs it. Out of the box, every account gets a pool with pm = ondemand and
pm.max_children = 5 — which is fine for a marketing landing page and catastrophic for a
WooCommerce store on Black Friday. The first time a busy site hits the wall you see 502
Bad Gateway in the access log, the pool spawns more workers than the box has RAM for, and
the OOM killer takes down something unrelated.
This guide shows how to size pools correctly per account, when to switch process manager modes, and how to verify the numbers under real load. It assumes you're running EasyApache 4 with PHP-FPM enabled (the default since EA4 11.96) and have WHM root access.
Where pool configs actually live
cPanel manages FPM pools through three layers, in this order of precedence:
- Per-domain overrides —
/var/cpanel/userdata/<user>/<domain>.php-fpm.yaml. Created when you click MultiPHP Manager → PHP-FPM on a domain and edit values. - Global FPM defaults —
/var/cpanel/ApachePHPFPM/system_pool_defaults.yaml. Applied to every newly-created pool. Edit once, rebuild, done. - Pool template —
/var/cpanel/ApachePHPFPM/system.yaml. Rarely touched; controls the skeleton the per-domain files inherit from.
After any edit to the YAML files you must rebuild the Apache config and restart FPM:
/usr/local/cpanel/scripts/php_fpm_config --rebuild
/usr/local/cpanel/scripts/restartsrv_apache_php_fpm
Editing the /opt/cpanel/ea-php<XX>/root/etc/php-fpm.d/*.conf files directly works for
about 20 minutes — until cPanel regenerates them from the YAML and your changes vanish.
Always edit the YAML.
The memory math
pm.max_children is the only setting that matters for capacity planning. Everything else is
secondary. The formula is the same one nginx blog posts have repeated for a decade, but it
keeps being wrong in real configs because people forget the per-pool multiplier on a
shared host.
pm.max_children (per pool) = (RAM available to PHP) / (avg worker RSS) / (active pools)
Concrete worked example. A 16 GB cPanel box with 200 hosted accounts:
- Reserve 4 GB for MySQL, 1 GB for Apache/LSWS, 1 GB for the kernel and everything else.
- That leaves 10 GB for PHP.
- Average WordPress worker RSS with OPcache warm is 80–120 MB. Use 100 MB.
- Of the 200 accounts, maybe 30 are actively serving traffic at any given minute. The
other 170 are idle pools using zero RAM on
ondemand. 10 GB / 100 MB / 30 = ~3.4 children per active pool.
That gives each active pool a ceiling of 3 concurrent PHP requests. For a low-traffic shared host this is correct. For a host running a few traffic-heavy stores it is wrong by an order of magnitude — those stores need 20–30 workers and the rest of the box needs to yield to them.
The answer is not to set a higher global default. It is to tier accounts.
Tier accounts by workload, not by package
Pick three tiers and assign pool sizes per tier. A starting point:
| Tier | Account profile | pm mode | pm.max_children | Memory ceiling |
|---|---|---|---|---|
| Idle | Brochure sites, parked domains | ondemand | 5 | ~500 MB peak |
| Active | Small WordPress, low-traffic Joomla | ondemand | 10 | ~1 GB peak |
| Heavy | WooCommerce, LMS, busy forum | dynamic | 30 | ~3 GB peak |
Apply with a per-domain YAML override:
# /var/cpanel/userdata/heavyuser/store.example.com.php-fpm.yaml
_is_present: 1
pm: dynamic
pm_max_children: 30
pm_start_servers: 4
pm_min_spare_servers: 2
pm_max_spare_servers: 6
pm_max_requests: 500
Then rebuild. The same file shape works for all three tiers — change pm and
pm_max_children, leave the rest at defaults for dynamic pools.
For the idle and active tiers, stay on ondemand. It costs a few hundred microseconds
per cold request but pays back hundreds of MB of RAM the box can give to the heavy tier
during a spike. Do not use static on a multi-tenant cPanel server — it pins the full
worker count in memory whether traffic is hitting the site or not.
If you're running CloudLinux with LVE limits, pair these tiers
with matching LVE memory ceilings — pool size and LVE memory together form the contract.
Without LVE, a runaway pm.max_children on one account can still starve neighbours.
pm.max_requests — the leak escape valve
Every PHP-FPM pool should set pm.max_requests to a non-zero value. Common defaults are
500–1000. The number tells the worker to die and respawn after handling that many requests,
which reclaims memory leaked by long-running PHP extensions (looking at you, older
ionCube, mongo, and any Composer package that caches reflection metadata).
Without it, every worker's RSS grows monotonically over a day. On a busy site you'll see the 100 MB average creep to 300 MB by 8 p.m. and your capacity math silently breaks.
pm_max_requests: 500
500 is a fine default. Drop to 200 for sites running known-leaky modules. Setting it to 0 (unlimited) is what the cPanel default does and what you should immediately change.
Verify the numbers under load
Don't trust your spreadsheet. Watch the actual RSS:
ps -ylC php-fpm --sort:rss | awk 'NR>1 {sum+=$8; n++} END {print "avg KB:", sum/n, " count:", n}'
This prints the average resident size and worker count across every active FPM process on
the box. Run it during a peak hour. If the average is double your planning assumption, your
pm.max_children math is now wrong and the box is overcommitted — fix it before the next
peak, not after.
For per-pool visibility, enable the status page on heavy-tier accounts:
pm_status_path: /fpm-status
ping_path: /fpm-ping
Then curl https://store.example.com/fpm-status?full from the server shows live worker
counts, slow requests, and accepted-connection rates. Don't expose these endpoints
publicly — restrict them in .htaccess to the server's own IP.
The four failure modes you'll actually hit
502 Bad Gateway, pool exhausted. FPM is queueing requests past pm.max_children and
Apache's proxy timeout fires first. Symptom: bursty 502s during traffic peaks, no PHP error
in the site log. Fix: raise pm.max_children on that account's pool, or shed traffic.
OOM killer eats MySQL. Total FPM RSS across pools exceeds free RAM, kernel kills the
largest process — usually mysqld. Symptom: site is up, database is dead, /var/log/messages
shows Out of memory: Killed process ... mysqld. Fix: lower pm.max_children ceilings or
move heavy accounts to a smaller static-pool count with a hard cap.
Slow first request after idle. Pool is on ondemand with pm.process_idle_timeout = 10s
and the worker has been reaped. Symptom: first request after a quiet minute takes 2–4
seconds, subsequent requests are fast. Fix: switch to dynamic with a small
pm.min_spare_servers (1–2). This is the correct trade for any monetised site.
Workers stuck on slow MySQL queries. All pm.max_children workers are blocked on the
same slow query, new requests queue. Symptom: fpm-status shows all workers in Reading
or Running state for the same script. Fix: this isn't an FPM problem — find the query
with mysqltuner or LVE statistics. Adding
more FPM workers just kills MySQL faster.
When to skip FPM and use LSAPI instead
If you're already paying for a LiteSpeed license, LSAPI uses 5–15 MB per idle site versus FPM's 30–60 MB, and the workers are shared across the whole server rather than partitioned per account. For high-density shared hosting (500+ accounts on one box), the RAM savings alone usually pay for the LiteSpeed license inside two months.
The catch: LSAPI on Apache (mod_lsapi) needs CloudLinux. LSAPI on LSWS is the default and
doesn't. See the handler comparison for the full
breakdown.
What is a good pm.max_children value for WordPress on cPanel?+
Why does pm.max_children only allow 5 by default in cPanel?+
Should I use pm = static, dynamic, or ondemand?+
Why are my PHP-FPM changes reverting after a few minutes?+
How do I see how many PHP-FPM workers are actually running?+
Next steps
- The handler decision before pool tuning — PHP handlers in cPanel: LSAPI vs PHP-FPM vs suPHP
- Cap runaway accounts at the kernel level — CloudLinux LVE tuning for shared hosting
- When the right answer is to leave FPM behind — Install LiteSpeed Web Server on cPanel