Technology

One Server, Many PHPs: How I Run Per‑Site Nginx + PHP‑FPM Pools Without the Drama

So there I was, staring at a server with a mix of WordPress, a legacy Laravel 5 app, and a shiny new Symfony project that really wanted PHP 8.2. Sound familiar? One client needed an old extension that wouldn’t compile beyond 7.4, another was excited about JIT on 8.x, and the third just wanted to move fast without breaking anything. That’s the moment I realized you don’t have to choose. You can run multiple PHP versions on one server—cleanly, calmly, and with a plan—for each site. And once you see the rhythm of Nginx handing off requests to dedicated PHP‑FPM pools, it starts to feel almost… relaxing.

Ever had that moment when upgrading PHP broke a plugin you didn’t even know was still there? Or when you wanted to test PHP 8.3 on staging without touching production? That’s exactly what we’re going to untangle together. In this guide, I’ll walk you through the mental model, the file layout, the Nginx blocks, and the PHP‑FPM pool configs I use day‑to‑day. We’ll talk about how to give each site its own PHP home, how to tune those worker pools, and how to switch versions for a site without downtime. If you’ve ever wanted your server to feel like a neat little neighborhood instead of a spaghetti bowl, you’re in the right place.

Why Bother With Multiple PHP Versions?

Let’s start with the truth: most servers don’t run a single perfect app that updates on command. Real servers host a mix of projects in different stages of life. One might be stuck on an old framework because the budget for refactoring hasn’t arrived yet. Another is green‑field and eager to use the latest language features. I remember a migration where a client wanted to ride the performance gains of PHP 8 but couldn’t abandon a custom ionCube loader in the old backend. We didn’t tear anything down. We just placed each site in its own lane.

Here’s the thing: PHP-FPM is happy to run different versions side‑by‑side. You can have php7.4-fpm humming along for one pool, php8.2-fpm powering another, and even a third one for staging. Nginx doesn’t care which version parses the request; it simply passes .php traffic to the socket you tell it to. That separation gives you breathing room. You can test upgrades per site, phase changes gradually, and avoid those scary full‑stack rollbacks at 2 a.m. If something goes sideways, you repoint that one site back to its old pool and recover in seconds.

The best part? You also gain better security isolation. Each site runs as its own system user, with its own PHP‑FPM pool, and you can restrict file permissions accordingly. A noisy neighbor can’t chew up the entire server because their pool has its own process manager settings and memory limits. And when you’re ready to retire a PHP version, you reduce the blast radius—migrate one site at a time.

The Mental Model: Nginx as Traffic Cop, PHP‑FPM Pools as Kitchens

Think of Nginx as a friendly traffic cop at an intersection. HTML, CSS, and images get waved through directly from disk. But when a .php request comes in, Nginx takes a peek at the domain and routes that request to the right kitchen. That kitchen is a PHP‑FPM pool. Each pool is staffed (the number of workers), has a pantry (its INI settings and extensions), and listens at its own private door (a Unix socket).

Multiple kitchens, multiple menus. You can have php7.4-fpm and php8.2-fpm both installed on the same server. Inside each PHP version, you create named pools—one per site. Those pools are where you set the Unix user and group the code runs as, define how many PHP workers to keep around, and specify where logs go. Nginx connects each site’s .php location to the correct pool socket, and that’s it. Suddenly, your server isn’t one giant pot—it’s a tidy row of stoves.

I like this model because it maps to daily life. Want to upgrade one app from 7.4 to 8.2? Open the Nginx config, change the socket from the 7.4 pool to the 8.2 pool, reload Nginx, and test. If it sings, you’re done. If it squeaks, point it back and debug without panic. You’ve got time and options.

Installing Multiple PHP Versions (and the Simple Layout That Keeps Me Sane)

Where files live

Over the years I settled into a simple, predictable structure. Each site gets its own system user and directory:

/var/www/site1/current/public
/var/www/site2/current/public

I deploy code to a versioned release directory and symlink current to it. Nginx points at current/public. That way I can do zero‑downtime releases by swapping the symlink. But even if you’re not doing fancy deploys, this layout keeps your paths clean and makes rolling back easy.

System users for each site

Create a dedicated Unix user per site. It’s a small step that unlocks better isolation.

sudo adduser --system --group --home /var/www/site1 site1
sudo adduser --system --group --home /var/www/site2 site2
sudo mkdir -p /var/www/site1/current/public
sudo mkdir -p /var/www/site2/current/public
sudo chown -R site1:site1 /var/www/site1
sudo chown -R site2:site2 /var/www/site2

Installing multiple PHPs

