Optimize PHP-FPM Pool on a Linux VPS: pm.max_children and the End of 502

Fix 502 Bad Gateway by calculating pm.max_children, pm.start_servers and pm.max_requests in the PHP-FPM pool on your Linux VPS with Nginx.

A poorly sized PHP-FPM pool is the number one cause of 502 Bad Gateway on VPS instances running Nginx + PHP. The symptom appears under moderate load — 50, 100 simultaneous requests — and disappears when traffic drops, pushing the diagnosis in the wrong directions (Nginx, database, network). In reality, the pool is exhausted: every PHP-FPM worker is busy, Nginx tries to send the request over FastCGI, gets refused or times out, and returns 502 to the client.

This tutorial is for developers or sysadmins running a Linux VPS with Nginx + PHP-FPM on any PHP application (Laravel, WordPress, Symfony, Magento) who are seeing sporadic 502s in production or who simply want to size the pool based on measurement instead of values copied from blog posts. We will cover how to measure real worker memory, calculate pm.max_children using the correct formula, tune the auxiliary parameters (pm.start_servers, pm.min_spare_servers, pm.max_spare_servers, pm.max_requests) and validate the configuration with Nginx serving real load.

Estimated execution time: 25 minutes, including a controlled load test to validate before and after.

Prerequisites

You need a Linux VPS with root or sudo privileges, PHP-FPM installed and running, and Nginx already routing requests to the PHP-FPM Unix socket. The examples use Ubuntu 24.04 LTS with PHP 8.3, but the principles apply to any PHP-FPM version (7.4 onwards) and to Debian, AlmaLinux or Rocky Linux with minimal adjustments to file paths.

Prerequisites

Active SSH access, sudo, and a PHP application running in production or staging with representative traffic. You will need to collect metrics with the pool under real load — measuring while idle produces wrong numbers.

System Ubuntu 24.04 LTS
PHP 8.3 (php-fpm)
Pool config /etc/php/8.3/fpm/pool.d/www.conf
Unix socket /run/php/php8.3-fpm.sock

Measure the real memory of your workers

Before touching any parameter, you need to know how much resident memory (RSS) each PHP-FPM worker consumes with your specific application. This number varies dramatically between stacks — a Laravel worker with heavy Eloquent usage may consume 80–120 MB, a simple WordPress sits at 40–60 MB, and an optimized Symfony API can run at 30 MB. Assuming 60 MB because “it’s the default” is the most common way to undersize the pool.

01

Generate representative load against the application. In staging, use a tool like ab (ApacheBench) or wrk against the heaviest endpoints:

ab -n 1000 -c 20 https://your-domain.com/heavy-endpoint

In production, natural traffic during peak hours works. The goal is to force workers to process real requests and end up in the post-execution state with the application loaded in memory.

02

With the pool under load, list workers ordered by RSS:

ps -ylC php-fpm8.3 --sort:rss

The output has an RSS column in KB. Ignore the master process (usually the first one, smaller) and focus on the workers (state S after processing requests). Those are the ones that matter for the calculation.

03

Compute the average RSS of active workers:

ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1; count++} END {print "Average:", sum/count/1024, "MB"}'

Write down this value. On a typical Laravel application, expect something between 70 and 110 MB. This is the number that goes into the pm.max_children formula.

Calculate pm.max_children

The formula is simple and exists to be respected: pm.max_children = (RAM available for PHP) ÷ (average RSS per worker). The non-obvious point is “available for PHP” — it is not the total RAM of the VPS. You need to subtract what MySQL, Redis, Nginx, the kernel and buffers consume at peak.

04

Check total RAM and current usage:

free -m

On a 4 GB VPS running MySQL (~800 MB), Redis (~150 MB), Nginx (~50 MB) and the base system (~400 MB), roughly 2.5 GB is left for PHP. On a 2 GB VPS with the same stack, about 600 MB is left — much less than blogs usually assume.

05

Apply the formula. With 2.5 GB for PHP and an average RSS of 80 MB per worker:

2560 MB ÷ 80 MB = 32 workers

Reserve a safety margin of 15–20%. Use 26 or 27 as pm.max_children. This buffer prevents RSS spikes (workers processing atypical endpoints) from pushing the system into swap, which kills performance much faster than 502.

Swap is worse than 502

If you blow through RAM and the system falls into swap, latency climbs to seconds and the whole VPS becomes unresponsive. An occasional 502 is recoverable; swap thrashing leads to a full outage. Always undersize before oversizing.

Tune the pool in www.conf

With pm.max_children calculated, open /etc/php/8.3/fpm/pool.d/www.conf and tune the related parameters. They are not independent — misaligned values cause so much churn that the gain from a correct max_children disappears in spawn overhead.

06

Edit the pool:

sudo nano /etc/php/8.3/fpm/pool.d/www.conf

Find and adjust these lines. The values below assume the example of 26 workers calculated above — adapt them to your own calculation:

pm = dynamic
pm.max_children = 26
pm.start_servers = 7
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500

Rule of thumb: start_servers ≈ 25% of max_children, min_spare_servers ≈ 15%, max_spare_servers ≈ 40%. This keeps enough idle workers around to absorb spikes without wasting RAM during idle periods.

07

Enable the pool status page — you will need it for monitoring. Uncomment or add:

pm.status_path = /fpm-status

Save and close the file.

08

Validate the syntax before applying:

