Running PHP 7.4, 8.3 and 8.4 Side by Side on One Server

One server, three apps, three PHP versions: PHP-FPM per version, one pool per site, a handler per vhost and the right CLI binary for every cron job.

Share
Running PHP 7.4, 8.3 and 8.4 Side by Side on One Server

One box, three PHP apps, and none of them agree on anything. An older WordPress shop that only behaves on PHP 7.4, a Nextcloud 32 that wants PHP 8.4, and a XenForo 2.3 forum that runs on PHP 8.3. Plus a Nextcloud background job that has to fire every five minutes with the right PHP binary, not whatever php happens to point at today.

This is completely normal on a small server, and it's easy once you stop thinking of "PHP" as one thing installed on the machine. PHP is a set of parallel installs, and every site and every cron job picks one explicitly.

TL;DR: Install each version's FPM package side by side (ondrej PPA on Ubuntu, sury repo on Debian), give every site its own FPM pool under the matching version, point each vhost at its own socket, and call the CLI binary by absolute path in cron (/usr/bin/php8.4, never bare php). Test configs with php-fpm8.4 -t before reloading.

Install the versions side by side

Ubuntu's own archive ships one PHP version per release. For anything else you want Ondřej Surý's PPA, which is where pretty much every multi-PHP setup on Ubuntu ends up:

apt install -y software-properties-common
add-apt-repository -y ppa:ondrej/php
apt update

apt install -y php7.4-fpm php7.4-mysql php7.4-curl php7.4-gd php7.4-mbstring php7.4-xml php7.4-zip php7.4-intl
apt install -y php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl
apt install -y php8.4-fpm php8.4-mysql php8.4-curl php8.4-gd php8.4-mbstring php8.4-xml php8.4-zip php8.4-intl \
               php8.4-bcmath php8.4-gmp php8.4-imagick php8.4-apcu php8.4-redis

On Debian the same packages come from packages.sury.org/php instead of a PPA. Extensions are per version: php8.4-gd does nothing for 7.4. If an app complains about a missing module, check which version it's actually running before you install anything.

Each version gets its own tree and its own service:

ls /etc/php/                       # 7.4  8.3  8.4
systemctl status php7.4-fpm php8.3-fpm php8.4-fpm --no-pager | grep -E 'php|Active'
ls /run/php/                       # one socket per pool

One pool per site

The default www pool under each version is fine for testing and wrong for production. Give each site its own pool: its own Linux user, its own socket, its own limits. If the shop gets hammered, it can only eat its own workers.

; /etc/php/7.4/fpm/pool.d/shop.conf
[shop]
user = shop
group = shop
listen = /run/php/php7.4-fpm-shop.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 30s

php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M

Same shape for the others: /etc/php/8.4/fpm/pool.d/cloud.conf with listen = /run/php/php8.4-fpm-cloud.sock, and /etc/php/8.3/fpm/pool.d/forum.conf with listen = /run/php/php8.3-fpm-forum.sock. Nextcloud wants a higher memory_limit (512M is the usual recommendation) and benefits from OPcache tuning; that goes into its pool or into /etc/php/8.4/fpm/conf.d/, and only affects 8.4.

Test, then reload:

php-fpm7.4 -t && systemctl reload php7.4-fpm
php-fpm8.3 -t && systemctl reload php8.3-fpm
php-fpm8.4 -t && systemctl reload php8.4-fpm

-t parses every pool file of that version and tells you which line is broken. A typo in one pool otherwise takes down every site on that version on the next restart, which is a fun way to learn this.

Point each vhost at its socket

Apache talks to FPM through mod_proxy_fcgi. Enable the modules, but do not enable a global phpX.Y-fpm conf, because that sets a server-wide default handler and you'll spend an hour wondering why one site ignores its vhost:

a2enmod proxy_fcgi setenvif
a2disconf php7.4-fpm php8.3-fpm php8.4-fpm 2>/dev/null

Then per vhost:

<VirtualHost *:443>
    ServerName shop.example.com
    DocumentRoot /var/www/shop

    <FilesMatch "\.php$">
        SetHandler "proxy:unix:/run/php/php7.4-fpm-shop.sock|fcgi://localhost"
    </FilesMatch>
</VirtualHost>
apachectl configtest && systemctl reload apache2

nginx is the same idea in one line:

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm-forum.sock;
}

Verify what a site actually runs, not what you think it runs. Drop a throwaway file, hit it, delete it:

echo '<?php echo PHP_VERSION;' > /var/www/forum/v.php
curl -s https://forum.example.com/v.php; echo
rm /var/www/forum/v.php

If you're on a hosting panel (Plesk, KeyHelp and friends all do multi-version FPM), the panel writes exactly these handler lines for you and you just pick the version per domain. Under the hood it's the same thing.

The CLI is a separate decision

This is the part that bites. The web side is wired by socket. The command line is wired by whatever /usr/bin/php points to, and that's an update-alternatives symlink:

update-alternatives --display php
readlink -f /usr/bin/php

In auto mode the alternative follows priority, and the newest version tends to win. So installing a new version from the PPA can quietly change what plain php means for every cron job and script on the box. That's why cron jobs get an absolute, versioned binary and nothing else.

Nextcloud's background job, every five minutes, as the web user, with the version Nextcloud runs on:

# /etc/cron.d/nextcloud
*/5 * * * * www-data /usr/bin/php8.4 -f /var/www/cloud/cron.php

Same rule for occ:

sudo -u www-data /usr/bin/php8.4 /var/www/cloud/occ status
sudo -u www-data /usr/bin/php8.4 /var/www/cloud/occ background:cron

The user matters as much as the version. cron.php and occ must run as the user that owns config/config.php, otherwise Nextcloud refuses or, worse, creates files the web process can't touch later.

If you'd rather not deal with cron at all, the same job works as a systemd timer, with logs per run. I wrote that up in systemd timers instead of cron; just keep the absolute php8.4 path in ExecStart.

Gotcha: the cron job pointed at the wrong copy

When I inventoried this server before a move, there was a second copy of the Nextcloud directory under a different path, and the cron job was the only thing still using it. The live install ran from one directory, the background jobs ran against another. Nothing errors loudly in that situation; background tasks just quietly don't happen for the instance people actually use.

Check that the path in every cron line matches the docroot of the vhost, and look at Administration settings → Basic settings in Nextcloud: it shows when the last background job ran. If that's not "a few minutes ago", your cron is talking to the wrong thing.

Why not just upgrade everything to 8.4?

Because the old WordPress tells you no. I've watched this exact thing happen during a migration: the shop got moved to the new server, landed on PHP 8.4 by default, and the front page turned into WordPress's "There has been a critical error on this website". No code had changed, only the PHP version under it. Installing 7.4 next to 8.4 and switching that one site back to the 7.4 handler made it return 200 again.

The culprits are almost always old plugins and themes: PHP 8 removed or changed a pile of things (each(), create_function(), stricter type juggling, warnings that became errors), and abandoned plugins never caught up. Upgrading means testing every plugin, replacing the dead ones, and sometimes paying for a rebuild. That's a project, not a Tuesday.

So the honest framing is:

  • 7.4 is a quarantine, not a home. It's been end-of-life upstream since November 2022. Keep the site on its own pool and user so a compromise stays in its box, keep WordPress core and plugins as current as they'll go, and put the upgrade on the list.
  • Don't let 7.4 hold the rest of the server back. That's the whole point of side by side: Nextcloud and the forum get current PHP, the shop gets its island.
  • Upgrade the island on a copy. Clone the site, point the clone at an 8.x pool, enable WP_DEBUG_LOG, click through the shop. The log tells you exactly which plugin breaks.

Gotcha: extensions and php.ini are per version

php -m on the CLI tells you about the CLI's version, not the site's. And /etc/php/8.4/cli/php.ini is a different file from /etc/php/8.4/fpm/php.ini. Classic failure: you raise memory_limit in the FPM ini, the web UI is happy, and occ still dies with "Allowed memory size exhausted" because the CLI ini never changed. When something works in the browser but not in cron (or the other way round), compare:

/usr/bin/php8.4 --ini
/usr/bin/php8.4 -i | grep memory_limit

Housekeeping

  • After a PPA update, all three FPM services get restarted by the packages. Check them, not just one: systemctl is-active php7.4-fpm php8.3-fpm php8.4-fpm.
  • Every version is another set of security updates. Unattended upgrades cover the PPA too if you add its origin; see unattended-upgrades on Ubuntu.
  • Remove versions nobody uses anymore. A forgotten php7.4-fpm with no pools is still a running service with a CVE list.
  • If you'd rather not hand-roll this, a panel does it for you. I ended up on KeyHelp as a free Plesk alternative, which handles multi-version FPM per domain out of the box. And the general server baseline is in my Ubuntu initial setup checklist.

Three apps, three versions, zero drama, as long as every site and every cron line says out loud which PHP it means.


Related posts