Tune PHP-FPM Pools for Production
Configure PHP-FPM pool process managers (dynamic, static, ondemand), calculate max_children from real memory usage, enable slowlog, and monitor with the status page.
Before you start
- ▸PHP-FPM installed and a working Nginx or Apache virtual host
- ▸sudo or root access to edit pool configuration files
- ▸Application running under real or simulated traffic so worker memory can be measured accurately
PHP-FPM's default pool configuration is designed for compatibility, not performance. On a production server, leaving pm = dynamic at its defaults—or worse, running a single www pool for every application—will cap your throughput and make performance problems hard to trace. This guide walks through choosing the right process manager mode, calculating worker counts, enabling the slow request log, and verifying the pool behaves as intended under load.
Understanding the Three Process Manager Modes
PHP-FPM offers three values for the pm directive, each with different trade-offs.
- pm = dynamic — Maintains a variable number of workers between
pm.min_spare_serversandpm.max_spare_servers, up topm.max_children. Best general choice for shared or multi-site servers where load fluctuates. - pm = static — Forks exactly
pm.max_childrenworkers at startup and keeps them alive. Eliminates fork/spawn overhead entirely. Best when RAM is plentiful and load is consistently high (e.g., a dedicated WordPress or Laravel host). - pm = ondemand — Spawns workers only when requests arrive and kills idle ones after
pm.process_idle_timeout. Ideal for low-traffic apps or staging environments where you want a near-zero memory floor.
Locating and Structuring Pool Files
Pool files live under the directory referenced by include= in the main php-fpm.conf. The typical paths are:
- Debian/Ubuntu:
/etc/php/8.3/fpm/pool.d/ - Fedora/RHEL/Rocky:
/etc/php-fpm.d/ - Arch:
/etc/php/php-fpm.d/
Create one .conf file per application rather than cramming everything into www.conf. Disable the default pool on production by renaming it or setting pm.max_children = 0:
sudo mv /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/www.conf.disabled
Calculating max_children
The most common production mistake is guessing max_children. The correct approach is empirical: measure the average RSS memory of your PHP workers under real traffic, then divide available RAM.
Step 1: Measure worker memory
After the application has been running under normal load, sample the resident memory of current FPM workers:
ps --no-headers -o rss,comm -C php-fpm8.3 | awk '/php-fpm/{sum+=$1; count++} END {printf "Avg RSS: %d kB\n", sum/count}'
Typical output on a medium WordPress site is 40,000–80,000 kB (40–80 MB) per worker. The exact value depends on installed extensions, OPcache settings, and application size.
Step 2: Apply the formula
Leave headroom for the OS, Nginx/Apache, databases, and OPcache shared memory:
# Example: 4 GB server, ~1.5 GB reserved for OS + MySQL + OPcache
# Available for FPM: 2500 MB
# Average worker: 60 MB
# max_children = floor(2500 / 60) = 41
Rounding down is safer than rounding up. Overcommitting RAM causes the OOM killer to terminate workers, which produces cryptic 502 errors.
Writing a Production Pool File
Below is a commented production pool for a Laravel application running as a dedicated Unix socket. Adjust values to match your own memory measurements.
sudo nano /etc/php/8.3/fpm/pool.d/myapp.conf
[myapp]
user = www-data
group = www-data
; Unix socket is faster than TCP for same-host Nginx
listen = /run/php/php8.3-fpm-myapp.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
; Recycle workers after N requests to prevent memory creep
pm.max_requests = 500
; Kill a worker that takes longer than 30 seconds
request_terminate_timeout = 30s
; Slow log: log requests exceeding 3 seconds
slowlog = /var/log/php-fpm/myapp-slow.log
request_slowlog_timeout = 3s
; Status and ping endpoints for monitoring
pm.status_path = /fpm-status
ping.path = /fpm-ping
; Separate error log per pool
php_admin_value[error_log] = /var/log/php-fpm/myapp-error.log
php_admin_flag[log_errors] = on
; Harden: disable dangerous functions per pool
php_admin_value[disable_functions] = exec,passthru,shell_exec,system
For a static pool, remove the spare server directives and set a fixed count:
pm = static
pm.max_children = 40
For an ondemand pool:
pm = ondemand
pm.max_children = 40
pm.process_idle_timeout = 10s
Enabling and Reading the Slow Log
The slow log records a full PHP stack trace for any request exceeding request_slowlog_timeout. Create the log directory and apply correct ownership before restarting:
sudo mkdir -p /var/log/php-fpm
sudo chown www-data:www-data /var/log/php-fpm
After generating some traffic, inspect slow requests:
sudo tail -f /var/log/php-fpm/myapp-slow.log
Each entry shows the script path, elapsed time, and the call stack at the moment of the timeout. This is far more actionable than generic application-level profiling for identifying N+1 queries or unindexed database calls.
Exposing the FPM Status Page
With pm.status_path = /fpm-status set in the pool, restrict the endpoint in Nginx so only trusted IPs can reach it:
location /fpm-status {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm-myapp.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
Fetch the status in JSON format for easier parsing by monitoring agents:
curl -s 'http://127.0.0.1/fpm-status?json' | python3 -m json.tool
Key fields to watch: active processes, idle processes, max active processes, and max children reached. A non-zero max children reached counter means you hit the ceiling—either increase max_children (if RAM allows) or profile and optimise the application.
Applying and Verifying the Configuration
Validate before reloading
# Debian/Ubuntu
sudo php-fpm8.3 --test
# Fedora/RHEL/Rocky
sudo php-fpm --test
# Arch
sudo php-fpm --test
Reload without dropping connections
# Debian/Ubuntu
sudo systemctl reload php8.3-fpm
# Fedora/RHEL/Rocky
sudo systemctl reload php-fpm
# Arch
sudo systemctl reload php-fpm
A reload sends SIGUSR2 to the master process, which gracefully replaces workers without refusing in-flight requests. Use restart only if the master process itself needs to be replaced (e.g., after a PHP binary upgrade).
Confirm the pool socket exists
ls -l /run/php/php8.3-fpm-myapp.sock
Check worker counts live
watch -n2 'ps --no-headers -o pid,rss,stat,comm -C php-fpm8.3 | sort -k2 -n'
Troubleshooting Common Issues
- 502 Bad Gateway from Nginx — The socket path in Nginx's
fastcgi_passdoesn't matchlisten =in the pool file, or the socket permissions don't allow the Nginx worker user to write. Double-checklisten.owner,listen.group, andlisten.mode. - Workers are constantly at max_children — Check
max children reachedin the status page. If it climbs under normal load, add workers if RAM is available; otherwise investigate slow queries with the slowlog. - Memory grows unboundedly over time — Lower
pm.max_requeststo 200–300 to recycle workers more aggressively while you locate the leak. Extensions likexdebugor Blackfire can identify the exact allocation site. - Slow log is empty despite slow requests — Verify the log directory is writable by the pool user and that
request_slowlog_timeoutis not zero. On some systems0disables the feature rather than logging everything. - OPcache warnings after adding a new pool — OPcache shared memory is global. Multiple pools share the same OPcache segment by default. If pools run as different users, you may need
opcache.validate_permission=1to prevent cross-user cache hits.
Frequently asked questions
- What is a safe starting value for pm.max_requests?
- Between 200 and 1000 for most applications. Lower values (200–300) recycle workers more often, which limits memory creep from leaky extensions but adds minor fork overhead. Start at 500 and adjust based on observed memory growth.
- Should I use a Unix socket or a TCP port for the pool listen directive?
- Use a Unix socket when Nginx and PHP-FPM are on the same host—it avoids TCP stack overhead and is measurably faster under high concurrency. Use a TCP address (e.g., 127.0.0.1:9001) only when PHP-FPM runs on a separate server.
- Can I run multiple pools for different PHP versions simultaneously?
- Yes. Install both PHP versions, ensure each has its own php-fpm service (e.g., php8.1-fpm and php8.3-fpm), and point each pool's listen directive to a unique socket path. Nginx selects the correct socket per virtual host.
- Why does max children reached keep incrementing even after I increased max_children?
- The bottleneck may be upstream—a slow database or external API is holding workers open. Use the slowlog to find long-running requests. Increasing max_children without fixing the root cause just delays the same problem while consuming more RAM.
- Is pm = static always faster than pm = dynamic?
- Not always. Static eliminates spawn latency but holds full memory even at idle, which can starve other services during low-traffic periods. On a dedicated single-app host with consistent traffic, static is preferable. On shared or variable-load servers, dynamic is usually the better default.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.