sudo php-fpm8.3 -t

The output should be configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful. If you get an error, the pool will not come up and the site goes down — do not move forward with a pending error.

09

Reload PHP-FPM without killing in-flight requests:

sudo systemctl reload php8.3-fpm

Reload is graceful: current workers finish what they are processing, the master spawns new ones with the new config, and the transition is transparent to the user. Use restart only if reload fails.

Expose the status through Nginx to monitor it

pm.status_path only works if Nginx routes the request to PHP-FPM. Without it, you size blindly and only discover the problem when the 502 comes back.

10

Edit the Nginx vhost and add a protected location:

location ~ ^/(fpm-status|fpm-ping)$ {
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
}

IP restriction is essential — a public status page exposes load and worker count to anyone on the internet, which is useful information for an attacker planning a DoS.

11

Test the config and reload:

sudo nginx -t && sudo systemctl reload nginx

Access http://127.0.0.1/fpm-status via SSH (curl localhost) and confirm the page returns metrics: accepted conn, listen queue, active processes, max active processes.

Verification

Generate load again and watch two critical metrics on the status page. The first is listen queue: if it is consistently greater than zero, the pool is saturating and requests are piling up — you need to raise max_children or investigate slowness in the application. The second is max children reached: if this counter goes up, you hit the ceiling at least once since the last reload.

curl -s http://127.0.0.1/fpm-status

Healthy output under load:

pool:                 www
process manager:      dynamic
accepted conn:        4823
listen queue:         0
max listen queue:     2
idle processes:       6
active processes:     11
total processes:      17
max active processes: 19
max children reached: 0

If max children reached stayed at 0 during the load test, your sizing is correct. If it went up, run ps again under load, recompute the average RSS (workers may be consuming more than what you measured initially) and adjust.

Troubleshooting

502 keeps showing up after tuning

Check /var/log/php8.3-fpm.log looking for the string server reached max_children setting. If it appears, your calculation underestimated memory or real load is greater than the test. Raise max_children by 20% and run the measurement cycle again. If the message does not appear but 502 persists, the problem is not the pool — look into fastcgi_read_timeout on Nginx (the default 60s can be low for heavy endpoints) and slow queries on the database.

Workers consuming much more RAM than expected

Memory leaks in PHP applications accumulate across hundreds of requests. Reduce pm.max_requests from 500 to 200 — this recycles workers more aggressively and kills the leak before it grows. If the leak is very fast (a worker doubles in size in 50 requests), the problem is in the code — profile with xdebug.profiler_enable in staging to find the cause.

Do not use pm = static without capacity planning

pm = static allocates every worker at PHP-FPM boot. If you configure pm.max_children = 100 on a 2 GB VPS and use static, boot will consume all the RAM immediately and the system will enter thrashing before the first request arrives.

Next steps

With the pool sized, a few logical next steps to strengthen the stack: configure OPcache with appropriate opcache.memory_consumption to avoid recompiling on every hit, tune fastcgi_read_timeout on Nginx for endpoints known to be slow, export pool metrics to an observability system (Grafana, Datadog), and add health checks on Nginx via fastcgi_next_upstream for graceful failover.

If you are putting this into production and want to start from a stable base, a Hostini VPS comes with PHP-FPM 8.3 preinstalled, Nginx tuned, and NVMe SSD — which drastically reduces I/O time per request and directly impacts the pm.max_children your application needs.

Frequently asked questions

What is the ideal value for pm.max_children?

There is no universal ideal value — it depends on the average RSS of each worker and the available RAM. The formula is (RAM available for PHP) ÷ (average RSS per process). On a 4 GB VPS with 60 MB per worker and 2 GB reserved for PHP, that is 33 workers. Measure before guessing.

Why am I getting 502 Bad Gateway only under load?

Almost always it is an exhausted pool: pm.max_children too low or workers stuck on I/O. Nginx sends a request, PHP-FPM rejects it or takes longer than fastcgi_read_timeout, and Nginx returns 502. Check php-fpm.log for the string 'server reached max_children setting'.

Should I use pm = dynamic, static or ondemand?

dynamic is the default and works in 95% of cases. static only makes sense on a VPS dedicated to a single application with predictable traffic — it eliminates spawn churn. ondemand saves RAM during idle periods but adds latency on the first request — good for low-traffic sites, bad for APIs.

Does pm.max_requests affect performance?

Yes. It defines how many requests each worker processes before being recycled. A low value (100) kills memory leaks quickly but increases CPU spent on spawning. A high value (1000) is efficient but lets leaks grow. 500 is a safe middle ground for most stacks (Laravel, WordPress, Symfony).

How do I know the real RSS of my PHP-FPM workers?

Run `ps -ylC php-fpm8.3 --sort:rss` with the pool under real load (not idle). The RSS column shows resident memory in KB. Take the average of workers in state S (sleeping after a request) — that is the stable consumption that matters for the pm.max_children calculation.

Do I need to restart PHP-FPM or is reload enough?

`systemctl reload php8.3-fpm` is enough for changes in pm.* — a graceful reread without killing in-flight requests. Use restart only if you change global options (error_log, daemonize) or if the pool is stuck. Reload is safe in production.

Topics:
Next steps Ryzen cloud with NVMe storage and always-on DDoS protection.Go live on a Hostini VPS →
Was this tutorial helpful?
Chat on WhatsApp