VPS Hosting for WordPress Agencies: Managing 50+ Client Sites

VPS Hosting for WordPress Agencies: Managing 50+ Client Sites

Managing one WordPress site on a VPS is straightforward. Managing 50 client sites is an operational challenge β€” updates, backups, security patches, staging environments, client access control, and billing all need systems that scale. This guide covers the tools, architecture, and workflows that allow a WordPress agency to run dozens of client sites efficiently on a VPS.

Architecture Options for Multi-Site Agencies

Architecture Best for Pros Cons
All sites on one VPS Up to ~30 small sites Simple, cost-effective Single point of failure
Sites grouped by client tier 30–100 sites Risk isolation per group More VPS to manage
Dedicated VPS per large client Enterprise clients Maximum isolation Higher cost per client
WordPress Multisite Sites with shared plugins/themes Central admin for all sites One breach affects all

πŸ’‘ VPS.DO Recommendation: For 20–40 average WordPress sites, the USA VPS 30IPs plan (4 vCPU / 8 GB RAM / 500 GB SSD) provides both compute headroom and multiple IPs for client isolation. For 50+ sites, consider grouping clients across 2–3 VPS instances. View Plans β†’


Part 1: Server Setup for Multiple WordPress Sites

Step 1: Install the LEMP Stack

sudo apt update && sudo apt upgrade -y
sudo apt install nginx mariadb-server php8.3-fpm \
  php8.3-mysql php8.3-xml php8.3-mbstring php8.3-curl \
  php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath \
  php8.3-redis redis-server certbot python3-certbot-nginx \
  wp-cli -y

sudo systemctl enable nginx mariadb php8.3-fpm redis-server

Step 2: PHP-FPM Pool per Client

Isolate each client’s PHP processes β€” prevents one buggy plugin from affecting others:

# Create a PHP-FPM pool for each client
sudo cp /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/client1.conf
sudo nano /etc/php/8.3/fpm/pool.d/client1.conf
[client1]
user  = client1
group = client1
listen = /run/php/php8.3-fpm-client1.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 5
pm.max_requests = 500

Step 3: Nginx Config Template for Client Sites

nano /etc/nginx/templates/wordpress.conf.template
server {
    listen 443 ssl http2;
    server_name CLIENT_DOMAIN www.CLIENT_DOMAIN;
    root /var/www/CLIENT_SLUG;
    index index.php;

    ssl_certificate /etc/letsencrypt/live/CLIENT_DOMAIN/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/CLIENT_DOMAIN/privkey.pem;

    server_tokens off;
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    client_max_body_size 64M;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm-CLIENT_SLUG.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~* \.(css|gif|ico|jpeg|jpg|js|png|woff2|svg)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        log_not_found off;
    }

    location ~ /\.(ht|git|env) { deny all; }
    location = /xmlrpc.php       { deny all; }

    access_log /var/log/nginx/CLIENT_SLUG.access.log;
    error_log  /var/log/nginx/CLIENT_SLUG.error.log;
}

Part 2: New Client Onboarding Script

Automate the entire setup of a new client site in minutes:

nano ~/new-client.sh
#!/bin/bash
set -e

