Right-Sizing Apache and PHP-FPM Without Guesswork

"The site is slow under load" on a LAMP server usually comes down to a handful of numbers in Apache and PHP-FPM that were never set deliberately — defaults from the distro, or values copied from a blog post about someone else's hardware. Here is how to set them from measurement instead of guesswork.

Step 0: get off mod_php if you are still on it

If Apache runs PHP via mod_php, every Apache process carries a full PHP interpreter — including the ones serving images — and Apache is locked to the prefork MPM, one process per connection. The single biggest structural improvement available is moving PHP into PHP-FPM behind mpm_event: Apache's event workers handle connections and static files cheaply, and a separate, independently-sized FPM pool runs PHP. Everything below assumes that layout; it has been the right default for over a decade.

Size the FPM pool from measured memory

pm.max_children is the number that matters most. Too low and requests queue while the CPU idles; too high and the server swaps, which is how "slow" becomes "down".

Measure what one worker actually costs on your application — not a rule of thumb:

ps --no-headers -o rss -C php-fpm | awk '{sum+=$1; n++} END {print sum/n/1024 " MB avg"}'

Take the average resident size of a warm worker (30–80 MB is typical for a framework app; more with fat dependencies). Then:

max_children = (total RAM − MySQL − OS/Apache headroom) / avg worker size

On a 4 GB server where MySQL is capped around 1.5 GB and the OS plus Apache need ~700 MB, you have ~1.8 GB for PHP; at 60 MB a worker that is 30 children, not the 5 the distro shipped or the 200 the blog post said. If 30 is not enough for your traffic, the answer is more RAM or faster requests, not a bigger number and swap.

For pm mode: dynamic for steadily-trafficked sites; ondemand for servers hosting many low-traffic pools, where idle workers are wasted RAM. Watch /status (enable pm.status_path, keep it firewalled) — if listen queue is persistently non-zero, requests are waiting for workers: raise max_children if RAM allows, otherwise fix the slow requests.

Find the slow requests before adding workers

More workers multiplied by slow requests is just more RAM spent waiting on the same bottleneck. Two switches show you where the time goes:

  • FPM slowlog: set request_slowlog_timeout = 2s and slowlog to a file. Every request over the threshold logs a PHP stack trace mid-flight — it names the function, which is usually a query.
  • MySQL slow query log: slow_query_log=1, long_query_time=0.5. Run pt-query-digest (or mysqldumpslow) over a day of it. In our experience the top two queries in that digest account for most of the pain, and an index fixes the first one.

Fixing one 800 ms query outperforms any amount of worker arithmetic.

Align the timeouts

Timeout mismatches produce the confusing errors: white pages, 502/504s, and requests that die at exactly 30 or 60 seconds. Three settings should be deliberately ordered:

  • PHP max_execution_time — the normal limit (30 s is sane).
  • FPM request_terminate_timeout — the hard kill, slightly above PHP's limit so PHP gets to fail gracefully first.
  • Apache's proxy timeout to FPM (ProxyTimeout or the timeout= on the SetHandler/ProxyPass line) — at or above FPM's, so Apache does not return 502 while PHP is still legitimately working.

If long-running work genuinely needs minutes, it should be a queue job or cron script with its own limits, not a web request holding a worker.

Apache: keep it light

With PHP out of the Apache processes, mpm_event needs little: MaxRequestWorkers sized generously (event threads are cheap), KeepAlive On with KeepAliveTimeout 2-5 (long keepalives hold connections for no benefit), and unused modules disabled. Enable mod_status (firewalled) so you can see the scoreboard when something is wrong.

Verify with a load test, not a benchmark

Finally, prove it: replay something like production traffic against staging — even ab or wrk against the five real routes with realistic concurrency — and watch three things at once: p95 latency, FPM's listen queue, and free memory. Raise concurrency until one of them breaks; now you know the server's actual ceiling and which resource sets it. Write those numbers down. Next time someone says "the site is slow", you will know whether you are near a ceiling you already mapped or looking at a regression — and that distinction is the whole game.