PHP-FPM Pool Configuration and Tuning for Production Nginx Servers

PHP-FPM (FastCGI Process Manager) is the standard way to run PHP behind Nginx. Its defaults are conservative and will not serve a production Drupal or PHP application efficiently. This guide explains how PHP-FPM's process models work, how to calculate appropriate pool sizes, and the configuration directives that make the difference between a server that performs and one that melts under load.

Where Configuration Lives

# Main PHP-FPM config (rarely edited directly)
/etc/php/8.3/fpm/php-fpm.conf

# Pool configurations — edit these
/etc/php/8.3/fpm/pool.d/www.conf

# To run multiple pools (e.g. separate Drupal sites), add files:
/etc/php/8.3/fpm/pool.d/site1.conf
/etc/php/8.3/fpm/pool.d/site2.conf

Process Management Modes

PHP-FPM offers three pm (process manager) modes. Understanding them is prerequisite to tuning:

ModeHow it worksBest for
staticFixed number of workers. Predictable memory, fastest response under constant load.High-traffic single-site servers with dedicated RAM
dynamicWorkers fluctuate between min_spare_servers and max_children. Spawns workers on demand up to the max.General-purpose servers with variable traffic
ondemandNo idle workers; workers spawn per request and die after process_idle_timeout.Multiple pools on low-traffic servers, saving idle RAM

Calculating max_children

This is the most important number in your PHP-FPM config. Too low: requests queue up and users see timeouts. Too high: the server swaps, performance craters.

The formula:

max_children = (Available RAM for PHP) / (Average PHP process memory)

Measure average PHP process memory on a running server:

# Method 1: ps summary
ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1} END {print sum/NR/1024 " MB avg"}'

# Method 2: smem (more accurate, counts shared memory once)
smem -a -P php-fpm8.3 -c "name pss" | awk 'NR>1{sum+=$2} END {print sum/NR/1024 " MB avg PSS"}'

Example calculation for a 4 GB server running Drupal:

Total RAM:         4096 MB
OS + Nginx:        ~400 MB
MariaDB:           ~600 MB
Available for PHP: 3096 MB
Avg Drupal process: ~80 MB (OPcache preloaded)

max_children = 3096 / 80 ≈ 38

# Be conservative — leave headroom for spikes
max_children = 30

Dynamic Mode Configuration

[www]
user  = www-data
group = www-data

listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm = dynamic

; Maximum number of workers — calculated above
pm.max_children = 30

; Workers ready at startup
pm.start_servers = 5

; Keep at least this many idle workers ready
pm.min_spare_servers = 3

; Kill idle workers above this count to free RAM
pm.max_spare_servers = 8

; Recycle workers after this many requests to prevent memory leaks
pm.max_requests = 500

; Slow log — log requests taking longer than 10s
slowlog = /var/log/php8.3-fpm.slow.log
request_slowlog_timeout = 10s

; Terminate requests after 60s (also set in php.ini as max_execution_time)
request_terminate_timeout = 60s

; Status page — restrict to localhost in Nginx
pm.status_path = /php-fpm-status

Static Mode Configuration (High-Traffic Single Site)

[www]
user  = www-data
group = www-data

listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data

pm = static
pm.max_children = 30   ; All workers start immediately, stay running

pm.max_requests = 1000 ; Recycle more aggressively on a busy server

slowlog = /var/log/php8.3-fpm.slow.log
request_slowlog_timeout = 5s
request_terminate_timeout = 30s

pm.status_path = /php-fpm-status

Static mode pre-allocates all workers and avoids the latency of spawning new processes under burst traffic. Use it when RAM allows all workers to run simultaneously.

Multiple Pools (Multiple Sites)

Each pool runs under its own user, socket, and memory limits — ideal for hosting multiple Drupal sites on one server:

; /etc/php/8.3/fpm/pool.d/site1.conf
[site1]
user  = site1
group = site1

listen = /run/php/site1.sock
listen.owner = site1
listen.group = www-data
listen.mode  = 0660

pm                    = ondemand
pm.max_children       = 10
pm.process_idle_timeout = 30s
pm.max_requests       = 200