On Debian/Ubuntu, the built‑in repos usually include one current PHP. For older or multiple versions, add Ondřej Surý’s PHP repo. It’s the backbone of many multi‑PHP setups on Debian/Ubuntu. I won’t paste the entire repository configuration here—follow Ondřej Surý’s PHP repository instructions for Debian/Ubuntu for the exact steps—then install what you need:

sudo apt update
sudo apt install -y nginx php7.4-fpm php8.2-fpm
sudo systemctl enable --now php7.4-fpm php8.2-fpm nginx

On RHEL/CentOS/Alma/Rocky, you’ll typically lean on Remi’s repository to install multiple PHP streams. The idea is the same: install separate PHP‑FPM packages per version. Regardless of distro, verify that each PHP‑FPM service runs and exposes its own global pool directory, usually at something like /etc/php/7.4/fpm/pool.d and /etc/php/8.2/fpm/pool.d.

One more thing: remember that the CLI version (php -v) doesn’t decide what FPM runs. Your FPM services are separate. You can have the CLI on 8.2 while some sites run through 7.4‑FPM, and that’s perfectly fine.

Per‑Site PHP‑FPM Pools: The Heart of the Setup

This is where the magic happens. We create a pool per site, and we run that pool as the site’s Unix user. Each pool listens on its own Unix socket with permissions that allow Nginx to connect. You get isolation, flexible tuning, and clear logs.

Example: site1 on PHP 8.2

Create /etc/php/8.2/fpm/pool.d/site1.conf:

[site1]
user = site1
group = site1

; The socket unique to this site and version
listen = /run/php/php8.2-fpm-site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Process manager tuning
pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

; Timeouts and stability
request_terminate_timeout = 60s
request_slowlog_timeout = 3s
emergency_restart_threshold = 10
emergency_restart_interval = 1m

; Logs
slowlog = /var/log/php8.2-fpm/site1-slow.log
php_admin_value[error_log] = /var/log/php8.2-fpm/site1-error.log
php_admin_flag[log_errors] = on
catch_workers_output = yes

; Security
security.limit_extensions = .php .php8

; Env and working dir
env[APP_ENV] = production
chdir = /var/www/site1/current

Make sure the log directories exist and are writable by the FPM master (often root creates them, and the FPM service writes into them):

sudo mkdir -p /var/log/php8.2-fpm
sudo touch /var/log/php8.2-fpm/site1-error.log /var/log/php8.2-fpm/site1-slow.log
sudo systemctl reload php8.2-fpm

Example: legacy site on PHP 7.4

Create /etc/php/7.4/fpm/pool.d/legacy.conf:

[legacy]
user = site2
group = site2

listen = /run/php/php7.4-fpm-legacy.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 8
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 400

request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php7.4-fpm/legacy-slow.log
php_admin_value[error_log] = /var/log/php7.4-fpm/legacy-error.log
php_admin_flag[log_errors] = on

security.limit_extensions = .php .php7
chdir = /var/www/site2/current

Create log directories and reload:

sudo mkdir -p /var/log/php7.4-fpm
sudo touch /var/log/php7.4-fpm/legacy-error.log /var/log/php7.4-fpm/legacy-slow.log
sudo systemctl reload php7.4-fpm

A quick note about OpCache

OpCache lives inside each PHP‑FPM service. That means all pools within the same FPM version share the same OpCache memory. If you need completely separate OpCache configurations, you run separate FPM services (which you already do per version). Many OpCache directives are system‑level and best set in the global php.ini for that version. Per‑pool tweaks that are allowed by the SAPI can go into php_admin_value lines, but I prefer to keep OpCache sizing consistent per PHP version to avoid surprises.

Nginx Server Blocks: Point Each Site to Its Pool

We’ll set up one server block per site. Each block serves static files directly and forwards only .php requests to the correct PHP‑FPM socket. Keep the config boring and predictable—future you will thank you.

site1 on PHP 8.2

server {
    listen 80;
    server_name site1.example.com;
    root /var/www/site1/current/public;

    access_log /var/log/nginx/site1.access.log;
    error_log  /var/log/nginx/site1.error.log warn;

    index index.php index.html;

    # Serve static quickly
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Deny PHP execution in uploads
    location ~* /(?:uploads|files|media)/.*.php$ {
        deny all;
    }

    # PHP handler
    location ~ .php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass unix:/run/php/php8.2-fpm-site1.sock;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_read_timeout 60s;
    }

    client_max_body_size 64m;
}

legacy site on PHP 7.4

server {
    listen 80;
    server_name legacy.example.com;
    root /var/www/site2/current/public;

    access_log /var/log/nginx/legacy.access.log;
    error_log  /var/log/nginx/legacy.error.log warn;

    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~* /(?:uploads|files|media)/.*.php$ {
        deny all;
    }

    location ~ .php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass unix:/run/php/php7.4-fpm-legacy.sock;
        fastcgi_read_timeout 60s;
    }

    client_max_body_size 32m;
}

