How to Size Apache Event MPM and PHP-FPM From Measurements
In this article9 sections
Most Apache and PHP-FPM sizing advice is a formula with no measurements behind it. Pick a number of workers, hope the server has enough memory, and find out under load which limit was wrong. This guide takes the other route. You measure what one Apache process and one PHP-FPM worker really use on your site, then size the two limits so the server can never run out of memory, and so requests queue in a controlled way instead of thrashing the machine.
The stack assumed here is Apache with the event MPM, handing PHP requests to PHP-FPM through mod_proxy_fcgi. Two independent limits control the load, and they must agree: Apache’s worker limit decides how many requests Apache serves at once, and PHP-FPM’s pm.max_children decides how many PHP requests can run at once. If Apache accepts more requests than PHP-FPM can run, the extra requests wait in a queue, and the page looks slow even though the server is not busy.
Part 1: Confirm the stack
Check which MPM Apache loaded, whether the FastCGI proxy module is on, and which PHP-FPM pool serves the site:
apache2ctl -V | grep -i mpm
apache2ctl -M | grep -E "proxy_fcgi|mpm_"
ls /etc/php/8.4/fpm/pool.d/
Expected: mpm_event in the module list, proxy_fcgi_module present, and one or more pool files. If Apache shows mpm_prefork or mpm_worker, switch to event first (Part 4). If PHP runs through mod_php instead of PHP-FPM, this guide does not apply, because the PHP processes are then children of Apache and sized differently.
Part 2: Measure what the processes actually use
Memory figures from top or the RSS column are misleading for PHP-FPM, because every worker shares the same PHP and extension code. Adding up RSS counts the shared pages many times. The proportional set size, PSS, divides shared pages between the processes that use them, which gives the real cost per worker. Linux exposes it in /proc/PID/smaps_rollup, on kernels from 4.14 onward, which Ubuntu 24.04 has.
Measure during normal traffic, not on an idle server. Average PSS over every PHP-FPM worker:
for p in $(pgrep -f "php-fpm: pool"); do awk '/^Pss:/ {print $2}' /proc/$p/smaps_rollup; done | awk '{s+=$1; n++} END {printf "PHP-FPM worker: %.0f MB average PSS over %d workers\n", s/n/1024, n}'
Do the same for Apache’s worker processes:
for p in $(pgrep -x apache2); do awk '/^Pss:/ {print $2}' /proc/$p/smaps_rollup; done | awk '{s+=$1; n++} END {printf "Apache process: %.0f MB average PSS over %d processes\n", s/n/1024, n}'
Record both numbers. They are the inputs to every calculation in Part 3. Take the measurement at a busy hour, and again at a quiet one, and use the higher value for sizing.
Part 3: Work out the budget
Start with the memory you can give to Apache and PHP together:
Budget = total RAM − memory for the OS − memory for the database − headroom for spikes
Keep at least 15 to 20 percent of RAM free as headroom. A server that uses every byte gets killed by the kernel out-of-memory handler the first time traffic spikes, and that takes down every site on it.
Then split the budget between the two limits:
Apache processes = MaxRequestWorkers ÷ ThreadsPerChild
Apache memory = Apache processes × Apache PSS
PHP memory = pm.max_children × PHP-FPM worker PSS
Apache memory + PHP memory ≤ Budget
A worked example with made-up numbers, to show the arithmetic: a 4 GB server with 1.5 GB for the database and the OS leaves about 2.5 GB, and 20 percent headroom brings the budget to 2 GB. If Apache’s processes measure 60 MB each and the PHP workers 45 MB each, then 1 GB goes to Apache (about 16 processes) and 1 GB to PHP (about 22 workers). Your numbers will differ, and the method is the part to keep.
Check the result against the traffic you expect. pm.max_children should be at least the number of PHP requests running at the same moment at your busiest hour. The PHP-FPM status page in Part 5 shows the real figure.
Part 4: Set the event MPM
The event MPM serves many idle keep-alive connections with a few threads, so PHP workers are not held by idle browsers. Three directives set its limits, and they must agree. Apache’s manual describes the process count as MaxRequestWorkers divided by ThreadsPerChild, and ServerLimit caps how many processes may exist:
<IfModule mpm_event_module>
ServerLimit 16
ThreadsPerChild 25
MaxRequestWorkers 400
</IfModule>
These values are an example. The arithmetic works: 16 processes times 25 threads is 400, so ServerLimit and ThreadsPerChild allow exactly MaxRequestWorkers. If ServerLimit times ThreadsPerChild is lower than MaxRequestWorkers, Apache cannot reach the value you set, and it does not tell you that in a clear way, so check the arithmetic every time you edit these lines. Check the MPM page for your Apache version for the exact limits on ThreadLimit, which must allow your ThreadsPerChild.
Use the process count from Part 3 to set MaxRequestWorkers, then choose ThreadsPerChild to suit your load. Apache’s MPM settings are read when the server starts, so restart Apache after the change rather than reloading it:
sudo apache2ctl configtest && sudo systemctl restart apache2
Part 5: Set PHP-FPM and make the two limits agree
PHP-FPM’s pm.max_children is the most important number on the PHP side. In a dynamic pool it is the maximum number of workers, and the PHP manual describes it as the limit on simultaneous requests. Set it from Part 3, and choose the process manager to suit the traffic:
[www]
pm = dynamic
pm.max_children = 22
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
pm.status_path = /fpm-status
The example values match the worked example above, and yours should come from Part 3. Three points matter:
pm.start_serversdefaults to the average of the minimum and maximum spare servers, according to the PHP manual, so setting it explicitly makes the start-up behaviour visible.pm.max_requestsrecycles a worker after a set number of requests. It hides slow memory growth in third-party code, but it is not a fix for a leak, so keep it as a safety net and not a cure.- Apache’s worker limit should not be much higher than
pm.max_children. Any Apache worker above that number waits in the queue for a PHP worker. The queue is invisible in the Apache status page, so the FPM status page must show it.
Restart PHP-FPM after the change, then check the pool settings it loaded:
sudo php-fpm8.4 -tt 2>&1 | grep -E "pm.max_children|pm.start_servers"
sudo systemctl restart php8.4-fpm
Part 6: Decide on connection reuse
mod_proxy_fcgi disables connection reuse by default, according to Apache’s documentation, and each request opens a fresh connection to PHP-FPM. That is simple and safe. Reuse can save the cost of the connections, but it has a trap that the documentation spells out: with reuse on, the connections are held by each Apache child, so under HTTP/1.1 load Apache can open up to MaxRequestWorkers connections to the backend, and PHP-FPM’s workers can end up held by idle connections while new requests stall.
Leave reuse off unless you have measured a benefit. If you turn it on for PHP-FPM, use the pool size the documentation recommends and check the FPM status page for idle workers while requests queue. The setting is written on the proxy handler:
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost/"
</FilesMatch>
Keep the default unless you have a reason to change it, and treat any change as a measured experiment.
Part 7: Watch the two status pages under load
You need two views: Apache’s worker counts, and PHP-FPM’s queue. Enable Apache’s status page, limited to the server itself:
<Location "/server-status">
SetHandler server-status
Require ip 127.0.0.1
</Location>
Expose the PHP-FPM status page the same way, through a location that only the server can reach:
<LocationMatch "^/fpm-status$">
SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost/"
Require ip 127.0.0.1
</LocationMatch>
This works when the request reaches PHP-FPM with the script name it expects. If the page returns a 404 or an empty response, the status path is not being passed through. Check PHP-FPM’s status page documentation for your version, and set the script name the status handler needs, rather than loosening the access rule.
Read them from the server, with curl, while a load test runs:
curl -s http://127.0.0.1/server-status?auto | grep -E "BusyWorkers|IdleWorkers"
curl -s http://127.0.0.1/fpm-status?full | grep -E "active processes|max active|listen queue|max children reached"
Read the results like this:
- A listen queue above zero means requests are waiting for a PHP worker. Raise
pm.max_childrenonly if the memory budget allows it, or make the pages faster. max children reachedcounting up means the pool hit its limit. The PHP-FPM log records the same event, asserver reached pm.max_children setting.- BusyWorkers close to MaxRequestWorkers means Apache is at capacity. Check the FPM queue first, because the bottleneck is usually PHP.
Test with a load tool against a staging copy, not the live site. A simple run with ab, the Apache benchmark tool, shows the trend:
ab -n 2000 -c 50 https://staging.example.com/
Run the test at the same concurrency before and after each change, and keep the numbers. A change that makes the test faster but raises memory use to the edge of the budget is not an improvement.
Common mistakes
- Sizing from the RSS column. RSS counts shared memory once per process, so it overstates the cost of each PHP worker and understates the total. Use PSS.
- Setting
MaxRequestWorkersfar abovepm.max_children. The extra Apache workers only wait in the PHP queue. - Changing one limit and not the other. The two limits form one pipeline, and the slowest stage sets the speed.
- Reloading instead of restarting after an MPM change. Apache reads these settings when it starts.
- Ignoring the headroom. A server at 100 percent memory use is one traffic spike away from the kernel killing a process.
- Leaving the status pages open. Limit them to the server, as shown above, because they reveal your configuration.
Frequently asked questions
Do I need the event MPM for PHP-FPM?
Not strictly. The prefork MPM also works with PHP-FPM. The event MPM is usually better for sites with many idle keep-alive connections, because it serves them with fewer processes.
How often should I re-measure?
After every major change to the site, such as a new plugin, a larger page or a new service on the same server, and at least once a quarter. Memory per worker changes as the code changes.
Is pm = ondemand better for small sites?
It saves memory when a site is idle, because workers start only when needed. It adds a little delay for the first request after a quiet period. Use it for low-traffic sites on a shared server, and dynamic for busy sites.
Can I use the same numbers on another server?
No. The numbers depend on the memory per worker, which depends on the code, the extensions, and the traffic. Measure on each server.
Size the limits from measurements, not from a formula alone. Measure PSS for Apache and PHP-FPM, split the memory budget between them, keep MaxRequestWorkers and pm.max_children in step, and watch the two status pages under a test load. The goal is a server that stays inside its memory and queues requests in a controlled way when traffic peaks.