LinuxAugust 29, 2026 6 views

Host multiple websites on a VPS with Nginx (2026)

Host multiple websites on a VPS with Nginx (2026)

What "multiple websites on one VPS" really involves

Hosting several websites on a VPS is not just a matter of Nginx configuration. Three points to anticipate before you start:

  • RAM: every active site consumes memory (PHP-FPM workers, database, cache). A VPS with 4 GB of RAM can host several low-traffic websites, but not dozens of WordPress sites with simultaneous traffic. Size the server according to actual traffic, not to the number of sites.
  • Isolation: on a single PHP-FPM instance without specific configuration, the sites are not truly isolated. A compromised site can read the files of the others. The solution is to use separate PHP-FPM pools with distinct system users (see the dedicated section below).
  • Backups: with several sites, manual backups quickly become unmanageable. Automate them from the start with a loop over the directories and the databases.

Recommended directory layout

Use one directory per site under /var/www/, with the domain name as the identifier. This convention keeps backup scripts and Nginx configurations readable and predictable.

/var/www/
    site-a.fr/
    site-b.fr/
    site-c.fr/

Create the directories:

sudo mkdir -p /var/www/site-a.fr
sudo mkdir -p /var/www/site-b.fr
sudo mkdir -p /var/www/site-c.fr

Permissions will be adjusted when the PHP-FPM pools are created (next section).

One Nginx vhost per site

Create one configuration file per site

Each site gets its own file in /etc/nginx/sites-available/. Do not put everything in a single file: one file per site makes it easier to disable, debug and read a configuration.

Example for site-a.fr:

sudo nano /etc/nginx/sites-available/site-a.fr
server {
    listen 80;
    listen [::]:80;
    server_name site-a.fr www.site-a.fr;
    root /var/www/site-a.fr;
    index index.php index.html;

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

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

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

Repeat for each site, replacing site-a.fr and the PHP-FPM socket (site-a.sock) with the matching values.

Note: the socket /run/php/site-a.sock does not exist yet at this stage, it is created in the PHP-FPM pools step further down. nginx -t does not check whether the socket exists, so the configuration will validate, but the site will return a 502 error until the corresponding pool is in place.

Enable the sites

sudo ln -s /etc/nginx/sites-available/site-a.fr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/site-b.fr /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/site-c.fr /etc/nginx/sites-enabled/

Always test the configuration before reloading:

sudo nginx -t
sudo systemctl reload nginx

Source: nginx.org - server_name directive

The first-vhost-served trap and the catch-all vhost

What happens without an explicit default vhost

When Nginx receives a request for a domain or an IP address that is not declared in any server_name, it serves the first vhost loaded in alphabetical order. This behavior is documented and predictable, but it can expose a site unintentionally: a request to the raw IP address of the VPS, or to a domain pointing at the VPS by mistake, lands on the first configured site.

Source: nginx.org - How nginx processes a request

The catch-all vhost with default_server

Create a default vhost that intercepts every unrecognized request and returns an empty response (code 444, which closes the connection without an HTTP response):

sudo nano /etc/nginx/sites-available/default-catchall
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;

    # Self-signed certificate to absorb unrecognized HTTPS requests
    ssl_certificate /etc/nginx/ssl/self-signed.crt;
    ssl_certificate_key /etc/nginx/ssl/self-signed.key;

    return 444;
}

Generate a self-signed certificate for the catch-all SSL block (Certbot cannot issue a certificate for _):

sudo mkdir -p /etc/nginx/ssl
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/self-signed.key \
  -out /etc/nginx/ssl/self-signed.crt \
  -subj "/CN=localhost"

Enable the catch-all and reload:

sudo ln -s /etc/nginx/sites-available/default-catchall /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Code 444 closes the TCP connection without sending a response. Automated scanners and requests to the raw IP address get no information about the hosted websites.

Separate PHP-FPM pools per site

Why separate pools?

Without separate pools, every site runs under the same system user (often www-data). A compromised site can read the configuration files of the others (.env files, wp-config.php, API keys). Separate pools bring:

  • Permission isolation: each site runs under its own user
  • Per-site process limits: a site hit by a traffic spike does not consume every available PHP worker
  • Separate logs: quickly identify which site is generating errors

Source: php.net - PHP-FPM configuration

Create one system user per site

sudo useradd -r -s /usr/sbin/nologin site-a
sudo useradd -r -s /usr/sbin/nologin site-b
sudo useradd -r -s /usr/sbin/nologin site-c

Adjust the directory permissions:

sudo chown -R site-a:site-a /var/www/site-a.fr
sudo chown -R site-b:site-b /var/www/site-b.fr
sudo chown -R site-c:site-c /var/www/site-c.fr

Nginx (which runs as www-data) must be able to read the files. Either add www-data to the site user groups, or adjust the directory permissions:

sudo chmod 750 /var/www/site-a.fr
sudo usermod -aG site-a www-data

Worth knowing: adding www-data to each site group lets Nginx serve the static files, but it also means the Nginx process can read the files of every site. The isolation provided by the pools applies to PHP execution, not to reads by the web server. For stricter isolation, you need separate containers or virtual machines.

Create one PHP-FPM pool per site

Copy the default pool as a starting point:

sudo cp /etc/php/8.4/fpm/pool.d/www.conf /etc/php/8.4/fpm/pool.d/site-a.conf
sudo nano /etc/php/8.4/fpm/pool.d/site-a.conf

Change these directives in the file (replace [www] with [site-a]):

[site-a]
user = site-a
group = site-a
listen = /run/php/site-a.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

