PHP-FPM Pool Isolation: Stop One WordPress Site From Crashing the Rest

· 10 min read

A client messaged me on a Monday morning: five of their WordPress sites were down. All five ran on the same 8GB VPS, alongside a WooCommerce store that was technically responding — if you were willing to wait 40 seconds per page. I SSH'd in and found the problem in under a minute.

Every PHP-FPM worker was occupied. All six sites shared a single pool, and the WooCommerce store had consumed every last process.

The symptom

Five sites returning 502 Bad Gateway. The sixth — a WooCommerce store with around 4,000 products — was grinding through requests at glacial speed. Nginx was healthy, MariaDB was idle, server load was only 2.1 on four cores. The bottleneck was purely PHP-FPM.

ps -C php-fpm8.3 -o pid,rss,etime,args --no-headers | wc -l
# 32

The pool was configured with pm.max_children = 30, and every child was busy. I checked what the longest-running workers were doing:

ps -C php-fpm8.3 -o pid,etime,rss --no-headers | sort -k2 -t: -rn | head -5
18432  12:34  186420
18419  11:58  192048
18445  10:22  178336
18401  09:47  184712
18456  08:15  171024

Five workers had been running for over eight minutes, consuming 170–190MB of RAM each. That's not normal web traffic — those are long-running background processes. I traced one:

ls -la /proc/18432/cwd
# lrwxrwxrwx 1 www-data www-data 0 Aug 18 09:14 /proc/18432/cwd -> /var/www/store.example.com/htdocs

The WooCommerce store. Someone had triggered a product CSV import via the admin, and it was processing rows in batches — each batch holding a PHP-FPM worker open. Combined with WooCommerce's Action Scheduler firing webhooks and inventory sync in the background, the store had quietly consumed 24 of the 30 available workers. The remaining six were serving all six sites' regular traffic — and losing.

Why a shared pool is a single point of failure

The default PHP-FPM installation creates one pool: [www]. Every site on the server shares it. The configuration typically lives at /etc/php/8.3/fpm/pool.d/www.conf:

[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
pm = dynamic
pm.max_children = 30
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10

This means one site's runaway process can starve every other site, a compromised site's PHP process can read files belonging to other sites (same Unix user), you can't restart PHP-FPM for one site without bouncing all of them, and you have no visibility into which site is consuming which resources.

The fix: one pool per site

I created a dedicated PHP-FPM pool for each site. On Ubuntu/Debian, each pool is a separate .conf file in /etc/php/8.3/fpm/pool.d/. Here's the pool for the WooCommerce store:

; /etc/php/8.3/fpm/pool.d/store.conf
[store]
user = store
group = store
listen = /run/php/php8.3-fpm-store.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 12
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

request_terminate_timeout = 300s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/store-slow.log

php_admin_value[error_log] = /var/log/php-fpm/store-error.log
php_admin_value[open_basedir] = /var/www/store.example.com/:/tmp/
php_admin_value[memory_limit] = 512M

And a lighter pool for a brochure site that gets minimal traffic:

; /etc/php/8.3/fpm/pool.d/brochure.conf
[brochure]
user = brochure
group = brochure
listen = /run/php/php8.3-fpm-brochure.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

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

request_terminate_timeout = 60s

php_admin_value[error_log] = /var/log/php-fpm/brochure-error.log
php_admin_value[open_basedir] = /var/www/brochure.example.com/:/tmp/
php_admin_value[memory_limit] = 256M

The differences that matter:

Separate Unix users. Each site's files are owned by its pool user. A compromised plugin in one site can't read another site's wp-config.php.

Per-site pm.max_children. The WooCommerce store gets 12 workers because it handles background processing, cron, and heavier page loads. The brochure site gets 4 — it rarely needs more than one.

pm = ondemand for low-traffic sites. Workers spin up only when a request arrives and die after 30 seconds of inactivity. This keeps memory free for the sites that need it.

open_basedir lockdown. Each pool can only access its own document root and /tmp. Even if an attacker gets code execution, they're jailed to that one site.

Separate error and slow logs. When something breaks at 2am, I know exactly which site caused it without grepping through a shared log.

Wiring it into Nginx

Each Nginx server block points to the correct pool's socket:

server {
    server_name store.example.com;
    root /var/www/store.example.com/htdocs;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm-store.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

If you're already using a fastcgi_pass directive with the default php8.3-fpm.sock, the only change is swapping the socket path. One line per site.

Sizing pools: the memory calculation

With isolated pools, you need to budget memory deliberately. The formula I use:

Available RAM for PHP = Total RAM - MariaDB - Nginx - OS overhead - Redis

On an 8GB server:

8192 MB total
- 1500 MB MariaDB (InnoDB buffer pool + connections)
-  200 MB Nginx
-  800 MB OS + system services
-  256 MB Redis
= 5436 MB available for PHP-FPM

Each PHP-FPM worker averages 80MB for a standard WordPress site and 120MB for a WooCommerce store with large product catalogues. With six sites:

WooCommerce store:  12 children × 120 MB = 1,440 MB
Agency portfolio:    8 children ×  80 MB =   640 MB
Brochure site 1:     4 children ×  80 MB =   320 MB
Brochure site 2:     4 children ×  80 MB =   320 MB
Brochure site 3:     4 children ×  80 MB =   320 MB
Brochure site 4:     4 children ×  80 MB =   320 MB
                                          ─────────
Total worst case:                          3,360 MB

That leaves a 2GB buffer. The ondemand pools won't hit their ceiling unless traffic spikes — most of the time, those brochure sites are running zero workers.

Creating the system users

Each pool needs its own user. I create them without login shells:

sudo useradd -r -s /usr/sbin/nologin -d /var/www/store.example.com store
sudo chown -R store:store /var/www/store.example.com/htdocs
sudo chmod 750 /var/www/store.example.com/htdocs

WordPress needs write access to wp-content/uploads and wp-content/cache, which is already handled by the chown. Nginx needs read access to serve static files, so add the www-data user to the site group:

sudo usermod -aG store www-data

The timeout that actually matters

request_terminate_timeout is the safety net for runaway PHP processes. When a request exceeds this value, the PHP-FPM master kills the worker — no negotiation.

I set it differently per pool:

  • WooCommerce stores: 300s. Product imports, bulk operations, and Action Scheduler hooks genuinely need longer execution times. But five minutes is the ceiling — anything running longer is broken.
  • Standard WordPress sites: 60s. No legitimate front-end request should take a minute.
  • Low-traffic brochure sites: 30s. A 30-second PHP process on a simple site is a stuck connection or a misbehaving plugin.

Set request_terminate_timeout slightly higher than PHP's max_execution_time so PHP gets a chance to throw Maximum execution time exceeded and log a stack trace before the FPM master kills the process:

request_terminate_timeout = 65s
php_admin_value[max_execution_time] = 60

If you're also using the PHP-FPM slow log, enable it per pool. You get per-site backtraces showing exactly which function was hanging when the timeout fired.

After the migration

The next time the client ran a WooCommerce product import, their store slowed down slightly as expected — but the other five sites kept serving requests at normal speed. The import consumed 8 of its 12 allocated workers, leaving 4 for regular store traffic. The brochure sites' pools weren't touched.

Control panels like CloudPanel and HestiaCP create per-site pools automatically — if you're using one, check that they're actually configured with sensible pm.max_children limits rather than defaults. If you're managing Nginx and PHP-FPM directly, the fifteen minutes it takes to set up proper pool isolation will save you from the Monday morning "everything is down" message.

I set up per-site pools on every server I manage. It's one of those changes that costs almost nothing to implement and prevents an entire category of outage.

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans | Get in Touch

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs