Site Clusters & Multi-IP

Deploy Hundreds of Sites on a Multi-IP Server: One IP per Site with Nginx Templates, Bulk SSL and Verification

6 steps 17 min read 14 views 0
On this page

A site-cluster server with one or more /24 blocks can host hundreds of websites, each on its own IP address. Clicking through a control panel hundreds of times is slow and error-prone; a small set of templates and a script does the same in minutes and keeps every site configured identically. This guide shows how to plan the domain-to-IP mapping, stop sites from leaking onto the wrong IP, generate Nginx virtual hosts from a template, issue SSL certificates in bulk with acme.sh and check that every domain answers on its own IP.

This guide assumes all IPs are already bound on the server (see binding a full /24 on Linux) and that each domain's A record points to the IP you assign it. Try the script with two or three domains before running it for hundreds.

Step 1: Plan the Mapping

Keep one plain text file as the single source of truth: one line per site with the domain and its IP. Everything else is generated from it.

mkdir -p /root/cluster
cat > /root/cluster/sites.txt <<'EOF2'
example-a.com 203.0.113.10
example-b.com 203.0.113.11
example-c.net 203.0.113.12
EOF2

# Check that every IP in the list is actually bound on the server
awk '{print $2}' /root/cluster/sites.txt | while read ip; do ip -br addr | grep -qw "$ip" || echo "NOT BOUND: $ip"; done

Step 2: Stop Sites Leaking onto Other IPs

Nginx first chooses the listening address (IP and port) and then matches the hostname; a request with an unknown hostname is answered by the default server of that address, which is otherwise the first site configured on it. On a site cluster that means someone opening a bare IP, or sending another site's hostname to it, sees one of your sites, which links them together. Close such requests in two places: a catch-all for IPs that have no site yet (below), and a per-IP default server in the template (Step 3).

cat > /etc/nginx/conf.d/00-default.conf <<'EOF2'
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}
EOF2

# Hide the Nginx version in headers and error pages
sed -i 's/^\s*#\?\s*server_tokens.*/    server_tokens off;/' /etc/nginx/nginx.conf
grep -q 'server_tokens off' /etc/nginx/nginx.conf || sed -i '/http {/a\    server_tokens off;' /etc/nginx/nginx.conf

Step 3: Create the Virtual Host Template

The template uses placeholders that the script replaces for each site. Each site listens only on its own IP, and requests for any other hostname on that IP are closed.

mkdir -p /etc/nginx/sites-cluster /www/sites
cat > /root/cluster/vhost-http.tpl <<'EOF2'
# Unknown hostnames on this IP: close the connection
server {
    listen __IP__:80 default_server;
    server_name _;
    return 444;
}

server {
    listen __IP__:80;
    server_name __DOMAIN__ www.__DOMAIN__;
    root /www/sites/__DOMAIN__/public;
    index index.html index.php;
    access_log /www/sites/__DOMAIN__/logs/access.log;
    error_log  /www/sites/__DOMAIN__/logs/error.log;

    location /.well-known/acme-challenge/ { allow all; }
    location / { try_files $uri $uri/ =404; }
}
EOF2

# Load the generated files from the http block
grep -q 'sites-cluster' /etc/nginx/nginx.conf || sed -i '/http {/a\    include /etc/nginx/sites-cluster/*.conf;' /etc/nginx/nginx.conf

Step 4: Generate All Sites with One Script

cat > /root/cluster/deploy.sh <<'EOF2'
#!/bin/bash
set -e
while read -r DOMAIN IP; do
  [ -z "$DOMAIN" ] && continue
  mkdir -p /www/sites/$DOMAIN/public /www/sites/$DOMAIN/logs
  [ -f /www/sites/$DOMAIN/public/index.html ] || echo "<h1>$DOMAIN</h1>" > /www/sites/$DOMAIN/public/index.html
  sed -e "s/__DOMAIN__/$DOMAIN/g" -e "s/__IP__/$IP/g" /root/cluster/vhost-http.tpl > /etc/nginx/sites-cluster/$DOMAIN.conf
