Host Multiple Websites on One VPS with Nginx Server Blocks

One VPS can serve many websites. Nginx reads the domain name each browser asks for and routes the request to the matching site. Getting the routing right takes ten minutes. Getting the isolation right takes a bit longer, and it is the part most guides skip: if every site runs as the same user, one hacked plugin on one site can read and change every other site on the server.
This guide sets up two sites on Ubuntu 24.04 with Nginx and PHP 8.3-FPM, each with its own Linux user, its own PHP-FPM pool and its own logs. It assumes Nginx and PHP-FPM are already installed. The examples use example.com and example.org; replace them with your domains.
How Nginx chooses a site
Every HTTP request carries a Host header, and every HTTPS connection sends the domain name during the TLS handshake (SNI). Nginx compares that name against the server_name of each server block listening on the port. If nothing matches, the request goes to the default server for that port. If you have not declared one, the default is simply the first server block Nginx loaded, which is how visitors typing your IP address end up on a random client's site.
So the plan is: one server block per site, plus an explicit catch-all that refuses anything else.
The layout
/var/www/
├── site1/ owner site1, mode 750
│ ├── public/ document root for example.com
│ ├── tmp/ PHP sessions and uploads
│ └── logs/ PHP error log
└── site2/ owner site2, mode 750
├── public/ document root for example.org
├── tmp/
└── logs/
Each site gets a Linux user that owns its files and runs its PHP. The directory mode 750 means the owner has full access, the group can read, and everyone else, including other site users, gets nothing.
Step 1: Create a user per site
sudo adduser --disabled-password --gecos "" site1
sudo mkdir -p /var/www/site1/public /var/www/site1/tmp /var/www/site1/logs
sudo chown -R site1:site1 /var/www/site1
sudo chmod 750 /var/www/site1
sudo usermod -aG site1 www-data
Repeat for site2. The last command adds the Nginx user, www-data, to the site's group, so Nginx can read static files such as images and CSS. Nginx never runs PHP code, so this read access is all it needs. Upload the site files as the site user (for example over SFTP with that user's key), not as root, so ownership stays correct.
Step 2: One PHP-FPM pool per site
This is the core of the isolation. A pool is a group of PHP worker processes with its own user, socket and limits. Create one file per site:
; /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 = 10
pm.process_idle_timeout = 30s
pm.max_requests = 500
php_admin_value[open_basedir] = /var/www/site1/
php_admin_value[upload_tmp_dir] = /var/www/site1/tmp
php_admin_value[session.save_path] = /var/www/site1/tmp
php_admin_value[error_log] = /var/www/site1/logs/php-error.log
php_admin_flag[log_errors] = on
What the settings do:
user and group: PHP for this site runs as
site1. It can write to its own files and cannot read/var/www/site2.listen: each pool has its own socket. Nginx connects to it, so the socket belongs to
www-datawith mode 0660.pm = ondemand: workers start when requests arrive and stop after 30 seconds idle. With many quiet sites this saves a lot of RAM. The cost is a few milliseconds on the first request after an idle period. For one busy site,
dynamickeeps warm workers ready.pm.max_children: a per-site cap. One site under attack can use at most 10 workers, and the others keep running.
open_basedir: PHP file functions cannot open paths outside the site directory. It is a second layer, not a replacement for file permissions.
session.save_path and upload_tmp_dir: by default all sites share
/var/lib/php/sessions. Separate directories stop one site from reading another site's session files.
Test the configuration and reload:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
ls -l /run/php/
You should see one socket per site. If no site uses the default www pool, rename www.conf to www.conf.disabled so it stops using memory.
Step 3: One server block per site
# /etc/nginx/sites-available/example.com
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?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-site1.sock;
}
location ~ /\.(?!well-known) {
deny all;
}
}
Create the same file for example.org with site2 paths and the php8.3-fpm-site2.sock socket. Separate log files per site make debugging much faster: when one site returns errors, you read one small log instead of searching a shared one.
Step 4: A catch-all default server
# /etc/nginx/sites-available/00-default
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
return 444 is an Nginx-specific code: it closes the connection without sending a response. Bots that scan IP addresses get nothing, and no client's site appears for an unknown name. ssl_reject_handshake (available since Nginx 1.19.4; Ubuntu 24.04 ships 1.24) does the same for HTTPS without needing a certificate for the catch-all.
Enable everything and restart. Use restart once here rather than reload, so the Nginx workers pick up the new group memberships from Step 1:
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/00-default /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/example.org /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
Step 5: Test before you change DNS
You do not have to wait for DNS. Send the Host header yourself:
curl -I -H "Host: example.com" http://127.0.0.1/
curl -I -H "Host: example.org" http://127.0.0.1/
curl -I -H "Host: unknown.test" http://127.0.0.1/ # connection closed, no response
Then confirm the isolation actually works:
sudo -u site1 cat /var/www/site2/public/index.php
# cat: /var/www/site2/public/index.php: Permission denied
If this command prints the file, the permissions are wrong. Check the mode of /var/www/site2 with ls -ld.
Step 6: HTTPS for each site
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot --nginx -d example.org -d www.example.org
Certbot adds the certificate lines and an HTTP to HTTPS redirect to each server block. Each site gets its own certificate, so renewing or removing one site does not touch the others.
Databases: one user per site too
Isolation breaks if every site connects to MariaDB with the same account. Create one database and one database user per site, with privileges only on that database (GRANT ALL PRIVILEGES ON site1db.* TO 'site1'@'localhost'). A SQL injection in one site then cannot read another site's tables.
Common problems
A long domain name breaks nginx -t
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
Add this line and test again:
# /etc/nginx/nginx.conf, inside http { }
server_names_hash_bucket_size 128;
502 Bad Gateway on one site only
That site's pool is not running, or the socket name in fastcgi_pass does not match. Check /var/log/nginx/site1.error.log and systemctl status php8.3-fpm. A syntax error in one pool file stops the whole FPM service, which is why php-fpm8.3 -t comes before every reload.
WordPress cannot write files or asks for FTP details
The files are owned by root or another user, not the site user. Fix ownership with sudo chown -R site1:site1 /var/www/site1.
How many sites per VPS?
There is no fixed number. RAM is usually the limit. As a rule of thumb, an active PHP worker for a typical CMS uses 40 to 80 MB, so add up the workers you expect to be busy at the same moment across all sites, add the database and the OS, and keep at least 20% free. Measure rather than guess: ps -o user,rss,cmd -C php-fpm8.3 shows memory per pool user.
Also remember that all sites share one point of failure. A kernel update reboot or a full disk takes them all down together. When one site becomes business-critical or much busier than the rest, give it its own server.
Run it on IM HOST
This setup needs full root access. IM HOST Linux VPS plans start from $5/month with full root on KVM, a dedicated IPv4 plus an IPv6 /64, and DDoS protection. For more sites and steadier load, Cloud VPS gives you dedicated vCPU and RAM and can be upgraded anytime. If you host sites for clients and prefer cPanel/WHM to manage accounts and isolation for you, look at Reseller Hosting with white-label branding. There are no setup fees, support engineers are available 24/7, and migration of existing sites is free.
More from our blog
Discover more practical guides and product insights from the IM Host team.
View all articles