Disclosure: this guide was written by the team at Unihost, a provider of dedicated servers and VPS hosting, including the AMD EPYC based machines mentioned in the hardware section. The method and configuration examples are vendor-neutral and work on any modern Linux server.
Small hosting teams and web agencies often inherit a messy estate. Forty WordPress, Joomla or Drupal sites sit on a dozen small virtual servers, each with its own PHP version, patch schedule and backup script. Nobody planned it that way. Each new client simply got a new box, and after a few years the team spends more time maintaining servers than improving sites.
Consolidating those sites onto one powerful server can cut that maintenance work and improve performance, but only if the capacity plan is sound and the sites are properly isolated from each other. Put forty busy sites on one machine without limits, and a single traffic spike, runaway cron job or compromised plugin can take every client down at once.
This guide walks through the whole process: auditing the current estate, sizing PHP workers and memory, isolating sites with PHP-FPM, systemd and database limits, choosing and laying out the hardware, and migrating without drama. The examples use Nginx, PHP-FPM and MariaDB on a Debian-based Linux distribution, but the method applies to any similar stack.
Audit the Estate Before You Size Anything
Capacity plans fail when they start from a server catalogue. Start instead from two to four weeks of data from the sites you already run, and include a busy period if your clients’ businesses are seasonal.
Collect the Numbers That Matter
For each site, record the following:
- Peak requests per second, split into requests served from a cache and requests that reach PHP.
- Average and 95th percentile PHP execution time for uncached requests.
- Database size, number of tables and the count of slow queries.
- Disk usage for code, uploads and backups.
- Scheduled tasks: what runs, how often and for how long.
Most of this data already exists on your servers. If each site writes its own Nginx access log, a one-line command ranks the sites by total requests in the current log files:
wc -l /var/log/nginx/*.access.log | sort -rn | head -20For PHP, enable the status page and the slow log on every pool with the pm.status_path and request_slowlog_timeout directives. The PHP-FPM status page reports, among other fields, active processes, the listen queue and a “max children reached” counter, which increases each time a pool hits its worker limit. If that counter is above zero on your current servers, the site was already queuing requests at peak, and your plan has to give it more room than it has today.
On the database side, turn on the slow query log with slow_query_log = 1 and long_query_time = 1, then summarise it with a tool such as pt-query-digest from Percona Toolkit. Fix the worst queries before you migrate. A missing index burns exactly as much CPU on a new server as it did on the old one, and consolidation makes that waste everyone’s problem.
Sort, Then Simplify
Sort the list by requests that reach PHP. In most estates, a handful of sites generate the bulk of the load: online shops, membership sites and anything with logged-in users. The long tail of brochure sites rarely matters for capacity, because a page cache serves nearly all of their traffic.
Use the audit to shrink the estate as well. Retire abandoned staging copies and forgotten campaign sites, and agree on a single PHP version for the new server with a short, documented list of exceptions. Every site you remove now is one less thing to migrate, patch, monitor and back up for years to come.
Size PHP Workers, CPU and Memory
Workers From Real Traffic
For a typical CMS, the main capacity limit is the number of PHP workers that can run at the same time. Each uncached request occupies one worker for as long as it takes to build the page. Little’s law from queueing theory gives a reliable first estimate: the average number of requests in a system equals the arrival rate multiplied by the time each request spends there.
Busy workers at peak = peak uncached requests per second × average PHP time in seconds
A shop that receives 12 uncached requests per second at peak, with an average execution time of 0.35 seconds, keeps about 4.2 workers busy. Requests do not arrive evenly, and some take much longer than average, so allow 30 to 50 percent headroom when you set the pool limit.
The table applies the same calculation to a small estate. The inputs are illustrative, not measurements, so replace them with the figures from your own audit.
| Site | Peak uncached req/s | Avg PHP time (s) | Busy workers at peak | Pool limit (pm.max_children) |
|---|---|---|---|---|
| Online shop | 12 | 0.35 | 4.2 | 7 |
| Membership site | 6 | 0.50 | 3.0 | 5 |
| News site with page cache | 4 | 0.25 | 1.0 | 2 |
| 25 brochure sites together | 5 | 0.20 | 1.0 | 2 per site |
| Estate total | 27 | 9.2 | 64 |
Note the gap between the last two columns. Pool limits protect each site from its neighbours, so their sum is far higher than the number of workers that are busy at any one time. CPU sizing follows the busy workers, not the limits.
Two rules turn workers into hardware:
- CPU cores. A worker that is executing PHP keeps most of one core busy. Plan for the sum of busy workers during the busiest real hour, plus cores for the database, the web server, caches and backup jobs. As a starting point, keep busy PHP workers at or below 60 to 70 percent of the available cores.
- Peak overlap. Do not add up every site’s individual peak. A news site peaks in the morning and a shop in the evening. The figure that matters is the total at the busiest hour that actually occurs in your logs.
Build a Memory Budget
Memory usually runs out before CPU on a consolidated CMS server. Build the budget from measured values rather than defaults:
| Component | How to estimate it | Notes |
|---|---|---|
| PHP workers | Pool limit × resident memory per worker under load | Use measured memory, not the memory_limit setting |
| OPcache | opcache.memory_consumption for each PHP-FPM master | The default is 128 MB, which many multi-site servers outgrow |
| MariaDB | innodb_buffer_pool_size plus per-connection buffers | The default buffer pool is 128 MB, far too small for this role |
| Redis object cache | maxmemory for each instance | Set an eviction policy so it cannot grow without limit |
| Operating system and page cache | The remaining memory | Spare RAM caches uploads, images and static files |
| Headroom | 20 to 30 percent of the total | Absorbs traffic spikes, deployments and growth |
The PHP line is the usual trap. A pool with pm.max_children = 20 and 120 MB per worker can claim 2.4 GB on its own, and memory_limit is only a per-request ceiling, not a measure of typical use. Measure resident memory with your process monitor during a realistic load test before you trust any total.
Isolate Every Site
Isolation is what makes consolidation safe. The goal is simple: a problem on one site stays that site’s problem.
One PHP-FPM Pool and System User per Site
Give each site its own pool, socket and Unix user. A compromised site then cannot read another site’s files, and a busy site cannot borrow another site’s workers. The PHP-FPM configuration reference documents every directive in this example:
; /etc/php/8.3/fpm/pool.d/shop1.conf
[shop1]
user = shop1
group = shop1
listen = /run/php/shop1.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 7
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php/shop1-slow.log
php_admin_value[memory_limit] = 256M
php_admin_value[open_basedir] = /var/www/shop1/
php_admin_value[upload_tmp_dir] = /var/www/shop1/tmp
php_admin_value[session.save_path] = /var/www/shop1/sessionsFor brochure sites, pm = ondemand with a small pm.max_children and pm.process_idle_timeout = 10s stops idle sites from holding memory. Protect the status page so that only your monitoring system can reach it.
Hard Limits With systemd for Heavy Sites
Pool limits cap the number of workers, but every pool under one PHP-FPM master runs in the same control group. A memory leak in one site can therefore still push the whole server into swap. For the heaviest sites, run a separate master per site with a templated systemd unit and give it hard limits. The systemd resource control documentation explains each option:
# /etc/systemd/system/php-fpm@.service
[Unit]
Description=PHP-FPM for %i
After=network.target
[Service]
ExecStart=/usr/sbin/php-fpm8.3 --nodaemonize --fpm-config /etc/php/8.3/fpm/sites/%i.conf
CPUQuota=300%
MemoryHigh=2G
MemoryMax=2560M
TasksMax=200
[Install]
WantedBy=multi-user.targetEach file in the sites directory holds a [global] section with its own PID file and error log, followed by that site’s pool. CPUQuota=300% caps the site at the equivalent of three cores, MemoryHigh starts throttling memory use, and MemoryMax is the hard ceiling. Start a site with systemctl enable --now php-fpm@shop1. Separate masters cost extra memory and OPcache, so keep them for the few sites that justify them.
Database Limits
Give each site its own database and user, and cap connections so that one misbehaving site cannot use up the server’s max_connections. MariaDB supports per-account resource limits in CREATE USER:
CREATE USER 'shop1'@'localhost' IDENTIFIED BY 'use-a-long-random-password'
WITH MAX_USER_CONNECTIONS 30;
GRANT ALL PRIVILEGES ON shop1.* TO 'shop1'@'localhost';Set the global max_connections to cover the number of connections you really expect at the same time, with a margin, rather than the theoretical sum of every limit.
Rate Limits and Shared Caches
Per-IP rate limits at the web server keep one aggressive crawler or small attack from occupying every worker of a site. In Nginx, define a zone once and apply it in each site:
# http context
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# server or location context of each site
limit_req zone=perip burst=20 nodelay;Shared caches need the same care. Give every site a unique key prefix in the Redis object cache, or run a separate Redis instance with its own password for clients who need strict separation. Keep page cache storage in a separate directory per site, so purging one site never touches another.
This table summarises the controls:
| Layer | Control | What it prevents |
|---|---|---|
| PHP | Pool and Unix user per site | One site using all workers or reading another site’s files |
| Process | systemd CPUQuota and MemoryMax for heavy sites | A leak or runaway script starving the server |
| Database | Database, user and MAX_USER_CONNECTIONS per site | Connection exhaustion spreading to other sites |
| Web server | limit_req per site | A crawler or attack consuming a site’s workers |
| Cache | Key prefix or instance per site | Cache collisions and data leaking between sites |
| Scheduling | Real cron jobs with staggered start times | Every site running heavy tasks at the same minute |
Choose and Lay Out the Server
Cache Before You Buy Hardware
Caching is the cheapest capacity you can buy, so settle it before you choose the server. A page cache, whether Nginx FastCGI caching or a well-configured CMS plugin, can serve most anonymous traffic without touching PHP at all. OPcache should be large enough to hold the compiled code of every site on the master, so raise opcache.memory_consumption and opcache.max_accelerated_files and check the hit rate after a week of real traffic. An object cache then removes repeated database queries for logged-in users. Re-run the worker calculation after caching, because the number of uncached requests usually falls sharply.
CPU, Memory and Storage
Consolidated CMS hosting is a highly parallel workload. Hundreds of small requests matter more than any single long calculation, so a high core count usually beats a slightly higher clock speed. Memory has to cover the budget above with headroom, and memory bandwidth matters too, because many cores share the same memory channels. Fill every channel your platform supports, since half-populated channels quietly reduce performance.
That profile is why teams consolidating dozens of sites often shortlist amd epyc servers alongside other many-core platforms: a single socket provides many cores, many memory channels and plenty of PCIe lanes for NVMe drives. Whatever you choose, compare complete configurations against your own measured numbers, and keep at least 30 percent spare capacity at the expected peak.
For storage, use NVMe drives in RAID 1 or RAID 10, so that a single drive failure does not become an outage. Put databases on their own volume, keep site files and logs separate, and never treat a backup on the same array as a backup. Ask for a remote console such as IPMI, so you can recover the server without waiting for hands-on support.
Migrate in Waves and Keep It Healthy
Move Sites in Small Batches
A big-bang migration is the riskiest way to do this. Move sites in waves, starting with low-traffic sites, so that surprises appear while the stakes are small.
- Lower DNS TTLs a few days before each wave.
- Copy files and databases, then run each site on the new server under a temporary hostname.
- Test forms, checkout, logins, scheduled tasks and outgoing email.
- Run a final sync during a quiet period, then switch DNS.
- Keep the old server running for at least a week before cancelling it.
- Compare the new server’s metrics with your plan before you start the next wave.
Move the heaviest sites last. By then the team knows the new platform, and the limits are tuned on real traffic.
Watch the Signals That Matter
After each wave, check a short list of signals rather than a wall of dashboards:
- Max children reached and listen queue on every PHP-FPM pool. A rising counter means the pool limit is too low, or the site needs caching.
- Memory pressure, which modern Linux kernels expose in
/proc/pressure/memory. Rising values warn you before swapping starts. - Database connections and slow queries per user, to catch a single site misbehaving.
- Disk usage per site, including logs and backups.
- Backup results, with an alert on any failed job.
Review capacity every quarter. Compare peak busy workers, memory use and disk growth with the plan, and decide whether you can accept new clients or need more headroom first.
Plan for Failure
One server means one failure domain, and clients should understand that. Keep off-site backups for every site, test a full restore onto different hardware at least once a quarter, and write down the order in which sites come back after a failure. For the largest clients, a warm standby with database replication may be worth the extra cost. Decide the trigger in advance, such as a contractual recovery time or a number of clients, rather than after the first outage.
Done well, consolidation leaves you with one patching routine, one backup design and one place to look when something goes wrong. The capacity plan keeps the server fast, isolation keeps clients safe from each other, and the waves keep the migration calm. For a small hosting team, the time saved on routine maintenance is often worth as much as any saving on infrastructure.