— Article — № 123

123 —Operations

PHP-FPM pool sizing: 2GB, 4GB and 8GB VPS, compared

A WooCommerce shop crashes at lunch. PHP-FPM is at 100% pool capacity and the host wants you to size up the VPS. Before you click, here's the math.

Overhead still life on bone linen: PHP-FPM tuning worksheet, manila folder, brass key, fountain pen, red wax seal.
Hero · staged still№ 123

A Dutch agency we work with sent us a Loom at 13:42 last Tuesday. Their client's WooCommerce shop, sitting on a 4GB DigitalOcean droplet, was throwing 502s every lunchtime. The nginx error log had the line everyone reading this has seen before:

2026-06-09 12:47:14 [error] 1142#1142: *4319 connect() to unix:/run/php/php8.2-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream

The host wanted them to upgrade to an 8GB box. The actual fix was a one-line edit in /etc/php/8.2/fpm/pool.d/www.conf and a php-fpm reload. PHP-FPM pool sizing is one of those tuning jobs where the defaults ship for a hypothetical small site and stay there until lunch traffic surfaces the problem.

This post walks through pm.max_children math on the three VPS sizes we see most often under a tired WordPress shop: 2GB, 4GB and 8GB. The numbers at the end are real, taken from production boxes we've watched for the last twelve months.

The only formula that matters

Everything else is a footnote on this:

pm.max_children = (Total RAM - reserved) / average PHP-FPM worker RSS

Reserved is the memory you owe the kernel, MySQL, nginx, Redis if you're running it, and whatever cron jobs run inside the same box. Worker RSS is what one php-fpm process holds resident under your real workload, not a synthetic benchmark.

You can read it off a live box with:

ps -ylC php-fpm8.2 --sort:rss | awk '{sum+=$8; n++} END {print "avg KB:", sum/n}'

On a stock WordPress 6 install with a single page builder and twenty plugins, that number tends to land between 40 and 80 MB. WooCommerce with a heavy theme and bookkeeping plugins pushes it past 100 MB. Get this number wrong and the rest of the math is decoration.

The 2GB box

Cheapest VPS in the catalogue, often a single vCPU. We see this size under brochure sites, small dental practices, and the kind of WordPress shop that does 60 orders a week. After Ubuntu, MySQL (with innodb_buffer_pool_size at 256M), nginx, fail2ban and a small Redis instance, you have roughly 900 MB to spend on PHP.

Assume 60 MB per worker. 900 / 60 = 15. Round down to leave headroom for OPcache growth and the occasional fat request:

; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 14
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

Fourteen workers will saturate a single vCPU before they run out of RAM, which is the right failure mode. If you're seeing 502s on this box, the answer is almost never "add more children." It's caching: page cache, object cache, and a CDN in front of the static assets.

The 4GB box

The shop in the Loom was here. After MySQL with a 1 GB buffer pool, Redis, nginx and the OS, around 2.4 GB is yours. At 65 MB per worker that maths out to 36. They had pm.max_children set to 5, the default in some control panel installs. Five workers will hit a wall at lunch on any shop that does more than ten concurrent checkouts.

pm = dynamic
pm.max_children = 35
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests = 500

After the reload, the 502s stopped. The box now sits at around 60% pool usage during the lunch peak and 12% the rest of the day. No upgrade required.

The 8GB box

The size we'd actually recommend for a real WooCommerce shop doing 500+ orders a week. After a generous 2 GB MySQL buffer pool, 256 MB for Redis, the OS, nginx and a backup agent, you're left with about 5.4 GB. At 80 MB per worker, that's 67. We round to 70 and accept that, on a four vCPU droplet, we'll be CPU-bound before we're memory-bound:

pm = dynamic
pm.max_children = 70
pm.start_servers = 12
pm.min_spare_servers = 6
pm.max_spare_servers = 18
pm.max_requests = 500

On this size of box, pm.max_requests starts to matter. We set it to 500 to recycle workers that pick up plugin memory leaks. The php.net FPM manual still describes this as the canonical defence against unbounded growth, and we've yet to see a WordPress install where the leak rate doesn't justify a recycle every few hundred requests.

Three things that quietly wreck the math

The formula assumes you've accounted for what else is on the box. The three traps we keep finding on a legacy site running on a single VPS:

MySQL's buffer pool

Default innodb_buffer_pool_size on a fresh Ubuntu install is 128 MB, but a lot of ops engineers set it to 50 to 70% of total RAM out of habit. On a 4GB box, that's 2 GB gone before you've started counting PHP. Run mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size'" on the customer's box before you draft a pool config.

OPcache is shared

Plenty of capacity calculators count opcache.memory_consumption per worker. It's shared memory, allocated once. If you've set it to 256 MB, that's 256 MB total, not 256 MB times pm.max_children. This is in the OPcache configuration docs but the misread is annoyingly common.

Object cache RSS lives inside the worker

Redis is separate. But the Redis client library, the WordPress object cache drop-in, and the serialised cache entries the worker holds in its own memory during a request all count toward worker RSS. On WooCommerce shops with the official Redis Object Cache plugin, we see this push the average worker from 60 MB to 90 MB on a long product-list request.

The smallest change you can make today

Open a shell on the box. Run the ps oneliner above to see the actual average RSS of your PHP-FPM workers. Open /etc/php/8.2/fpm/pool.d/www.conf and sanity-check pm.max_children against the formula. If it's wildly off (you'll know, the gap is usually 3x or more), fix it, reload php-fpm, and watch the next traffic peak.

When we built Pier we ran into this exact thing on customer after customer, and the way we ended up handling it was to surface the live PHP-FPM pool config, MySQL buffer pool size and average worker RSS as a single page you can open before touching the site. It uses the same MySQL editor and version history as the rest of the app, so the config edit and the reload are one rollback away.

The smallest thing you could do today: SSH into your busiest shop's box, run the ps oneliner, and put the real average worker RSS into a comment at the top of www.conf. Next time someone tunes the pool, the math will be in front of them.

— Questions —

What's a safe pm.max_children for a 4GB VPS running WooCommerce?

Around 30 to 35 if MySQL's buffer pool is 1 GB and Redis is running. Measure your actual worker RSS with ps before committing to a number.

Should I run pm = dynamic, static or ondemand?

Dynamic for most WordPress shops. Static if traffic is steady and you want predictable memory. Ondemand only on dev boxes or rarely visited sites.

Why am I seeing 502s when the pool is only at 50% capacity?

Usually listen.backlog (default 511) or net.core.somaxconn, not pm.max_children. Raise both before adding more children to the pool.

What does pm.max_requests = 500 actually do?

It recycles each worker after 500 requests, defending against unbounded memory growth from plugin leaks. Set it lower if you see drift; rarely raise it.