SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

PHP-FPM pool sizing on a small VPS

Your PHP-FPM pool size is a division you run on your own box: measure one worker, subtract what the database needs, then read the number that is left.

PHP-FPM pool sizing on a small VPS is a division

PHP-FPM pool sizing on a small VPS is one division: the memory you can give the pool, divided by what one of your workers really costs. Every number below comes from a command you run on your own machine, because a pm.max_children value copied from a forum was measured on somebody else's application, with different plugins, a different theme, a different database and a different traffic shape. This guide covers the measurement and the division, then what breaks on each side of the number.

PHP-FPM (FastCGI process manager) runs one master process and a pool of child processes. A child handles exactly one request from start to finish. So the number of children is the number of PHP requests the server can run at the same time, and every request beyond that waits in the listen queue until a child is free. Static files and cached responses never enter the pool at all.

That is why pool size is a memory question rather than a traffic question. Each child is a full PHP interpreter with your application loaded into it. If you are still assembling the stack, the Ubuntu 24.04 LAMP stack install covers where these files live. The default pool config is /etc/php/8.3/fpm/pool.d/www.conf on Ubuntu 24.04, which ships PHP 8.3 (as of August 2026). Run php -v and substitute your own version in every path here.

What a 502 under load is actually telling you

Two different faults both return 502, and they need opposite fixes. Read the web server error log before you touch any config. This line is saturation:

connect() to unix:/run/php/php8.3-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream

Every child was busy and the socket's listen backlog was full, so nginx could not even hand the request over. This line is something else entirely:

recv() failed (104: Connection reset by peer) while reading response header from upstream

That is a child that died in the middle of the request. Look in the FPM log for child 1234 exited on signal 9 (SIGKILL), which usually means the kernel out of memory killer took it, or signal 11 (SIGSEGV), which is a crash in PHP or in an extension. Adding workers makes that worse, not better.

A 504 is a third case:

upstream timed out (110: Connection timed out) while reading response header from upstream

A child accepted the request and did not finish inside fastcgi_read_timeout. On Apache with mod_proxy_fcgi the same conditions read AH01079: failed to make connection to backend and AH01075: Error dispatching request to.

Then FPM's own log, /var/log/php8.3-fpm.log:

WARNING: [pool www] server reached pm.max_children setting, consider raising it

The real line prints your configured value in brackets. It is an accurate report and incomplete advice: FPM knows every child was busy, and it knows nothing about how much memory the box has left. Raising the number is correct only when the arithmetic below says there is room for the extra children. Seeing the line once during a crawler visit is not an emergency. Seeing it through your normal peak means real requests are queueing.

Step 1: what does one PHP-FPM worker cost on your box?

Measure warm workers under real traffic. A child that has just forked has barely executed anything, so its footprint at boot is not the footprint that matters. After it serves your heaviest pages, the resident set grows to whatever that code path needs.

ps --no-headers -o rss,args -C php-fpm8.3 | grep 'pool www' | sort -rn | head

RSS (resident set size) is printed in kilobytes. If -C php-fpm8.3 matches nothing, find your binary name with ps -eo comm | grep php. Sort descending and read the top line: your ceiling is set by the largest worker, not the average one, because the heavy page can land on every child at once.

Now the correction that matters at this scale. RSS counts a shared page in full inside every process that maps it, so adding RSS across a pool counts the interpreter, the shared libraries and the copy-on-write pages inherited from the master many times over. PSS (proportional set size) divides each shared page by the number of processes sharing it, so the PSS total is close to what the pool really takes away from the machine.

for p in $(pgrep -f 'php-fpm: pool www'); do sudo awk '/^Pss:/ {print $2}' /proc/$p/smaps_rollup; done | awk '{s+=$1; n++} END {print n, "workers,", s, "KB total,", s/n, "KB each"}'

sudo is needed because the workers run as another user and smaps_rollup is not world readable. If the loop prints nothing, either the pool has no live children at this moment (which is itself an answer when you run ondemand), or your pool is not named www.

