← Tutorials
Server stack intermediate · ~25 min ·

Multiple PHP versions on AlmaLinux and Rocky Linux, one FPM pool per site

Run PHP 8.4 and 8.2 side by side with Apache and Remi's packages: each site gets its own PHP version, its own Linux user and its own FPM pool.

A hosting server rarely gets to pick one PHP version. A client’s old shop needs 8.2, while new WordPress sites should run 8.4. The distribution ships one PHP at a time, so this tutorial uses Remi’s repository, which installs each version under its own prefix so they never collide.

You will end up with two sites, each running as its own Linux user in its own PHP-FPM pool:

SitePHPRuns as
site1.example.com8.4site1
site2.example.com8.2site2

A pool per site is what keeps one site’s code from reading another site’s files. It also means one site that runs away with its PHP processes does not starve the others.

Add the EPEL and Remi repositories

Remi’s packages depend on EPEL, and EPEL depends on CodeReady Builder (crb), which is off by default.

Terminal window
dnf -y install dnf-plugins-core
dnf config-manager --set-enabled crb
dnf -y install epel-release
dnf -y install "https://rpms.remirepo.net/enterprise/remi-release-$(rpm -E %rhel).rpm"

$(rpm -E %rhel) expands to your major version (9 or 10), so the same line works on both.

Install Apache and both PHP versions

Remi names each version’s packages phpXY-php-*. Install FPM and a few common extensions for each version you want:

Terminal window
dnf -y install httpd
dnf -y install php84-php-fpm php84-php-opcache php84-php-mysqlnd php84-php-process
dnf -y install php82-php-fpm php82-php-opcache php82-php-mysqlnd php82-php-process

Each version lives in its own tree. Its configuration is under /etc/opt/remi/php84/, its sockets under /var/opt/remi/php84/run/, and it runs as its own service, php84-php-fpm. Nothing here replaces the system php, and you can add php83 or php85 the same way later.

Create a user and a document root per site

Each site gets its own Linux user. Its files belong to that user and to the apache group, so Apache can serve static files, and nobody else can read them:

Terminal window
for site in site1 site2; do
useradd --system --shell /sbin/nologin "$site"
mkdir -p "/var/www/$site/public"
chown -R "$site:apache" "/var/www/$site"
chmod 750 "/var/www/$site"
done

Directories under /var/www already carry the SELinux label Apache is allowed to read, so nothing needs relabelling.

Put a test page in each site that reports which PHP version runs it, and as which user:

Terminal window
for site in site1 site2; do
cat > "/var/www/$site/public/index.php" <<'EOF'
<?php
echo PHP_VERSION, ' ', posix_getpwuid(posix_geteuid())['name'], "\n";
EOF
chown "$site:apache" "/var/www/$site/public/index.php"
done

Give each site its own FPM pool

A pool is a group of PHP processes with its own user, its own socket and its own limits. Create one for site1 under PHP 8.4:

Terminal window
cat > /etc/opt/remi/php84/php-fpm.d/site1.conf <<'EOF'
[site1]
user = site1
group = site1
listen = /var/opt/remi/php84/run/php-fpm/site1.sock
listen.owner = apache
listen.group = apache
listen.mode = 0660
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
php_admin_value[open_basedir] = /var/www/site1:/tmp
EOF

and one for site2 under PHP 8.2:

Terminal window
cat > /etc/opt/remi/php82/php-fpm.d/site2.conf <<'EOF'
[site2]
user = site2
group = site2
listen = /var/opt/remi/php82/run/php-fpm/site2.sock
listen.owner = apache
listen.group = apache
listen.mode = 0660
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
php_admin_value[open_basedir] = /var/www/site2:/tmp
EOF

What the settings do:

  • user / group — the PHP code of this site runs as this user, and only this user. File permissions do the isolating from here.
  • listen.* — only Apache may connect to the socket.
  • pm = ondemand — no processes at all until a request arrives, and idle ones exit after 10 seconds. On a server with many small sites this is the setting that saves the most memory. pm.max_children caps how many requests run at once, per site.
  • open_basedir — a second fence inside PHP itself: even a file the user could read is off limits outside these paths.

Each version also ships a default www pool. It is not used here, so disable it, then start both services:

Terminal window
mv /etc/opt/remi/php84/php-fpm.d/www.conf /etc/opt/remi/php84/php-fpm.d/www.conf.disabled
mv /etc/opt/remi/php82/php-fpm.d/www.conf /etc/opt/remi/php82/php-fpm.d/www.conf.disabled
systemctl enable --now php84-php-fpm php82-php-fpm

Point each Apache virtual host at its pool

The SetHandler line is where a site picks its PHP version: it hands .php files to that site’s socket.

Terminal window
cat > /etc/httpd/conf.d/site1.conf <<'EOF'
<VirtualHost *:80>
ServerName site1.example.com
DocumentRoot /var/www/site1/public
<Directory /var/www/site1/public>
Require all granted
AllowOverride All
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/var/opt/remi/php84/run/php-fpm/site1.sock|fcgi://localhost"
</FilesMatch>
DirectoryIndex index.php index.html
</VirtualHost>
EOF
cat > /etc/httpd/conf.d/site2.conf <<'EOF'
<VirtualHost *:80>
ServerName site2.example.com
DocumentRoot /var/www/site2/public
<Directory /var/www/site2/public>
Require all granted
AllowOverride All
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/var/opt/remi/php82/run/php-fpm/site2.sock|fcgi://localhost"
</FilesMatch>
DirectoryIndex index.php index.html
</VirtualHost>
EOF
apachectl configtest
systemctl enable --now httpd

To move a site to another PHP version later, change the version in its SetHandler path, move its pool file into that version’s php-fpm.d, and reload both services.

Check that each site runs its own PHP, as its own user

Until DNS points the names at this server, ask Apache directly with a Host header:

Terminal window
curl -fsS -H 'Host: site1.example.com' http://127.0.0.1/ | grep -E '^8\.4\.[0-9]+ site1$'
curl -fsS -H 'Host: site2.example.com' http://127.0.0.1/ | grep -E '^8\.2\.[0-9]+ site2$'

Each line prints the version and the user, for example 8.4.26 site1.

Now check the isolation. Have site2 try to read site1’s code:

Terminal window
cat > /var/www/site2/public/peek.php <<'EOF'
<?php
echo @file_get_contents('/var/www/site1/public/index.php') === false ? "blocked\n" : "LEAKED\n";
EOF
chown site2:apache /var/www/site2/public/peek.php
curl -fsS -H 'Host: site2.example.com' http://127.0.0.1/peek.php | grep -x blocked
rm /var/www/site2/public/peek.php

blocked is the answer you want. Two separate fences stop it: open_basedir refuses the path, and even without it, site2 is neither the owner of /var/www/site1 nor in its group.

The CorePanel way

CorePanel builds this structure for every hosting account and keeps it up to date. Each account is its own Linux user with its own on-demand FPM pool, and PHP 8.4, 8.3, 8.2 and 7.4 come installed side by side. The version is chosen per account when you create it, and changed later under Edit account → PHP version: CorePanel rewrites the pool on the new version and re-points every site of the account in one reload. A single site can also run a version of its own, from the PHP column of the account’s domains, and your customers can pick it from their own panel. open_basedir, process limits and a private temporary directory per account are set the same way.

See How PHP runs and Isolation and limits.