Repeat for each site (site-b.conf, site-c.conf), adapting the pool name, the user and the socket.

Disable the default pool once every site has its own pool:

sudo mv /etc/php/8.4/fpm/pool.d/www.conf /etc/php/8.4/fpm/pool.d/www.conf.disabled

Check beforehand that no vhost still references the default socket /run/php/php8.4-fpm.sock: those sites would return a 502 error once the pool is disabled.

Restart PHP-FPM:

sudo systemctl restart php8.4-fpm

Each site socket (/run/php/site-a.sock) matches the fastcgi_pass directive in the corresponding Nginx vhost.

Separate databases per site

One dedicated MariaDB database and user per site. Never use a shared user across several sites: compromising one site would give access to every database.

sudo mariadb -u root
-- Site A
CREATE DATABASE site_a_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'site_a_user'@'localhost' IDENTIFIED BY 'strong_password_a';
GRANT ALL PRIVILEGES ON site_a_db.* TO 'site_a_user'@'localhost';

-- Site B
CREATE DATABASE site_b_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'site_b_user'@'localhost' IDENTIFIED BY 'strong_password_b';
GRANT ALL PRIVILEGES ON site_b_db.* TO 'site_b_user'@'localhost';

FLUSH PRIVILEGES;
EXIT;

Multi-domain SSL certificates

Certbot handles the certificate of each domain independently. Issue one certificate per domain (or per domain/www pair):

sudo certbot --nginx -d site-a.fr -d www.site-a.fr
sudo certbot --nginx -d site-b.fr -d www.site-b.fr
sudo certbot --nginx -d site-c.fr -d www.site-c.fr

Certbot automatically edits the Nginx vhosts to add the SSL directives and the HTTP to HTTPS redirection.

Automatic renewal is handled by a systemd timer installed by Certbot. Check that it is active:

sudo systemctl status certbot.timer

Full guide (renewal, wildcard certificates, error troubleshooting): OuiHeberg Certbot guide.

Sizing: how many sites for how much RAM?

There is no universal answer: the number of sites you can host depends on the traffic of each site, the CMS used and the active plugins. A few reasonable orders of magnitude:

  • A WordPress site with OPcache enabled and little simultaneous traffic uses between 128 MB and 256 MB of RAM under normal load.
  • A static site or a lightweight application uses noticeably less.
  • A WooCommerce site with real traffic or heavy plugins can use 512 MB or more.

On a VPS with 4 GB of RAM, hosting several low-traffic sites is reasonable. Monitor the actual usage with free -h and top once in production, and adjust the pm.max_children values of the PHP-FPM pools accordingly.

Do not saturate the available RAM: leave headroom for the operating system, MariaDB and unexpected load spikes.

Multi-site backups

With several sites, automate the backups from the start. A loop over the directories and the databases covers everything in a single cron job.

Backup script

#!/bin/bash
BACKUP_DIR="/root/backups"
DATE=$(date +%F)
mkdir -p "$BACKUP_DIR"

# Back up the files of each site
for SITE in /var/www/*/; do
    SITE_NAME=$(basename "$SITE")
    tar -czf "$BACKUP_DIR/${SITE_NAME}-files-${DATE}.tar.gz" "$SITE"
done

# Back up the databases
for DB in site_a_db site_b_db site_c_db; do
    mysqldump -u root "$DB" > "$BACKUP_DIR/${DB}-${DATE}.sql"
done

# Delete backups older than 7 days
find "$BACKUP_DIR" -type f -mtime +7 -delete

Save this script as /usr/local/bin/backup-sites.sh, make it executable and add it to the crontab:

sudo chmod +x /usr/local/bin/backup-sites.sh
sudo crontab -e
# Daily backup at 3 a.m.
0 3 * * * /usr/local/bin/backup-sites.sh

Store the backups off the server

Backups kept on the same VPS do not protect against a hardware failure or a compromise of the server. Transfer the archives regularly to a remote location (object storage, third-party server, local machine) with rsync or scp.

Frequently asked questions

Can websites with different PHP versions run on the same VPS?

Yes. Install several PHP-FPM versions side by side (php8.1-fpm and php8.4-fpm, for example), then assign the matching version to each pool. Each pool socket points to the desired PHP version, and the Nginx vhost of each site references the right socket.

sudo apt install php8.1-fpm php8.4-fpm

Each version has its own pool directory: /etc/php/8.1/fpm/pool.d/ and /etc/php/8.4/fpm/pool.d/.

Can one crashing site affect the others?

With separate PHP-FPM pools and per-pool pm.max_children limits, a site hit by a traffic spike or generating PHP errors does not consume the workers of the other sites. However, if MariaDB is saturated (too many simultaneous connections), every site that depends on it can be affected. Monitor the MariaDB connections with SHOW PROCESSLIST;.

How do I temporarily disable a site without deleting it?

Remove the symbolic link in sites-enabled and reload Nginx:

sudo rm /etc/nginx/sites-enabled/site-a.fr
sudo nginx -t && sudo systemctl reload nginx

The configuration file in sites-available is kept. Re-enable the site by recreating the symbolic link.

How do I add a new site after the initial setup?

Create the directory, the system user, the PHP-FPM pool, the database, the Nginx vhost and the SSL certificate by following the sections of this guide in order. Each addition is independent and does not affect the existing sites.

Are separate PHP-FPM pools mandatory?

No, but they are strongly recommended as soon as several sites share the same VPS. Without separate pools, every site runs as www-data and can access the files of the others. For a personal VPS with trusted projects, a single pool may be enough. For client websites or third-party projects, separate pools are essential.