Here is the trap that ruins most of these calculations. memory_limit is not the cost of a worker. It is the per-request ceiling at which PHP aborts a script with Allowed memory size of N bytes exhausted. Raising it allocates nothing. Multiplying it by the worker count produces a worst case your application probably never reaches, so people size the pool from that product and end up with a pool far smaller than the box could actually run. Use measured RSS or PSS for sizing, and treat memory_limit as the blast radius of one runaway request.

The question to answer on your own box: what does the largest warm worker hold after it has served your slowest page? Request that page a few times first, with curl -o /dev/null -s -w '%{time_total}\n' https://example.com/slow-page, then read the process list while the workers are still warm.

One more thing to watch during that measurement. If the top worker keeps growing all day and never settles, the application leaks. pm.max_requests retires a child after a request count you choose and forks a fresh one, which converts an unbounded leak into a bounded one. It contains the symptom. It does not fix the bug, and a leaking application makes every sizing number below a moving target.

Step 2: how much memory is the pool allowed to have?

free -m
vmstat 1 5

Read the available column, not free. Linux fills otherwise unused memory with page cache, so free looks frightening on a perfectly healthy machine. available is the kernel's own estimate of what a new process can get without pushing anything to swap.

From that, subtract at peak rather than at 3am: the database engine, the object cache, the web server, and the kernel with the page cache it needs to keep database reads off the disk. Then subtract whatever else the box runs, such as backup jobs and a monitoring agent.

  • What does the database hold when it is warm? systemctl status mysql prints a Memory: line, or use ps -o rss= -C mysqld. The InnoDB buffer pool is allocated once and stays; the per-connection buffers grow with the connection count.
  • What is the object cache using, and is it capped? redis-cli info memory prints used_memory_human and maxmemory. A Redis with maxmemory unset grows until something on the box dies, and on a small VPS the victim is usually the largest process. Setting that cap is part of running Redis as a WordPress object cache.
  • What is genuinely left for PHP after all of that, measured during your busiest hour rather than your quietest?

The pool ceiling is the memory you set aside for PHP divided by the peak per-worker figure from step 1. Then hold some of it back. Planning to use the last free megabyte assumes the nightly backup, the cron sweep and the traffic spike will never coincide. Eventually they do.

Write the budget somewhere the kernel enforces it. A systemd drop-in with MemoryMax= on the php8.3-fpm service puts the pool in a cgroup with a hard boundary, so an overrun kills a PHP worker inside that cgroup instead of letting the global OOM killer choose the biggest process on the box, which on a LAMP server is the database. Capping process memory and CPU with systemd shows the drop-in. If PHP runs in containers, the same division happens per container and Compose memory limits are where the answer belongs.

dynamic, ondemand and static are three bets about idle RAM

pm.max_children is the ceiling in all three process manager modes. The mode only decides how many children sit there when nothing is happening, and how much latency the first request after a quiet spell pays.

  • static starts pm.max_children children and keeps every one of them alive. Memory use is flat and predictable, no request pays fork latency, and idle RAM is spent whether traffic arrives or not. This is the honest choice on a box that does one job, once you have done the division.
  • dynamic keeps a spare pool between pm.min_spare_servers and pm.max_spare_servers, begins with pm.start_servers, and forks up to pm.max_children under load. Idle children above the spare maximum are terminated as load falls.
  • ondemand starts no children at all. It forks one per waiting request and retires it after pm.process_idle_timeout, which applies in this mode only. Idle memory is close to nothing.

The cold start costs less than most people expect. OPcache lives in shared memory created by the master process, so a newly forked child inherits the compiled bytecode rather than recompiling your codebase. What it does pay is the fork and your framework's per-request bootstrap with a cold realpath cache.

Which bet fits your box: if it serves one busy site, static or dynamic with a floor of spare children avoids paying fork latency in the middle of a spike. If it hosts many quiet sites with a separate pool each, ondemand is what makes that fit at all, because the idle floors of a dozen dynamic pools add up to more memory than the sites actually use. The risk with ondemand is the one you just measured: nothing prevents every pool reaching its own ceiling at the same moment, so the sum of all pm.max_children values still has to fit in the machine.

