#

Security

Subscribe
Security October 29, 2026·17 min read

Isolate WordPress Sites on One Server with Separate PHP-FPM Pools

On one trusted VPS, give each independently installed WordPress site its own non-root PHP-FPM user and pool, lock down its files and database, and test two things: that WordPress still works, and that each site is denied access to the other. If you need hard tenant or resource isolation, use a stronger separate environment. Begin by checking which PHP identities already run your sites, and keep a way to roll back.

The examples below use an existing Ubuntu 24.04 LTS server with Nginx, distribution-packaged PHP 8.3-FPM, and two independent WordPress installations. Domains, accounts, paths, and database names are placeholders.

What per-site PHP-FPM isolation protects

WordPress isolation boundary

A PHP-FPM worker runs WordPress code as a Unix user and group. If two installations share that identity, code in one site can read the other's wp-config.php whenever the filesystem allows it, and that file holds the database credentials. Give each installation its own PHP identity, then restrict files and database privileges to match. If one site gets compromised, its PHP processes reach far less.

Decide whether PHP-FPM pools are the right boundary

PHP isolation options

Separate pools suit independently installed WordPress sites run by one trusted administrator. WordPress Multisite is different. Its sites share one WordPress installation, so this per-installation approach doesn't apply.

The deciding question is who controls the code and who trusts whom. An agency maintaining several websites on its own VPS has different needs than a host that lets unrelated customers run arbitrary PHP.

ArrangementPHP executionSeparation providedWhen to consider it
Different pools, shared www-dataSame Unix identitySeparate process settings and routes. The shared identity keeps its existing file access.You only need separate process management
Dedicated pool and Unix user per siteDistinct identity per siteFilesystem and database access restricted per installationIndependent sites under one trusted administrator
Separately configured containers or servicesSet by the users, mounts, and configuration you chooseExtra boundaries, depending on how you design the deploymentStricter tenant or resource separation
Separate VMs or VPSsIndependent guest environmentsStronger administrative and operating-environment separationSites with materially different trust requirements

A pool named site-a that still runs PHP as www-data adds no Unix-user boundary around Site B's files.

Inspect your existing WordPress server before changing it

WordPress server inventory

Start by taking inventory of the server as it is: service versions, active PHP-FPM pools, Nginx routes, file owners, database logins, and backups you can restore.

On the Ubuntu 24.04 and PHP 8.3 example, these commands give you a first inventory:

cat /etc/os-release
php-fpm8.3 -v
dpkg-query -W 'php8.3*'
sudo nginx -T
sudo systemctl status php8.3-fpm

The full Nginx configuration output can contain sensitive details, so don't share it publicly. Note each site's document root, PHP handler, fastcgi_pass endpoint, current pool identity, and any extra configuration the server block includes.

On this setup, pool definitions live under /etc/php/8.3/fpm/pool.d/, and the running configuration can also include a default [www] pool. Check its user, group, and listen directives to see how PHP currently runs.

Existing WordPress ownership

For each WordPress root, record ownership, groups, ACLs, and directory traversal permissions. Find out which database accounts the sites use, without printing passwords or config contents. If both sites send PHP to /run/php/php8.3-fpm.sock, look at the pool behind that listener.

Before you change anything, copy the Nginx and PHP-FPM configuration and take a restorable backup of each site's files and database.

PHP security prerequisite. On October 21, 2026, the PHP project released fixes for CVE-2026-109618 and CVE-2026-109619 in upstream releases 8.2.35, 8.3.36, 8.4.27, and 8.5.12. Separate pools don't repair a vulnerable PHP runtime. Ubuntu's USN-8948-1 lists 8.3.6-0ubuntu0.24.04.13 as the corrected php8.3-fpm package for Ubuntu 24.04 LTS. Distribution packages can carry backported fixes, so compare your full package version with the notice, not the upstream version number.

