Running two, three, five PHP applications on a VPS — perfectly normal in the small-SaaS world. In a default install they all share a single www.conf pool. This usually works — until it doesn’t.

The problem with a single pool

A single pool means:

  1. If one application exhausts pm.max_children = 50, the others can’t produce a response.
  2. A memory leak in one application drags the others’ workers into OOM too.
  3. Restarting one application (e.g. an opcache reset) restarts everyone’s workers.

Pool separation solves all three at once.

Config per pool

/etc/php/8.4/fpm/pool.d/billing.conf:

[billing]
user = billing
group = billing
listen = /run/php/billing.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500

request_terminate_timeout = 30s
catch_workers_output = yes
decorate_workers_output = no
clear_env = no

php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php-fpm/billing-error.log

/etc/php/8.4/fpm/pool.d/notify.conf:

[notify]
user = notify
group = notify
listen = /run/php/notify.sock
; ...
pm.max_children = 8       ; lighter application
php_admin_value[memory_limit] = 128M

We gain two things:

  • Filesystem isolation — with user = billing, the billing app can’t write to notify’s files.
  • Resource isolation — if billing’s max_children is exhausted, notify still runs.

On the Nginx side

Each vhost connects to its own socket:

server {
    server_name billing.example.com;
    root /var/www/billing/public;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/billing.sock;
        # standard fastcgi params
    }
}

server {
    server_name notify.example.com;
    root /var/www/notify/public;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/notify.sock;
    }
}

opcache: shared or separate?

The PHP-FPM master process shares opcache — so opcache is not automatically isolated between pools. But if you use opcache.validate_root = 1 and different root paths, there’s no collision. In practice: same master, separate pools is enough.

Monitoring

Expose the /status endpoint on the pool:

pm.status_path = /status

Then on the Nginx side, restrict it to localhost only:

location ~ ^/status$ {
    access_log off;
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/billing.sock;
    # ...
}

With curl localhost/status you can pull metrics like active workers, queue length, and slow request count, and monitor them with a Prometheus exporter or a simple cron.

When is a single pool enough?

If you’re running two copies of the same codebase (e.g. app.example.com and staging.example.com), a separate pool is unnecessary. Different domains but the same process class. Separation is worth it when you want applications to be operationally independent.