How to Host Multiple Websites on One VPS With Nginx
In this article11 sections
To host multiple websites on one VPS with Nginx, give each site four things of its own: a folder, a Linux user, a PHP-FPM pool that runs as that user, and an Nginx server block that sends the site’s domain to that folder and pool. Add a separate database and a separate SSL certificate per site and you are done. The separate user and pool are the part most guides leave out, and they are what stops one hacked site from reading the files of every other site on the server.
This guide is for Ubuntu 22.04 or 24.04 with Nginx, PHP-FPM and MariaDB installed. The examples use PHP 8.3; replace 8.3 with your version. They add a site called site1 for the domain example.com, and you repeat the steps with a new name for each further site.
Why not put every site under www-data?
By default all PHP code on a server runs as one user, www-data. That means code on any site can read and change the files of every other site, including their database passwords. One outdated plugin on the smallest site is then enough to take over all of them. Running each site’s PHP as its own user closes that door: a compromised site can only reach its own files.
Step 1: Create a user and folder for the site
sudo adduser --disabled-password --gecos "" site1
sudo mkdir -p /var/www/site1/public /var/www/site1/logs
sudo chown -R site1:site1 /var/www/site1
sudo chmod 750 /var/www/site1
sudo usermod -aG site1 www-data
The site’s files belong to site1, and nobody outside its group can enter the folder. The last line adds Nginx’s user to that group, so Nginx can read images, CSS and JavaScript directly while other sites’ users cannot. Restart Nginx once so it picks up the new group:
sudo systemctl restart nginx
Step 2: Give the site its own PHP-FPM pool
Create /etc/php/8.3/fpm/pool.d/site1.conf:
[site1]
user = site1
group = site1
listen = /run/php/php8.3-fpm-site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 30s
pm.max_requests = 500
php_admin_value[open_basedir] = /var/www/site1/:/tmp/:/var/lib/php/sessions/
php_admin_value[upload_tmp_dir] = /tmp
php_admin_value[error_log] = /var/www/site1/logs/php-error.log
php_admin_flag[log_errors] = on
Test the configuration and reload:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
ls -l /run/php/php8.3-fpm-site1.sock
pm = ondemand starts workers only when a request arrives and stops them when the site is idle. On a server with many small sites this saves a great deal of memory, because quiet sites use none. open_basedir is a second fence: even inside its own user, this site’s PHP may open files only in its own folder, the temporary folder and PHP’s session folder. PHP errors go to a log inside the site’s own folder, outside public, so they are never served to visitors.
Step 3: Add the Nginx server block
Create /etc/nginx/sites-available/site1:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/site1/public;
index index.php index.html;
access_log /var/log/nginx/site1.access.log;
error_log /var/log/nginx/site1.error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-site1.sock;
}
location ~ /\.(?!well-known) {
deny all;
}
}
Enable it, test, and reload:
sudo ln -s /etc/nginx/sites-available/site1 /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
The try_files line is the one WordPress, Laravel and most PHP applications need. A separate log per site means you can see at a glance which site a problem or a traffic spike belongs to.
Step 4: Create a database and user for this site only
sudo mariadb -e "CREATE DATABASE site1 CHARACTER SET utf8mb4;"
sudo mariadb -e "CREATE USER 'site1'@'localhost' IDENTIFIED BY 'use-a-long-random-password';"
sudo mariadb -e "GRANT ALL PRIVILEGES ON site1.* TO 'site1'@'localhost';"
Never share one database user between sites, and never give a site the root database account. The grant above lets this site touch its own database and nothing else. Generate the password with openssl rand -base64 24.
Step 5: Test, then add SSL
Put a test file in place as the site’s user and request it:
echo '<?php echo "site1 runs as " . posix_getpwuid(posix_geteuid())["name"];' | sudo -u site1 tee /var/www/site1/public/index.php
curl -s -H 'Host: example.com' http://127.0.0.1/
The answer should be site1 runs as site1. If it says www-data, the server block is still pointing at the default pool. Once the domain’s DNS points at the server, add a certificate as described in how to set up free SSL on Nginx with Certbot:
sudo certbot --nginx -d example.com -d www.example.com
Delete the test file before you upload the real site.
Step 6: Catch requests for unknown domains
When a request arrives for a name that matches no server block, Nginx serves the first site it loaded. That is how one customer’s site ends up appearing on someone else’s domain. Add a catch-all that answers nothing, in /etc/nginx/sites-available/000-default:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
Enable it the same way as in step 3 and remove the stock default site from sites-enabled. On Ubuntu 24.04, whose Nginx is new enough, add a second block to the same file so unknown names are refused over HTTPS as well:
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
How many sites fit on one server?
Memory decides, not the number of sites. Each PHP worker for a typical WordPress site uses 40 to 80 MB while it is busy. Add up the worst case:
| Sites | pm.max_children each |
Worst-case PHP memory at 60 MB per worker |
|---|---|---|
| 5 | 5 | 1.5 GB |
| 10 | 5 | 3 GB |
| 20 | 4 | 4.8 GB |
All sites are rarely busy together, so real use is far lower, but the total must still leave room for MariaDB and the system. Measure your own worker size and set the limits with the method in how to optimize PHP-FPM workers for limited RAM. A busy site should get a higher pm.max_children than a quiet one: that is the point of separate pools.
Common mistakes to avoid
- Running every site as
www-data. One compromised site then means all of them. - Setting folders to 777 to “fix” a permission error. Make the files belong to the site’s user instead.
- Sharing a database user across sites. A leaked password in one site’s config file exposes every database.
- Forgetting the catch-all block. Unknown domains then show your first site.
- Copying a pool file and forgetting to change the socket name. PHP-FPM refuses to start, and every site on the server goes down. Always run
php-fpm8.3 -tbefore reloading. - Giving every pool a high
pm.max_children. The total can exceed your RAM many times over.
Would a control panel be easier?
If you add or remove sites often, or other people need to manage their own, a panel does these steps for you: one click creates the user, pool, server block, database and certificate. On a SiteHarbour VPS you can run as many sites and apps as the plan’s resources allow, and our engineers install a free panel such as CloudPanel or HestiaCP on request. No panel is installed by default, which keeps the server lean. For agencies hosting many client sites, we usually suggest starting with VPS-4.
Frequently asked questions
Do I need a separate IP address for each website?
No. Nginx tells sites apart by the domain name in each request, and SSL works the same way. One address serves any number of sites.
Can the sites use different PHP versions?
Yes. Install the second version, create that site’s pool file under its /etc/php/VERSION/fpm/pool.d/ folder, and point the server block at that version’s socket.
Will one busy site slow down the others?
It can use CPU the others wanted, but its own pm.max_children stops it taking every PHP worker. That limit is the main protection the others have.
How do I give someone SFTP access to only one site?
Give them an SSH key for that site’s user. Because the folder belongs to that user and is closed to others, they can reach nothing else.
How do I remove a site?
Delete its link in sites-enabled and its pool file, reload Nginx and PHP-FPM, then drop its database and remove its user and folder. Take a backup of the folder and database first.
The short version: one user, one folder, one PHP-FPM pool, one server block, one database and one certificate per site. It takes ten minutes longer than dropping everything under www-data, and it turns a break-in on one site into a problem for that site alone.