Two details save me headaches. First, I always use $realpath_root so that if a symlink points to a new release directory, PHP sees the resolved path. Second, I scope Nginx logs per site. When things misbehave, site‑specific logs cut the noise. When you want a full picture, ship Nginx and PHP‑FPM logs to centralized storage—if you’re curious, here’s how I handle centralised logging with Loki, Promtail and Grafana.

If you prefer to keep configs DRY, you can include a common snippet for the PHP location and only change the fastcgi_pass socket per site. Just don’t over‑abstract to the point of confusion. Clarity wins at 3 a.m.

Test, Verify, and Avoid the Classic Traps

Quick smoke tests

Create a simple PHP info file per site, hit it once, then remove it:

echo '<?php phpinfo();' | sudo -u site1 tee /var/www/site1/current/public/info.php
curl -s http://site1.example.com/info.php | head -n 5
rm /var/www/site1/current/public/info.php

Make sure the page shows the expected PHP version. If you see a 502 Bad Gateway, check socket paths, permissions, and whether the FPM service is running. If you see a 404, you might not have deployed an index.php yet or try_files isn’t falling back to it correctly.

Common pitfalls I still see

One, the FPM socket permissions don’t let Nginx connect. Nginx usually runs as www-data or nginx, so set listen.owner and listen.group appropriately and use mode 0660. Two, the pool user doesn’t match the site’s owner, so file writes fail. Keep user/group aligned with the site owner. Three, pm.max_children is too low or too high. Too low starves busy sites; too high chews through RAM. Start conservative, then tune with data. Four, someone confuses the CLI version with FPM. They’re separate worlds.

I also like to have a status endpoint per pool while I’m tuning. You can add pm.status_path = /fpm-status in the pool config and then expose it behind an IP allowlist in Nginx. It shows active processes and queue length. Just don’t leave it open to the world.

Tuning the Pools: Calm Performance Without Guesswork

The biggest lever is the process manager. I lean on pm = dynamic for most websites, because it keeps a warm set of workers ready under load and shrinks during quiet hours. If your server hosts many low‑traffic sites, pm = ondemand can be frugal, spinning workers up only when needed. The catch is that first requests might feel colder. For heavy, steady traffic, pm = static can reduce latency jitter at the cost of memory.

Speaking of memory: each PHP worker consumes RAM. How much depends on the app and loaded extensions. I usually estimate with a small load test and observe the RSS of worker processes. From there, I cap pm.max_children so total potential usage per pool won’t drown the server. Remember, multiple pools and versions all live on the same box. Leave room for Nginx, databases, and caches.

Set pm.max_requests to recycle workers occasionally. It nudges away slow memory leaks in userland code or extensions. Values between 300 and 1000 are common for me, depending on traffic patterns. Timeouts help too: request_terminate_timeout keeps zombie requests from hanging forever, and request_slowlog_timeout is perfect for catching code paths that suddenly take ages. The slow log will become your new favorite detective tool.

For insight, the FPM status page is gold. Watch queue length—if requests pile up, you might need more children or faster code. Also watch CPU saturation and IO. Sometimes what looks like a PHP problem is actually slow disk or a chatty database. That’s when I’m glad I can jump into centralized metrics and logs instead of guessing.

Security Basics That Age Well

Even a simple setup benefits from a few habits. First, run each pool as the site’s own Unix user, and make the code directories owned by that user. Nginx should only need read access, and only the pool for that site should have write access where uploads land. A common approach is to set directory permissions to 750 and files to 640, with group access tuned to your web user if needed.

Second, keep sockets private. Placing them under /run/php/ with mode 0660 and group www-data is a sensible default. If your distro uses nginx as the worker user, adjust groups accordingly. Third, give each site separate logs. When someone uploads a massive video and runs the disk near full, you’ll know which site to call first and you won’t drown in mixed output. If you haven’t set up an observability stack yet, now’s a good time to think about it—shipping logs off box makes troubleshooting faster when you need it most.

Finally, don’t rely on open_basedir to save the day. Clean file permissions and process isolation are sturdier. If you need stronger boundaries, containers or VMs per project are worth considering, but for many teams, per‑site FPM pools with sane permissions hit the sweet spot of simplicity and safety.

Upgrades, Switchovers, and Rollbacks Without Panic

Here’s the smooth way to upgrade a site from PHP 7.4 to 8.2 without breaking a sweat. Keep both FPM versions installed. Create a new pool on 8.2 mirroring the old pool’s settings. Make sure extensions and INI tweaks match what the app expects. Then duplicate the Nginx server block into a temporary test host (or use hosts file mapping on your laptop) and point that copy at the new 8.2 socket. Hit your critical pages, run a quick smoke test, check logs, and watch the FPM status. If it looks healthy, flip the production block’s fastcgi_pass to the 8.2 socket and reload Nginx.