Ubuntu 26.04 uses a different PHP branch in the referenced distribution setup. Adapt package names, service names, and paths to your installed environment before continuing.

Plan separate site users, file roots, sockets, and database accounts

Per-site identity mapping

Give each WordPress installation its own PHP worker account, pool, socket, protected configuration, and database credentials.

ComponentSite ASite B
Hostnamesite-a.examplesite-b.example
PHP Unix user/groupwp_site_awp_site_b
WordPress root/srv/www/site-a.example//srv/www/site-b.example/
FPM pool[site-a][site-b]
Unix socket/run/php/php8.3-fpm-site-a.sock/run/php/php8.3-fpm-site-b.sock
Database / loginwp_a / wp_awp_b / wp_b
Protected configsite-a.example/wp-config.phpsite-b.example/wp-config.php

On this Ubuntu example, Nginx workers run as www-data. They need to connect to the PHP sockets and read public files. They don't need access to private WordPress configuration or to another installation's database credentials.

Check whether the accounts already exist before creating anything:

getent passwd wp_site_a
getent passwd wp_site_b

If an account exists, inspect its UID, primary group, supplementary groups, and any site files it already owns, and keep it. Create an account only if it's absent, and never replace an existing one just to match these names. On this Ubuntu setup, a dedicated system account looks like this:

sudo adduser --system --group --no-create-home wp_site_a
sudo adduser --system --group --no-create-home wp_site_b

In this example, each account's primary group takes the account's name.

Plan deployment tools, backup processes, and static asset access as separate permission requirements. Don't put both site users in a shared group that can write to both installations.

Give each WordPress site its own PHP-FPM pool

PHP-FPM pool configuration

Each pool needs a unique name, an existing non-root Unix identity for its workers, a FastCGI listener, and valid process-manager settings. On the reference system, create one file per site, such as /etc/php/8.3/fpm/pool.d/site-a.conf and site-b.conf.

Both configurations below use ondemand process management. The value pm.max_children = 1 only demonstrates valid syntax and is not a production recommendation. Set each site's real limit from server memory, workload, and concurrency.

Site A pool

[site-a]
user = wp_site_a
group = wp_site_a

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

pm = ondemand
pm.max_children = 1

Site B pool

[site-b]
user = wp_site_b
group = wp_site_b

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

pm = ondemand
pm.max_children = 1

The user and group fields set the identity of the PHP workers. The listen.owner, listen.group, and listen.mode fields control who can open the Unix socket Nginx uses. Nginx connects to the socket as www-data. The PHP code behind it still runs as wp_site_a.

PHP worker identities

PHP documents three pm modes: static, dynamic, and ondemand. All require pm.max_children, and dynamic also requires startup and spare-worker settings.

PHP-FPM also supports listen.acl_users and listen.acl_groups. Per the manual, when listen.acl_users is set, listen.owner and listen.group are ignored for the listener-access decision. Check existing ACL settings before trusting the visible socket owner and group.

These directives are documented in the PHP-FPM configuration reference, which also warns against exposing the FastCGI listener to unauthorized clients.

Check configuration syntax before activating the new pools:

sudo php-fpm8.3 -t

Connect each Nginx site to the correct PHP-FPM socket

Nginx FastCGI routing

Nginx sends each site's PHP requests to the socket named in fastcgi_pass inside that site's PHP handler.

Within Site A's existing PHP location:

fastcgi_pass unix:/run/php/php8.3-fpm-site-a.sock;

Within Site B's existing PHP location:

fastcgi_pass unix:/run/php/php8.3-fpm-site-b.sock;

These lines change only the backend target inside your existing handler. Keep its root, SCRIPT_FILENAME, FastCGI parameters, HTTPS configuration, rewrite rules, and restrictions on which PHP files can execute.

The path must match the pool's listen value character for character, and the Nginx worker needs permission to connect to it. A copied path is a costly mistake: if Site B sends requests to Site A's socket, they run as Site A's PHP user.