php_admin_value[error_log] = /var/log/php8.3-site1.log
php_admin_flag[log_errors]  = on
; /etc/php/8.3/fpm/pool.d/site2.conf
[site2]
user  = site2
group = site2

listen = /run/php/site2.sock
listen.owner = site2
listen.group = www-data
listen.mode  = 0660

pm                    = dynamic
pm.max_children       = 15
pm.start_servers      = 2
pm.min_spare_servers  = 1
pm.max_spare_servers  = 4
pm.max_requests       = 500

Reference each pool's socket in its corresponding Nginx server block:

# Nginx — site1
location ~ \.php$ {
    fastcgi_pass unix:/run/php/site1.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

PHP-FPM Status Page in Nginx

# Nginx snippet — restrict status to monitoring IP
server {
    ...
    location = /php-fpm-status {
        fastcgi_pass  unix:/run/php/php8.3-fpm.sock;
        include       fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        allow 127.0.0.1;
        allow 10.0.0.0/8;   # monitoring server
        deny  all;
    }
}
# Poll the status page
curl http://localhost/php-fpm-status

# Sample output:
pool:                 www
process manager:      dynamic
start time:           01/Apr/2025:09:00:00 +0000
start since:          86400
accepted conn:        41293
listen queue:         0           <-- >0 means workers are saturated
listen queue len:     128
idle processes:       4
active processes:     3
total processes:      7
max active processes: 12
max children reached: 0          <-- >0 means max_children was too low at some point

Environment Variables and php.ini per Pool

[www]
; Override php.ini values per pool
php_admin_value[memory_limit]    = 256M
php_admin_value[max_execution_time] = 60
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size]   = 33M
php_admin_flag[display_errors]   = off
php_admin_flag[log_errors]       = on
php_admin_value[error_log]       = /var/log/php8.3-www-errors.log
php_admin_value[date.timezone]   = Europe/Copenhagen

; Pass environment variables to PHP workers
env[DRUPAL_HASH_SALT] = "your_hash_salt_here"
env[DATABASE_URL]     = "mysql://drupal:pass@localhost/drupal"

OPcache Integration

OPcache is per-FPM pool process — ensure it is correctly configured in /etc/php/8.3/fpm/php.ini:

[opcache]
opcache.enable                 = 1
opcache.memory_consumption     = 256   ; MB — increase if opcache.oom_restarts_total grows
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files  = 20000  ; Must exceed your codebase file count
opcache.validate_timestamps    = 0      ; Production: disable filesystem checks
opcache.revalidate_freq        = 0
opcache.fast_shutdown          = 1

; PHP 8+ preloading — dramatically reduces memory per worker
opcache.preload                = /var/www/drupal/vendor/autoload.php
opcache.preload_user           = www-data

After changing OPcache settings, restart PHP-FPM — not just reload:

sudo systemctl restart php8.3-fpm

Monitoring and Alerting

# Watch the status page continuously
watch -n 2 'curl -s http://localhost/php-fpm-status'

# Alert when listen queue is non-zero (workers saturated)
# Cron every minute:
* * * * * curl -s http://localhost/php-fpm-status | grep -q "listen queue: [^0]" \
  && echo "PHP-FPM queue backing up on $(hostname)" | mail -s "PHP-FPM Alert" admin@example.com

# Prometheus: use php-fpm_exporter for metrics scraping
# https://github.com/hipages/php-fpm_exporter
php-fpm_exporter server --phpfpm.scrape-uri tcp://127.0.0.1:9000/php-fpm-status

Summary Checklist

  • Measure average PHP process memory with ps or smem before setting pm.max_children.
  • Use pm = dynamic for general servers; pm = static when RAM allows all workers to run; pm = ondemand for low-traffic pools.
  • Set pm.max_requests = 500–1000 to recycle workers and prevent memory leaks.
  • Enable the status page and alert when listen queue is non-zero.
  • Use separate pools for separate sites to isolate failures and apply per-site memory limits.
  • Set per-pool php_admin_value overrides to avoid a global php.ini that pleases no one.
  • Configure OPcache with validate_timestamps=0 and preloading on production.
  • Monitor max children reached counter — if it increments, raise max_children or optimise PHP memory usage.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.