$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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_servers and pm.max_spare_servers, up to pm.max_children. Best general choice for shared or multi-site servers where load fluctuates.
  • pm = static — Forks exactly pm.max_children workers 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_pass doesn't match listen = in the pool file, or the socket permissions don't allow the Nginx worker user to write. Double-check listen.owner, listen.group, and listen.mode.
  • Workers are constantly at max_children — Check max children reached in 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_requests to 200–300 to recycle workers more aggressively while you locate the leak. Extensions like xdebug or 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_timeout is not zero. On some systems 0 disables 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=1 to prevent cross-user cache hits.
tested on:Ubuntu 24.04Debian 12Rocky 9Arch rolling

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