sudo nginx -t

This only checks syntax. A valid configuration can still point at a socket that doesn't exist or can't be opened, and you'll see that in the live connection and the logs after cutover.

Enforce filesystem and database boundaries without breaking WordPress

WordPress filesystem permissions

Separate PHP users only help if each site's files and database refuse the other site's user. Set access for each installation deliberately and keep WordPress's normal writes working.

In the example, wp_site_a must read Site A's PHP files and wp-config.php and write only where Site A's workflows require it. wp_site_b needs the same access to Site B. Neither identity should be able to get the other site's database secrets through normal Unix permissions.

Effective access comes from file owner, file group, mode bits, traversal permissions on every parent directory, supplementary groups, and ACLs. A strict file mode doesn't help if an unrelated account has another route in.

For a protected file, check ownership and every parent directory:

sudo stat /srv/www/site-a.example/wp-config.php
sudo namei -l /srv/www/site-a.example/wp-config.php

If Site A's PHP worker should own its private configuration, mode 0600 protects the file through ordinary owner, group, and other bits. Change ownership only after you confirm that the application and your maintenance tools use that account:

sudo chown wp_site_a:wp_site_a /srv/www/site-a.example/wp-config.php
sudo chmod 0600 /srv/www/site-a.example/wp-config.php

These commands change one file. Repeat them for Site B.

WordPress write permissions

WordPress also needs to write files, mainly to media-upload directories and during updates. If those paths stay owned by the former PHP account, a site that otherwise works can fail on an upload or a plugin install.

Check Site A's uploads directory and test write access as the site's PHP user:

sudo stat /srv/www/site-a.example/wp-content/uploads
sudo namei -l /srv/www/site-a.example/wp-content/uploads
sudo -u wp_site_a test -w /srv/www/site-a.example/wp-content/uploads

If the site user lacks access, find the affected path and its descendants in the ownership and traversal output. Adjust the owner, group permissions, or ACL only for those writable paths. Run the same check as wp_site_b on Site B's uploads directory.

Give write access only to the account that performs the operation, and keep the web server's read access to public static assets separate from PHP's access to private configuration. Don't run a recursive chmod 777, which makes files writable to unrelated accounts and defeats the restriction.

Database access belongs to the same boundary

Use a separate database login for each installation and grant it privileges on that site's database only. Changing the WordPress table prefix while keeping one broad login creates no database separation.

A database administrator can inspect the grants for the account and host WordPress actually uses:

SHOW GRANTS FOR 'wp_a'@'localhost';
SHOW GRANTS FOR 'wp_b'@'localhost';

Replace localhost if your accounts use a different host. Grants scoped to wp_a.* and wp_b.* reach one database each, while a grant on *.* reaches all of them. Changing an existing site's database login also means updating the credentials in its WordPress configuration at the same time.

If both sites currently share a login, have a database administrator create a separate account for each and grant it only the privileges it needs on its own database. During Site A's cutover, update the database credentials in its wp-config.php, confirm the connection and effective grants, and then migrate Site B. Keep the old account until no site or rollback path uses it. Revoke it only after both sites have moved.

WordPress core, plugin, and theme updates can need schema-changing privileges such as CREATE, ALTER, and DROP. Your grants must allow those operations without giving one site's login unrestricted access to another site's database.

Handle temporary data, OPcache, and WordPress maintenance paths

PHP runtime storage

Depending on the installation, sessions, uploads, cache entries, temporary files, logs, and scheduled tasks can use other paths with their own permissions. For each site, find where its sessions, cache, temporary files, and logs live, and check which Unix user owns them. Another site's user must not be able to read that private runtime data, including anything outside wp-content.

PHP documents opcache.validate_permission with a default of 0. Enabling it checks cached-file permissions for the current user. It's an INI_SYSTEM setting, so look at the INI configuration the PHP-FPM service loads and at any pool-level overrides for both sites. If they differ, confirm the effective value in the PHP-FPM context, because the CLI configuration can differ from what the web workers use.