If something misbehaves? Switch fastcgi_pass back to the 7.4 socket and reload. You’ve rolled back in seconds, with no package downgrades or server restarts. That’s the beauty of per‑site sockets. When I first adopted this approach, the stress of PHP upgrades dropped like a stone. I could schedule careful tests during the day instead of crossing my fingers at midnight.

One thing to remember: after deploying new code, you may want to clear OpCache for that site’s version so new files load immediately. A quick FPM reload for that version does the trick and is typically safe in production—workers restart gracefully and pick up the new code.

Little Quality‑of‑Life Improvements

Over time, small touches make the stack pleasant to live with. I like to set a distinct pm.status_path per pool and hide it behind an allowlist so I can peek at live activity. I add a ping path (ping.path, ping.response) in case I need a simple health check that doesn’t hit the app. In Nginx, I drop in a rule to deny PHP execution in the uploads directory—it’s a cheap, effective guardrail. And for performance, a couple of FastCGI buffer lines stop big responses from thrashing.

For Laravel and Symfony, ensure the worker user can write to storage or var directories. For WordPress, watch the wp-content/uploads permissions. If you’re using Composer, run it as the site user so vendor files aren’t owned by root. It’s the little things that keep deploys calm. When your process is boring, you’re doing it right.

Docs I Keep Handy (and Why)

Whenever I forget a directive name or want to double‑check behavior, I lean on the official docs. The the PHP‑FPM pool configuration directives page is where I confirm what can go into php_admin_value and how timeouts behave. For Nginx, the Nginx FastCGI parameters reference is my go‑to whenever I need to tweak buffering or pass extra params to PHP. And for Debian/Ubuntu, I bookmark Ondřej Surý’s PHP repository instructions to refresh how to add or pin versions cleanly. It saves a lot of tab‑chaos in the heat of the moment.

Putting It All Together: A Short Walkthrough

Let’s do a quick lap to make it real. Imagine you’re adding a new app on PHP 8.2 while keeping an old site on 7.4. You create the site1 user and directories, install php8.2-fpm, and write the site1.conf pool that runs as the site user and listens on a unique socket. You point an Nginx server block at /var/www/site1/current/public, give it a clean PHP location that sends requests to /run/php/php8.2-fpm-site1.sock, and restart services. You deploy code, run a phpinfo to confirm the version, then remove it. You visit the home page. It’s fast. Logs are clean.

Next, you clone that process for legacy on 7.4. Different user, different pool, different socket. Nginx server block points to the legacy socket. You repeat the smoke test. Now both sites run on the same server, with their own PHP versions and their own lanes. You didn’t make a complicated cluster or container mesh. You just used what PHP and Nginx already offer—properly.

When it’s time to upgrade the legacy site, you prepare a new 8.2 pool and a test Nginx block, shake it down, and switch the socket in production when you’re ready. If a plugin chokes, you revert with a single line and try again tomorrow. Zero midnight drama, zero panicked rollbacks.

Wrap‑Up: Calm Servers, Happy Migrations

I love this pattern because it lowers the stakes. Running multiple PHP versions on one server with per‑site Nginx + PHP‑FPM pools turns scary platform upgrades into tiny, reversible steps. Each site lives in its own space. You can dial in worker counts, control timeouts, and collect logs per app. You can test a new PHP on a single domain without touching the rest. And when you finally retire an old version, you’ll feel it in your shoulders—less weight.

If you’re starting from scratch, begin with one site and make it spotless. Create the system user. Write the FPM pool by hand. Point Nginx at that pool. Add the deny rule for uploads. Tail the logs while you click around. Then repeat for the next site, and the next. Keep a short checklist near your terminal, and don’t be shy about adding small quality‑of‑life improvements over time. Your future self will send you a thank‑you note.

Hope this was helpful! If you have questions or want me to look over a config, drop me a line. I’ll be around—with a warm coffee and a soft spot for tidy server neighborhoods.

Frequently Asked Questions

Absolutely. Install both php-fpm services, create a dedicated pool per site, and point each Nginx server block’s fastcgi_pass at the correct Unix socket. Each site then uses its own PHP version without stepping on the others.

Spin up a new pool on the target version, duplicate the Nginx block for testing, and verify the app against the new socket. When ready, change fastcgi_pass in the live server block to the new pool and reload Nginx. If something breaks, switch back just as quickly.

Start with pm = dynamic, set reasonable pm.max_children based on real memory use, and recycle workers with pm.max_requests. Add request_terminate_timeout and a slowlog to catch outliers. Watch the FPM status page—if queues build, scale workers or improve code paths.