Current situation: PHP2Shell. Two critical PHP vulnerabilities (CVSS 9.2) allow malicious code without login. Patches since 21 October 2026.What to do now →

Isolating PHP-FPM Pools per Website

A separate pool per website is the beginning, not the end. Here is how to check existing pools and harden sockets, extensions, status pages, resources and logs.

Last updated 6 min readHardening and configuration

In short: Every website on a server should have its own PHP-FPM pool with its own system user and its own socket. The separation only becomes effective through the details: socket permissions for the web server only, security.limit_extensions set to .php, no publicly reachable status pages, separate limits for processes and runtime, and separate logs. Against PHP2Shell itself only the update protects; pools limit how far an attack reaches.

One server, many websites, one shared risk

On many servers, over the years, something has grown that nobody planned that way. The company website, the shop, a campaign page from 2019 and a test system run side by side, often under the same user and in the same pool. If the forgotten campaign page is compromised, the shop's customer data lies open as well.

PHP-FPM, PHP's FastCGI process manager, solves this with pools: groups of worker processes with their own settings. How the basic principle works is explained in PHP-FPM explained: process manager, pools and workers. The setup of separate pools with their own user, their own temp folder and open_basedir is described in the guide open_basedir and isolation per website. This article goes one step further: how do you check existing pools, and which settings make the separation robust?

An important note on classification: with PHP2Shell, the vulnerabilities CVE-2026-109618 and CVE-2026-109619 lie in the PHP interpreter, which all pools of a PHP version share. A separate pool does not prevent exploitation. But it decides whether an attacker afterwards reaches one website or all of them.

PHP-FPM pools with their own user: checking the existing setup

Before you change anything, get an overview. The following commands only read. Paths and version numbers differ by distribution:

# All pools with user and socket
grep -HE '^\s*(\[|user|group|listen)\s*' /etc/php/*/fpm/pool.d/*.conf

# Running pools and their users
ps -eo user,pid,cmd | grep '[p]hp-fpm: pool'

# Sockets and their permissions
ls -l /run/php/

# Check the configuration for errors (changes nothing)
sudo php-fpm8.3 -t

Watch for three patterns: several websites in the same pool, pools that run as www-data or even as root, and sockets that are writable for all users. Each of these undermines the separation.

If several PHP versions run on the server, each has its own FPM service with its own pool directories. Check all of them, not just the version you set up last. Old versions with forgotten pools in particular are a risk, because they are often neither maintained nor restarted after updates. Notes on this are found under Several PHP versions on one server. Record the result in a small table: website, pool, user, socket, PHP version. This overview is also the basis for every later change.

The most important settings per pool

The following overview is based on the FPM configuration in the PHP manual. Values for processes and times depend on your load and application:

Setting Purpose Recommendation
user, group user of the workers separate user per website, never root
listen.owner, listen.group, listen.mode permissions on the socket web server group only, mode 0660
listen.acl_users access via ACL instead of owner list only the web server user
security.limit_extensions executable extensions limit to .php (default .php .phar)
clear_env clear environment variables leave at the default yes
pm.max_children maximum number of workers limit so that one website cannot overload the server
request_terminate_timeout abort hanging requests set to match the longest legitimate request
pm.status_path, ping.path status and ping page set only if needed, and secure internally
slowlog, request_slowlog_timeout log of slow requests own file per pool

An excerpt for a hardened pool looks like this:

; /etc/php/8.3/fpm/pool.d/shop.conf (excerpt)
[shop]
user = shop
group = shop
listen = /run/php/shop.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 = 6
request_terminate_timeout = 60s

security.limit_extensions = .php
clear_env = yes

slowlog = /var/log/php/shop.slow.log
request_slowlog_timeout = 10s
php_admin_value[error_log] = /var/log/php/shop.error.log

Settings such as memory_limit or session.upload_progress.enabled are set per pool with php_admin_value and php_admin_flag. According to the manual, these values cannot be overridden with ini_set(). More on this under php_admin_value: locking settings against overrides.

Putting status pages and worker recycling in context

pm.status_path and ping.path are active only if they are set. The status page shows running requests, processes and load. This is helpful for monitoring, but publicly reachable it is an unnecessary source of information for attackers. Forward these paths in the web server only for internal addresses. How Nginx and PHP-FPM work together securely is described in Securing Nginx with PHP-FPM.

pm.max_requests determines after how many requests a worker restarts; the default is 0, meaning never. A value above zero helps against slowly growing memory consumption. It is not intended as protection against PHP2Shell: php.net does not list it among the interim measures and stresses that only the update protects completely. After the update, it also applies that all workers of all pools must be restarted, because old processes would otherwise keep running with the vulnerable code.

Tip

Maintain a template for new pools and set up every new website with it. That way the separation comes about automatically instead of being retrofitted years later.

Typical mistakes during the changeover

The most common mistake is a new pool that runs under its own user while its files still belong to www-data. The pool then cannot write where it must, and someone sets the permissions too generously. Clarify beforehand which folders must be writable. The second mistake is scheduled tasks that keep running under the old user and create files with the wrong owner. The third is a shared session folder for all pools, which cancels the separation again.

After every changeover, test the website's login, uploads, caches and scheduled tasks, and watch the pool's error log.

How does PHP SOS help?

  • We record all pools, users, sockets and settings and show where websites share processes or permissions today.
  • We set up a hardened pool for each website, with socket permissions, an extension limit, resource limits and separate logs.
  • We secure status pages, restart all workers in a controlled way and test every website.
  • As part of server hardening, we document the separation as a template for future projects.

Frequently asked questions

Why should every website have its own PHP-FPM pool?

A separate pool with its own system user separates the websites at the level of processes and file permissions. If one website is compromised, the code cannot simply read the files of the others. Limits and settings can also be defined separately for each website.

Does a separate pool protect against PHP2Shell?

No. The vulnerabilities lie in the PHP interpreter itself, which all pools share. Only the update to a fixed version, with a restart of all workers, offers protection. Separate pools do, however, limit how far a successful attack reaches.

What does security.limit_extensions do?

According to the PHP manual, the setting limits which file extensions PHP-FPM executes as the main script. The default is .php and .phar. Limiting it to .php prevents, for example, uploaded image files from being executed as PHP if the web server is misconfigured.

Is the PHP-FPM status page dangerous?

It reveals details about running processes and requests. If it is publicly reachable without protection, it makes orientation easier for attackers. According to the manual it is active only if pm.status_path is set. In that case the web server should restrict the path to internal addresses.

Sources

Immediate contact

Every hour counts. Write to us now.

Briefly describe your system, PHP version and observation. We will get back to you, clarify the situation and work remotely until the incident is closed and documented.

Immediate help