About open_basedir. PHP describes this directive as a supplementary restriction on certain filesystem operations and not a full security boundary. It doesn't restrict Unix-domain socket connections and can disable the realpath cache. Protecting another WordPress installation still needs operating-system and database permissions.

WordPress maintenance accounts

WP-CLI, deployment tools, cron jobs, and backup services can run under a different account than the web-facing PHP worker. For each maintenance task, note which account runs it and what filesystem access it needs. Keep those permissions consistent with the site's ownership model and denied to the other site's worker account.

Apply the changes with a rollback path

WordPress staged cutover

Convert one installation at a time.

  1. Prepare Site A. Set up its service account and file access. Create the full PHP-FPM pool and keep the working site's other required PHP settings.
  2. Check the configuration. Fix any PHP-FPM or Nginx syntax errors before touching live routing.
  3. Activate the pool. Load the new PHP-FPM configuration through your service manager. On the Ubuntu example, a reload works:
    sudo systemctl reload php8.3-fpm
    
    Confirm the listener exists before sending traffic to it.
  4. Switch Site A's Nginx handler. Apply the prepared socket mapping, and reload Nginx only after its configuration passes validation:
    sudo systemctl reload nginx
    
  5. Test Site A. Run the site-function and cross-site authorization checks from the next section. Stop if either fails.
  6. Convert Site B. Repeat the steps only after Site A passes.
  7. Keep your recovery material. Hold on to the original configuration, the permissions record, and the backups until the deployment has passed its checks and you no longer need to recover.
Configuration rollback steps

A failed syntax test stops the cutover before activation. A broken hostname, a failed WordPress maintenance operation, or a failed cross-site check stops you from moving to the next site. If you can't repair the new setup safely, restore the matching former Nginx route and PHP-FPM pool configuration during a controlled maintenance window.

Reverse ownership and ACL changes separately, using the values you recorded. If files or databases changed, restoring application data is a separate step. Reloading a service restores neither permissions nor data.

Verify each site works and cannot read the other site

Cross-site access tests

Verification has two parts. Authorized WordPress operations must work, and cross-site access must be denied. Run both against the live configuration.

First confirm the hostnames, WordPress administration, uploads, and your maintenance operations. Then identify the PHP workers that handled requests. Under an ondemand pool, no workers exist until PHP traffic arrives.

ps -eo user,group,args | grep '[p]hp-fpm'

Site A's workers should run as wp_site_a and Site B's as wp_site_b. Read the process list together with the active pool and routing configuration. A cached response that bypasses PHP tells you nothing about worker identity.

TestActor and targetExpected resultYour result
Site A response and administrationSite A hostnameCorrect WordPress site works
Site B response and administrationSite B hostnameCorrect WordPress site works
Required writesEach site's intended PHP/maintenance accountAuthorized uploads and updates work
Worker identityEach active FPM poolSite A pool runs as wp_site_a, Site B pool as wp_site_b
File read A → Bwp_site_a and Site B private configurationPermission denied
File read B → Awp_site_b and Site A private configurationPermission denied
Database access A → BSite A application login, Site B databaseAccess refused
Database access B → ASite B application login, Site A databaseAccess refused

A loading homepage only shows the site is up. The two cross-site file reads and the two cross-database queries show whether the isolation works.

Test file access without disclosing secrets

Before reading anything into a failure, confirm the target file exists and that you're testing as the right identity. These examples use the mapped WordPress paths:

sudo test -f /srv/www/site-a.example/wp-config.php
sudo test -f /srv/www/site-b.example/wp-config.php

sudo -u wp_site_a test -r /srv/www/site-b.example/wp-config.php
sudo -u wp_site_b test -r /srv/www/site-a.example/wp-config.php