done < /root/cluster/sites.txt
chown -R www-data:www-data /www/sites 2>/dev/null || chown -R nginx:nginx /www/sites
nginx -t && systemctl reload nginx
EOF2
chmod +x /root/cluster/deploy.sh && /root/cluster/deploy.sh

Upload each site's content to /www/sites/DOMAIN/public. To add sites later, append lines to sites.txt and run the script again; existing content is not overwritten.

Step 5: Issue SSL Certificates in Bulk

Install acme.sh and set Let's Encrypt as the CA (see free SSL with Certbot or acme.sh). The loop below issues a certificate for each domain through its web root, installs it, and adds an HTTPS server block on the site's own IP.

cat > /root/cluster/ssl.sh <<'EOF2'
#!/bin/bash
mkdir -p /etc/nginx/ssl
while read -r DOMAIN IP; do
  [ -z "$DOMAIN" ] && continue
  [ -f /etc/nginx/ssl/$DOMAIN.crt ] && continue          # already done
  ~/.acme.sh/acme.sh --issue -d $DOMAIN -d www.$DOMAIN -w /www/sites/$DOMAIN/public || { echo "FAILED: $DOMAIN"; continue; }
  ~/.acme.sh/acme.sh --install-cert -d $DOMAIN \
     --key-file /etc/nginx/ssl/$DOMAIN.key --fullchain-file /etc/nginx/ssl/$DOMAIN.crt \
     --reloadcmd "systemctl reload nginx"
  cat >> /etc/nginx/sites-cluster/$DOMAIN.conf <<EOF3
server {
    listen $IP:443 ssl default_server;
    ssl_reject_handshake on;      # unknown hostnames on this IP (Nginx 1.19.4+)
}
server {
    listen $IP:443 ssl;
    http2 on;
    server_name $DOMAIN www.$DOMAIN;
    ssl_certificate     /etc/nginx/ssl/$DOMAIN.crt;
    ssl_certificate_key /etc/nginx/ssl/$DOMAIN.key;
    root /www/sites/$DOMAIN/public;
    index index.html index.php;
    access_log /www/sites/$DOMAIN/logs/access.log;
}
EOF3
  sleep 5
done < /root/cluster/sites.txt
nginx -t && systemctl reload nginx
EOF2
chmod +x /root/cluster/ssl.sh && /root/cluster/ssl.sh

Run it in batches of a few dozen domains; Let's Encrypt limits how many new certificates one account can order in a short time. Domains whose DNS does not point to the right IP yet will fail; fix the DNS and run the script again, it skips the ones already done. Renewal is automatic through acme.sh's cron job.

Step 6: Verify Every Site

# Each domain must answer on its own IP with HTTP 200 (or 301 if you redirect to HTTPS)
while read -r DOMAIN IP; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --resolve $DOMAIN:80:$IP http://$DOMAIN/)
  dns=$(dig +short $DOMAIN A | tail -1)
  echo "$DOMAIN $IP http=$code dns=$dns $( [ "$dns" = "$IP" ] && echo OK || echo DNS-MISMATCH)"
done < /root/cluster/sites.txt

# The bare IP must not show any site (connection closed = correct)
curl -sI http://203.0.113.10/ || echo "closed as expected"
curl -skI https://203.0.113.10/ || echo "handshake rejected as expected"

FAQ

Can I do this in aaPanel instead?

Yes: aaPanel can bind each site to an IP in the site settings, which is fine for a few dozen sites (see assigning a dedicated IP to each website). For hundreds of sites, templates and scripts are faster and less error-prone, and you can still use aaPanel for databases and monitoring.

Should outgoing traffic also use each site's own IP?

If your sites fetch content or call APIs, outgoing requests leave through the server's main IP by default. To make them use each site's IP, see the outbound IP section in assigning a dedicated IP to each website.

How should I spread sites across IP blocks?

For SEO site clusters, spreading sites across several /24 blocks (different C-classes) and keeping templates, analytics accounts and hosting details distinct matters more than the number of IPs. See SEO site-cluster servers with multiple C-class IPs.

Still stuck after following these steps? Open a support ticket and the IMIDC 24/7 technical team will help. Include the server IP, the OS version, the domain and IP that fail and the output of nginx -t so we can pinpoint the issue faster.

Was this answer helpful?

Related Tutorials