Raising max_children past your memory turns a slow site into a broken one

Suppose the arithmetic gives one number and you set a larger one because the log asked you to. Traffic arrives, FPM forks up to the new ceiling, and the total passes what is available. The kernel reclaims page cache first, so database reads that were served from memory now go to disk. Then it swaps. Swap on a small VPS is normally a file on the same virtual disk the database reads from, so a swapping PHP worker competes for that disk with the query it is waiting on. Requests get slower. Slower requests mean more of them are in flight at once. More in flight means more children. The loop closes on itself, and the site stops responding rather than merely responding late.

You can watch every step of it. vmstat 1 shows steady non-zero si and so columns. Load average climbs while CPU utilisation does not. dmesg -T | grep -i 'killed process' prints Out of memory: Killed process 1234 (php-fpm).

With no swap configured, the same over-provisioning ends sooner and louder. The OOM killer arrives at the first real spike, and it scores the largest resident process highest, which on this box is the database. The site does not serve slow pages then. It serves database connection errors, because MySQL is no longer running. Whether to have swap at all is a separate decision worth making deliberately, and what a swap file does and does not buy you is the thing to read before adding one as a cushion under a pool that is simply too big.

The next wall is the database connection count

Once the pool fits in memory, the next ceiling is rarely PHP. Every worker running a page that talks to the database holds one connection for the length of that work, so pm.max_children is also the peak number of connections this host can open. Add your cron runs and any queue consumers on top.

MySQL refuses connections past its max_connections and PHP surfaces the refusal as:

SQLSTATE[HY000] [1040] Too many connections

Postgres refuses with:

FATAL: sorry, too many clients already

Postgres gives every connection its own backend process, so the memory cost per connection is far higher than MySQL's per-thread cost and the wall arrives at a lower number. That is why a connection pooler in front of Postgres is standard practice on a small box, while MySQL deployments usually raise max_connections and pay for it in per-connection buffers.

Persistent connections do not move this wall. PDO::ATTR_PERSISTENT, and mysqli with the p: host prefix, keep the connection attached to the worker after the request ends, so the connection count equals your pool size and stays there instead of falling between requests. It removes handshake cost. It does not remove the ceiling, and it makes the peak permanent.

Ask your database what it sees: SHOW VARIABLES LIKE 'max_connections'; and SHOW GLOBAL STATUS LIKE 'Max_used_connections'; on MySQL, or SHOW max_connections; and SELECT count(*) FROM pg_stat_activity; on Postgres. If the peak used sits near the limit while your pool is smaller than that limit, something other than PHP is holding connections open.

These two ceilings are not independent. Raising max_connections raises the database's worst case memory, and that memory comes out of the same budget you divided for PHP in step 2. Buying pool headroom by starving the database moves the failure rather than removing it.

Instruments: the FPM status page and the slow log

Set pm.status_path in the pool config, then expose it to localhost only:

location = /status {
    allow 127.0.0.1;
    deny all;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
curl -s 'http://127.0.0.1/status?full'

Four fields answer the sizing question. active processes and idle processes show the shape of the pool right now. listen queue above zero means requests are waiting for a free child at this instant. max listen queue is the high water mark since the pool started, which catches the spike that happened while you were asleep. max children reached counts how many times the pool hit its ceiling; a zero there, with an empty queue at peak, means the pool is not your bottleneck and you should stop tuning it.

The slow log answers the other half. Set slowlog to a path under /var/log and set request_slowlog_timeout, and FPM writes a PHP stack trace of what a worker was doing at the moment the timer fired. It names the function rather than the URL, which makes it the most useful diagnostic in this list. How long should the timeout be? Shorter than the point at which your web server gives up, so read fastcgi_read_timeout from your own nginx config first and pick a value below it. Then the trace exists before the visitor ever sees the 504.

Read the trace from the top frame down. A database call at the top means the fix is the query or a missing index. curl_exec or file_get_contents against a remote host at the top means the worker is blocked on somebody else's server, and a larger pool only buys you more workers blocked on the same thing.

Is the plan too small, or is the application too heavy?

The honest test, in this order.

  1. Turn on the caching you have not turned on yet. A full page cache serves repeat visitors without entering PHP, so those requests never occupy a child at all. A persistent object cache removes the repeated queries inside the pages that do run. If enabling caching drops your concurrent worker count at peak, plan size was never the problem.
  2. Find out what the box is really doing at peak with vmstat 1. High us with low wa is CPU bound: more memory buys nothing, and more workers make it worse, because the same cores now switch between more runnable processes. High wa points at disk or database. Everything idle while requests still queue means workers are blocked on something remote, or the pool sits below the ceiling you measured.
  3. Check the st column in that same output. Steal time is CPU your VPS asked for and did not receive, and no amount of pool tuning inside the guest changes it: see what steal time means on a shared host.
  4. Only now compare plans. If, with caching on and the pool set to your measured ceiling, every worker is busy while your vCPUs are saturated through the peak, the box is genuinely too small and a larger plan helps in proportion. If workers are busy while the CPU idles, the application is waiting on something, and a larger plan buys you the same waiting with more memory to do it in.

Sizing is not a one time job. Rerun step 1 after a plugin update or a PHP major version upgrade, because per-worker cost is a property of the code you deploy, and the division changes whenever that code does.

FAQ

Why does PHP-FPM keep logging that it reached pm.max_children?

Every child in the pool was busy when another request arrived, so FPM had no free worker and was not allowed to fork one. The message is accurate about the pool and knows nothing about your memory. Before raising the value, measure the resident size of your largest warm worker with ps --no-headers -o rss,args -C php-fpm8.3 | sort -rn | head, and check the available column of free -m during your peak. If there is room for more children, raise it. If there is not, the fix is fewer or cheaper requests, because more workers than memory produces swapping instead of throughput.

How do I know how much memory one PHP-FPM worker uses?

Measure under real traffic rather than at boot, and read the largest worker rather than the average, because your heaviest page can land on every child at once. ps --no-headers -o rss,args -C php-fpm8.3 | grep 'pool www' | sort -rn | head prints RSS in kilobytes per worker. For what the pool truly costs the machine, sum the Pss: value from /proc/<pid>/smaps_rollup across the children, since RSS counts shared library and copy-on-write pages once per process and therefore overstates the total. Never use memory_limit as the figure: it is the per-request abort threshold, not an allocation.

Should I use dynamic, ondemand or static?

static holds every child open, so memory stays flat and no request pays fork latency, which suits a box running one busy site. dynamic keeps a floor of spare children and grows toward the ceiling under load, a middle position between idle cost and spike latency. ondemand keeps nothing idle and forks per request, which is what lets one small VPS host many quiet sites with a pool each. All three share the same pm.max_children ceiling, and the sum of every pool's ceiling still has to fit in the machine's memory.

Will more RAM fix my 502 errors?

Only when the 502s come from saturation and the pool is limited by memory. Read the web server log first. Resource temporarily unavailable on the FPM socket means every child was busy. Connection reset by peer means a child died mid-request, so look for signal 9, an out of memory kill, or signal 11, a crash, in the FPM log; more memory helps the first case and more workers make the second worse. If workers are busy while your vCPUs sit idle, the requests are waiting on the database or on a remote call, and extra memory changes nothing.

Does the pool size limit my database connections too?

Yes, and this is the ceiling most people hit after they fix the pool. A worker holds one database connection while it runs a page, so peak connections from this host is your pm.max_children plus cron and queue processes. MySQL answers past its limit with SQLSTATE[HY000] [1040] Too many connections and Postgres with FATAL: sorry, too many clients already. Persistent connections do not reduce the count, they pin one connection per worker for the worker's life. Compare Max_used_connections against max_connections before you assume PHP is the constraint.