Both cross-site test -r commands should fail for protected files that exist. Check the exit status right after each command, and note the result without printing the protected file.

Database authorization tests

Test the application's database identity

WP-CLI's wp db query uses the site's configured database connection. If WP-CLI is installed and the site account can run it, a read-only check as the matching site user looks like this:

sudo -u wp_site_a wp --path=/srv/www/site-a.example db query "SELECT 1;"
sudo -u wp_site_b wp --path=/srv/www/site-b.example db query "SELECT 1;"

The application's account should connect without trouble. To see its effective grants through that same connection, run a read-only grant query your database version supports:

sudo -u wp_site_a wp --path=/srv/www/site-a.example db query "SHOW GRANTS FOR CURRENT_USER()"

To test cross-database denial on a shared MySQL/MariaDB server, query a known, non-sensitive table in Site B's database while connected as Site A's login:

sudo -u wp_site_a wp --path=/srv/www/site-a.example db query "SELECT 1 FROM wp_b.existing_table LIMIT 1;"

Replace existing_table with a real table you've confirmed exists in that database. If the query succeeds, your privilege boundary has failed. Access denied is the result you want. A missing table, an unavailable database server, or an invalid account makes the test inconclusive, so diagnose it. Repeat the check in the opposite direction with Site B's login and a known Site A table.

If the installations use different database servers, this cross-schema example doesn't apply. Test against the connection and grant model of the servers you actually run.

Prefer local checks to public PHP diagnostic pages. Restrict any temporary web-accessible test, remove it afterward, and never let it expose database credentials, protected paths, or execution identities to visitors.

This procedure won't clean a compromised site, which needs its own containment, credential handling, investigation, and clean recovery.

Fix PHP-FPM, socket, permission, and WordPress failures

PHP-FPM error diagnosis

After a cutover, find the failed layer in the error and service logs.

SymptomInspect firstLikely layerTargeted repair
502, socket missingFPM service state and pool listen pathListener or serviceMatch fastcgi_pass to the pool's listen path and confirm the service is running
502, (13: Permission denied)Socket owner/group, mode, ACL and Nginx UIDUnix socket accessSet listen.owner, listen.group, and listen.mode so the Nginx user can connect, and check listen.acl_users
FPM rejects configurationPool names, service accounts, paths, pm settingsPool syntaxFix the pool name, user, group, or pm settings and rerun php-fpm8.3 -t
Wrong application or PHP identityServer block and fastcgi_pass mappingRequest routingPoint each server block's fastcgi_pass at its own site's socket
Uploads or updates failWorker identity, writable paths and ancestor traversalFilesystem permissionsFix owner, group permissions, or ACL on the specific writable paths only
Database connection errorSite-specific connection settings and account grantsDatabase authorizationFix the site's credentials in wp-config.php or its grants on its own database

A Server Fault question about Laravel on Ubuntu 24.04 and PHP 8.3 quoted the Nginx message connect() to unix:/run/php/php-fpm.sock failed (13: Permission denied). The same message applies to WordPress, because it points to the same Nginx-to-PHP-FPM listener-access layer.

Know when separate pools are not enough

Shared server limitations

Separate PHP worker identities limit access only through the permissions assigned to those processes. The sites still share the Linux kernel, privileged server administration, Nginx, PHP-FPM, databases, caches, storage, and the machine's total resources.

PHP-FPM's pm.max_children caps the number of concurrent PHP workers in a pool. It reserves no CPU time, RAM, disk throughput, or network capacity for the site.

An r/sysadmin operator running four WordPress sites on a VPS with 4 GB RAM and two vCPUs described a traffic incident this way: “This of course resulted in CPU exhaustion and all 4 websites became unresponsive for some time.”

Tenant isolation choices

Containers with suitable user, mount, network, and resource restrictions, or separate VMs or VPSs, give you stronger separation at the cost of more infrastructure and maintenance.

Explore related guides

NoScript Team
© 2026 No Script