CAREER & HIRING ADVICE
Share it
Facebook
Twitter
LinkedIn
Email

Consolidating Many CMS Sites on One Server: A Capacity Plan for Hosting Teams

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 -20

For 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.

SitePeak uncached req/sAvg PHP time (s)Busy workers at peakPool limit (pm.max_children)
Online shop120.354.27
Membership site60.503.05
News site with page cache40.251.02
25 brochure sites together50.201.02 per site
Estate total279.264

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:

ComponentHow to estimate itNotes
PHP workersPool limit × resident memory per worker under loadUse measured memory, not the memory_limit setting
OPcacheopcache.memory_consumption for each PHP-FPM masterThe default is 128 MB, which many multi-site servers outgrow
MariaDBinnodb_buffer_pool_size plus per-connection buffersThe default buffer pool is 128 MB, far too small for this role
Redis object cachemaxmemory for each instanceSet an eviction policy so it cannot grow without limit
Operating system and page cacheThe remaining memorySpare RAM caches uploads, images and static files
Headroom20 to 30 percent of the totalAbsorbs 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/sessions

For 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.target

Each 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:

LayerControlWhat it prevents
PHPPool and Unix user per siteOne site using all workers or reading another site’s files
Processsystemd CPUQuota and MemoryMax for heavy sitesA leak or runaway script starving the server
DatabaseDatabase, user and MAX_USER_CONNECTIONS per siteConnection exhaustion spreading to other sites
Web serverlimit_req per siteA crawler or attack consuming a site’s workers
CacheKey prefix or instance per siteCache collisions and data leaking between sites
SchedulingReal cron jobs with staggered start timesEvery 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.

  1. Lower DNS TTLs a few days before each wave.
  2. Copy files and databases, then run each site on the new server under a temporary hostname.
  3. Test forms, checkout, logins, scheduled tasks and outgoing email.
  4. Run a final sync during a quiet period, then switch DNS.
  5. Keep the old server running for at least a week before cancelling it.
  6. 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.

Share it
Facebook
Twitter
LinkedIn
Email

Categories

Related Posts

YOUR NEXT ENGINEERING OR IT JOB SEARCH STARTS HERE.

Don't miss out on your next career move. Work with Apollo Technical and we'll keep you in the loop about the best IT and engineering jobs out there — and we'll keep it between us.

HOW DO YOU HIRE FOR ENGINEERING AND IT?

Engineering and IT recruiting are competitive. It's easy to miss out on top talent to get crucial projects done. Work with Apollo Technical and we'll bring the best IT and Engineering talent right to you.