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 |