DOMAIN=$1
SLUG=${DOMAIN//\./_}
DB_NAME="wp_${SLUG:0:20}"
DB_USER="${SLUG:0:16}_user"
DB_PASS=$(openssl rand -base64 16)
WP_ADMIN_PASS=$(openssl rand -base64 12)

if [ -z "$DOMAIN" ]; then
    echo "Usage: $0 yourdomain.com"
    exit 1
fi

echo "=== Setting up WordPress for: $DOMAIN ==="

# 1. Create system user
sudo useradd -m -s /bin/bash $SLUG 2>/dev/null || true

# 2. Create web root
sudo mkdir -p /var/www/$DOMAIN
sudo chown -R $SLUG:$SLUG /var/www/$DOMAIN

# 3. Create database
sudo mysql -u root -e "
    CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';
    GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';
    FLUSH PRIVILEGES;"

# 4. Install WordPress with WP-CLI
sudo -u $SLUG wp core download --path=/var/www/$DOMAIN
sudo -u $SLUG wp config create \
    --path=/var/www/$DOMAIN \
    --dbname=$DB_NAME \
    --dbuser=$DB_USER \
    --dbpass=$DB_PASS \
    --dbhost=localhost
sudo -u $SLUG wp core install \
    --path=/var/www/$DOMAIN \
    --url=https://$DOMAIN \
    --title="$DOMAIN" \
    --admin_user=admin \
    --admin_password=$WP_ADMIN_PASS \
    --admin_email=admin@$DOMAIN

# 5. Create PHP-FPM pool
sudo cp /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/$SLUG.conf
sudo sed -i "s/\[www\]/[$SLUG]/" /etc/php/8.3/fpm/pool.d/$SLUG.conf
sudo sed -i "s/user = www-data/user = $SLUG/" /etc/php/8.3/fpm/pool.d/$SLUG.conf
sudo sed -i "s/group = www-data/group = $SLUG/" /etc/php/8.3/fpm/pool.d/$SLUG.conf
sudo sed -i "s|listen = /run/php/php8.3-fpm.sock|listen = /run/php/php8.3-fpm-$SLUG.sock|" /etc/php/8.3/fpm/pool.d/$SLUG.conf
sudo systemctl reload php8.3-fpm

# 6. Create Nginx config
sudo cp /etc/nginx/templates/wordpress.conf.template /etc/nginx/sites-available/$DOMAIN
sudo sed -i "s/CLIENT_DOMAIN/$DOMAIN/g" /etc/nginx/sites-available/$DOMAIN
sudo sed -i "s/CLIENT_SLUG/$SLUG/g" /etc/nginx/sites-available/$DOMAIN
sudo ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

# 7. Install SSL
sudo certbot --nginx -d $DOMAIN -d www.$DOMAIN --non-interactive --agree-tos -m admin@youragency.com

# 8. Install essential plugins
sudo -u $SLUG wp plugin install redis-cache wordfence updraftplus --activate --path=/var/www/$DOMAIN

# Save credentials
cat >> ~/client-credentials.txt << EOF
=== $DOMAIN ===
WordPress Admin: https://$DOMAIN/wp-admin
Username: admin
Password: $WP_ADMIN_PASS
DB Name: $DB_NAME
DB User: $DB_USER
DB Pass: $DB_PASS
Date: $(date)

EOF

echo ""
echo "βœ… WordPress site ready: https://$DOMAIN"
echo "   Admin: admin / $WP_ADMIN_PASS"
echo "   Credentials saved to ~/client-credentials.txt"
chmod +x ~/new-client.sh
sudo bash ~/new-client.sh client1.com

Part 3: Mass Update Management with WP-CLI

WP-CLI is the command-line interface for WordPress β€” essential for managing dozens of sites efficiently:

nano ~/update-all-sites.sh
#!/bin/bash
LOG="/var/log/wp-updates-$(date +%Y%m%d).log"
SITES=$(ls /var/www/ | grep -v html)

echo "=== WordPress Mass Update: $(date) ===" | tee $LOG

for SITE in $SITES; do
    if [ -f "/var/www/$SITE/wp-config.php" ]; then
        echo "--- Updating: $SITE ---" | tee -a $LOG

        # Update WordPress core
        wp core update --path=/var/www/$SITE 2>&1 | tee -a $LOG

        # Update all plugins
        wp plugin update --all --path=/var/www/$SITE 2>&1 | tee -a $LOG

        # Update all themes
        wp theme update --all --path=/var/www/$SITE 2>&1 | tee -a $LOG

        # Run database upgrades
        wp core update-db --path=/var/www/$SITE 2>&1 | tee -a $LOG

        echo "βœ… $SITE updated" | tee -a $LOG
    fi
done

echo "=== Update complete ===" | tee -a $LOG
chmod +x ~/update-all-sites.sh

# Schedule weekly updates (Sunday 3 AM)
crontab -e
# 0 3 * * 0 /bin/bash /root/update-all-sites.sh

Part 4: Automated Backups for All Client Sites

nano ~/backup-all-sites.sh
#!/bin/bash
DATE=$(date +%Y-%m-%d)
BACKUP_BASE="/var/backups/clients"
SITES=$(ls /var/www/ | grep -v html)

for SITE in $SITES; do
    if [ -f "/var/www/$SITE/wp-config.php" ]; then
        BACKUP_DIR="$BACKUP_BASE/$SITE/$DATE"
        mkdir -p "$BACKUP_DIR"

        # Backup files
        tar -czf "$BACKUP_DIR/files.tar.gz" /var/www/$SITE \
            --exclude=/var/www/$SITE/wp-content/cache \
            2>/dev/null

        # Backup database using WP-CLI
        wp db export "$BACKUP_DIR/database.sql" \
            --path=/var/www/$SITE 2>/dev/null
        gzip "$BACKUP_DIR/database.sql"

        echo "βœ… Backed up: $SITE"
    fi
done

# Remove backups older than 30 days
find $BACKUP_BASE -type d -name "20*" -mtime +30 -exec rm -rf {} + 2>/dev/null

echo "=== Backup complete: $DATE ==="
chmod +x ~/backup-all-sites.sh
# 0 2 * * * /bin/bash /root/backup-all-sites.sh

Part 5: Staging Environments

nano ~/create-staging.sh
#!/bin/bash
LIVE_DOMAIN=$1
STAGING_DOMAIN="staging.$1"
LIVE_SLUG=${LIVE_DOMAIN//\./_}
STAGING_SLUG="staging_${LIVE_SLUG}"

# Clone the live site to staging
sudo cp -r /var/www/$LIVE_DOMAIN /var/www/$STAGING_DOMAIN

# Create staging database
STAGING_DB="staging_${LIVE_SLUG:0:16}"
STAGING_PASS=$(openssl rand -base64 12)
sudo mysql -u root -e "
    CREATE DATABASE $STAGING_DB;
    CREATE USER '$STAGING_DB'@'localhost' IDENTIFIED BY '$STAGING_PASS';
    GRANT ALL ON $STAGING_DB.* TO '$STAGING_DB'@'localhost';"

# Export live DB and import to staging
wp db export /tmp/live-export.sql --path=/var/www/$LIVE_DOMAIN
mysql -u $STAGING_DB -p$STAGING_PASS $STAGING_DB < /tmp/live-export.sql

# Update staging wp-config.php
wp config set DB_NAME $STAGING_DB --path=/var/www/$STAGING_DOMAIN
wp config set DB_USER $STAGING_DB --path=/var/www/$STAGING_DOMAIN
wp config set DB_PASSWORD $STAGING_PASS --path=/var/www/$STAGING_DOMAIN

# Update URLs in staging database
wp search-replace "https://$LIVE_DOMAIN" "https://$STAGING_DOMAIN" \
    --path=/var/www/$STAGING_DOMAIN --all-tables

# Create Nginx config for staging
sudo cp /etc/nginx/sites-available/$LIVE_DOMAIN \
        /etc/nginx/sites-available/$STAGING_DOMAIN
sudo sed -i "s/$LIVE_DOMAIN/$STAGING_DOMAIN/g" \
        /etc/nginx/sites-available/$STAGING_DOMAIN
sudo ln -sf /etc/nginx/sites-available/$STAGING_DOMAIN \
            /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d $STAGING_DOMAIN --non-interactive \
  --agree-tos -m admin@youragency.com

echo "βœ… Staging ready at: https://$STAGING_DOMAIN"

Part 6: Security Monitoring Across All Sites

nano ~/security-scan.sh
#!/bin/bash
LOG="/var/log/security-scan-$(date +%Y%m%d).log"

for SITE in $(ls /var/www/); do
    if [ -f "/var/www/$SITE/wp-config.php" ]; then
        echo "=== Scanning: $SITE ===" | tee -a $LOG

        # Check for outdated WordPress
        WP_VERSION=$(wp core version --path=/var/www/$SITE 2>/dev/null)
        LATEST=$(wp core check-update --path=/var/www/$SITE --format=json 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['version'] if d else 'up-to-date')" 2>/dev/null)

        echo "WordPress: $WP_VERSION (latest: $LATEST)" | tee -a $LOG

        # Check for plugins with known vulnerabilities
        wp plugin list --path=/var/www/$SITE \
            --fields=name,version,status \
            --format=table 2>/dev/null | tee -a $LOG

        # Check file permissions
        WORLD_WRITABLE=$(find /var/www/$SITE -type f -perm -o+w 2>/dev/null | wc -l)
        echo "World-writable files: $WORLD_WRITABLE" | tee -a $LOG
    fi
done

echo "=== Scan complete ===" | tee -a $LOG

Capacity Planning: How Many Sites Per VPS?

VPS RAM Idle sites Active (concurrent traffic)
4 GB 15–25 sites 5–8 sites with traffic
8 GB 30–50 sites 10–20 sites with traffic
16 GB 60–100 sites 25–40 sites with traffic

Fast β€’ Reliable β€’ Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!