How to host several websites on one server with Nginx
One server can run many sites. The trick is to keep them apart: each site gets its own folder, user, PHP workers, database, logs and certificate. Then one hacked or broken site stays one problem, and removing a site is a clean job.
The plan
For every site on the server, you'll have:
- A folder:
/var/www/shop.example, with the public files inpublic. - A Linux user that owns the folder:
shop. - A PHP-FPM pool (a group of PHP workers) that runs as that user, with its own socket:
/run/php/shop.sock. - An Nginx file:
/etc/nginx/sites-available/shop.example. - A certificate for
shop.exampleandwww.shop.example. - A database and a database user, both called
shop. - Its own log files:
/var/log/nginx/shop.example.access.logand.error.log.
Plus one thing for the whole server: a catch-all that refuses requests for names you don't host.
The examples use three sites: shop.example (WordPress, user shop), app.example (Laravel or plain PHP, user app) and blog.example (plain HTML files, user blog). Use your own names.
Before you start
- You log in with a user that has sudo. See use a sudo user instead of root. If the server is new, work through the security checklist for a new PHP server too.
- Nginx, PHP-FPM and MariaDB (or MySQL) are installed:
sudo apt install nginx php-fpm php-mysql mariadb-server, plus the PHP extensions your apps need. - We tested every step on Ubuntu 24.04 (Nginx 1.24, PHP 8.3, MariaDB 10.11, certbot 2.9) and Debian 13 (Nginx 1.26, PHP 8.4, MariaDB 11.8, certbot 4.0). The PHP version is in some paths, so check yours with
php -v.
1. Refuse names you don't host
When a request comes in, Nginx looks at the name the visitor asked for and picks the site with that server_name. If no site matches, it uses the default server. Out of the box, that's the first site in the list. So a visit by IP address, or a stray domain someone points at your server, shows one of your real sites. With HTTPS, it also shows that site's certificate, which tells scanners which domain lives there.
Make a default server that answers nothing instead. First remove the "Welcome to nginx" site, which is the default now:
sudo rm /etc/nginx/sites-enabled/default
Then create the catch-all:
sudo nano /etc/nginx/sites-available/catch-all
# Answers every request whose name doesn't match a site
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
return 444;
}
return 444is special to Nginx: it closes the connection without an answer.ssl_reject_handshake onstops HTTPS before it starts, so no certificate is shown. That's also why this block needs no certificate of its own.
Turn it on, check and reload:
sudo ln -s /etc/nginx/sites-available/catch-all /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
This is the same block as in our guide to locking your server to Cloudflare. You need it with or without Cloudflare. Only one block may say default_server for each port, so leave it out of your site files.
2. Give each site a user and a PHP pool
Follow our guide to one user and PHP-FPM pool per site, steps 1 to 3, once for each site. Use the site's own name everywhere: the user app gets the folder /var/www/app.example, the pool file /etc/php/8.3/fpm/pool.d/app.conf and the socket /run/php/app.sock. Never put two sites in one pool.
A site with only HTML files (like blog.example) needs the user and the folder (steps 1 and 2 there), but no pool. Nginx sends the files itself.
When every PHP site has its own pool, turn off the shared www pool (step 6 of that guide). Then check that every pool is running:
sudo php-fpm8.3 -t && sudo systemctl restart php8.3-fpm
ls /run/php/
You should see one .sock file per PHP site. On Debian 13, use 8.4 in these commands and paths.
3. Make one Nginx file per site
Each site gets its own file in /etc/nginx/sites-available/, named after the domain. This one is for the WordPress site:
sudo nano /etc/nginx/sites-available/shop.example
# www.shop.example -> shop.example
server {
listen 80;
listen [::]:80;
server_name www.shop.example;
return 301 $scheme://shop.example$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name shop.example;
root /var/www/shop.example/public;
index index.php index.html;
access_log /var/log/nginx/shop.example.access.log;
error_log /var/log/nginx/shop.example.error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/shop.sock;
}
}
- The first block catches
www.shop.exampleand sends visitors toshop.example, keeping the rest of the address.$schemekeepshttporhttps, so the same line still works after step 5. Prefer the www name? Swap the two names. fastcgi_passpoints at this site's own socket. That's what makes the site run as its own user.- The log lines give the site its own log files (step 7).
Laravel: the same file works. The root must end in public, as in our Laravel deploy guide. WordPress: add the rules from our WordPress hardening guide, such as no PHP in uploads, inside the second block.
A site with only HTML files: leave out the location ~ \.php$ block, set index index.html; and use try_files $uri $uri/ =404;. Without a PHP block, a .php file is never run.
Turn the site on with a link in sites-enabled, then check and reload:
sudo ln -s /etc/nginx/sites-available/shop.example /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
To switch a site off later, remove only the link. The file in sites-available stays, so you can turn it back on.
4. Test a site before you change DNS
You can visit a site on the new server while the domain still points somewhere else. curl --resolve tells curl which address to use for a name, just for this one command. Run it from your own computer, with your server's address instead of 203.0.113.5:
curl -I --resolve shop.example:80:203.0.113.5 http://shop.example/
curl -I --resolve www.shop.example:80:203.0.113.5 http://www.shop.example/some/page
The first should show 200 OK (WordPress may answer with a redirect to its own address, which is fine too). The second should show 301 Moved Permanently and Location: http://shop.example/some/page.
Now check the catch-all, with a name you don't host and with the bare address:
curl -I -H 'Host: nothing.example' http://203.0.113.5/
curl -I http://203.0.113.5/
Both should say Empty reply from server. That's the 444 doing its job.
To click through a site in your browser, add a line such as 203.0.113.5 shop.example www.shop.example to the hosts file on your own computer: /etc/hosts on Linux and macOS, C:\Windows\System32\drivers\etc\hosts on Windows. Remove it when you're done, or you'll keep seeing this server after the switch.
Moving a live site from another server? Our guide to moving a PHP site to a new server covers the whole move.
5. Turn on HTTPS for each site
Once the domain points at the server, get one certificate per site, with and without www:
sudo certbot --nginx -d shop.example -d www.shop.example
sudo certbot --nginx -d app.example -d www.app.example
sudo certbot --nginx -d blog.example -d www.blog.example
Certbot finds each site by its server_name lines and adds HTTPS to both blocks. It also adds the redirect from HTTP to HTTPS. Our HTTPS guide has the details, how to check renewal and what to do when it fails.
In our test, every site got its own certificate, and the catch-all still showed no certificate at all for unknown names. http://www.shop.example takes two short hops to https://shop.example: first to HTTPS, then to the name without www. That's normal.
One certificate per site keeps things tidy: when you remove a site, you delete its certificate and nothing else changes.
6. Give each site its own database and user
Create one database and one database user per site, with a different random password each time:
CREATE DATABASE shop CHARACTER SET utf8mb4;
CREATE USER 'shop'@'localhost' IDENTIFIED BY 'PASTE-PASSWORD';
GRANT ALL PRIVILEGES ON shop.* TO 'shop'@'localhost';
Step 5 of our MySQL and MariaDB security guide shows how to make the password and check the user. Never let two sites share a database user, and never give a site ON *.*.
Put the password in the site's own settings file (wp-config.php or .env), owned by the site user and not readable by everyone: chmod 640.
7. Keep each site's logs apart
The access_log and error_log lines from step 3 give each site its own files:
ls -l /var/log/nginx/
You'll see shop.example.access.log, shop.example.error.log and the same for each site. Requests that hit the catch-all go to the shared access.log, so your site logs only show real visitors of that site.
- PHP errors land in the site's own error log too. PHP-FPM passes them to Nginx, which writes them as lines starting with
FastCGI sent in stderr: "PHP message: .... To watch one site:sudo tail -f /var/log/nginx/shop.example.error.log. - Old logs are cleaned up for you. The
nginxpackage rotates every/var/log/nginx/*.logfile, so your new files are included. Laravel's own log is different: see our guide to stop logs from filling the disk.
8. Check that the sites are kept apart
This is the point of all the steps above, so test it once. Put a small file in one site (here app.example) that tries to read another site's files:
echo '<?php var_dump(@file_get_contents("/var/www/shop.example/public/wp-config.php"));' | sudo tee /var/www/app.example/public/peek-7rq2.php
curl https://app.example/peek-7rq2.php
sudo rm /var/www/app.example/public/peek-7rq2.php
It should print bool(false): the app site's PHP isn't allowed in the shop site's folder. If you see the contents of wp-config.php, both sites run as the same user. Check the fastcgi_pass line and the pool's user line.
In our test, the app site's PHP couldn't read the shop site's files, couldn't list its folder or its sessions, and its database user got Access denied on the shop database.
How many sites fit on one server?
Memory runs out first, long before the disk or the processor on most small servers. Here's how to think about it:
- HTML-only sites cost almost nothing. Nginx sends the files, and one more site adds next to nothing.
- Quiet PHP sites cost almost nothing while idle. With
pm = ondemand(as in the pool guide), workers stop after 30 seconds without visitors. In our test, the pools of idle sites had no workers running at all. - Busy PHP sites cost memory per worker. In our test, a WordPress worker with no plugins used about 45 MB, and a tiny plain PHP app about 15 MB. Real WordPress sites with plugins often use 60 to 100 MB.
- The database takes a fixed share. MariaDB with its default settings used about 150 MB in our test.
The rule: add up pm.max_children of all pools and multiply by the size of a worker. That's how much PHP can use when every site is busy at once. It must fit in what's left after the system and the database. On a 1 GB server, that's room for about 8 PHP workers in total, for example 3 for the busy shop and 1 or 2 for each quiet site.
Our guide to stopping a small VPS from running out of memory shows how to measure your own numbers and gives a memory plan for 1 and 2 GB servers. Our OPcache and PHP-FPM speed guide shows how to measure one worker.
Memory isn't the only limit. Every site on the server shares its one address, its disk and its backups. A very important site, or one with lots of traffic, is often happier on its own server.
Remove a site cleanly
Do it in this order, so nothing is left half-running. The example removes app.example.
1. Take a backup of the files and the database first, and keep it somewhere else. See our backup guide.
2. Take it offline:
sudo rm /etc/nginx/sites-enabled/app.example
sudo nginx -t && sudo systemctl reload nginx
Visitors to app.example now get the catch-all: no answer.
3. Stop its PHP:
sudo rm /etc/php/8.3/fpm/pool.d/app.conf
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
4. Delete its certificate, so certbot stops trying to renew it:
sudo certbot delete --cert-name app.example
5. Delete its database and database user, in sudo mariadb:
DROP DATABASE app;
DROP USER 'app'@'localhost';
6. Remove its jobs and logins:
- Cron jobs:
sudo crontab -r -u app("no crontab for app" just means it had none). - Laravel queue workers or a scheduler timer: stop and remove them, as set up in our queue workers guide.
- SFTP logins for this site: follow "Remove access later" in our SFTP guide, including its line in
/etc/fstab.
7. Delete the user, its group and its files:
sudo userdel app
sudo groupdel app
sudo rm -rf /var/www/app.example
sudo rm /etc/nginx/sites-available/app.example
sudo rm /var/log/nginx/app.example.*
userdel says group app not removed because it has other members. That's expected: the web server user is in the group (from the pool guide). groupdel removes it.
8. Check that nothing is left:
ls /etc/nginx/sites-enabled/ /run/php/
sudo certbot certificates
The site, its socket and its certificate should be gone, and the other sites should still work. Finally, remove the domain's DNS records, or point them somewhere else.
If something goes wrong
nginx -tsays "a duplicate default server": another file still saysdefault_server. Usually it's/etc/nginx/sites-enabled/default. Remove it, or remove the word from that file.- A site shows "Empty reply from server", or the browser says the connection was closed: the request reached the catch-all. The name isn't in any
server_nameline, or the site's link insites-enabledis missing. Check withsudo nginx -T | grep server_name. - One site shows another site: two files share a
server_name, or arootline points at the wrong folder.nginx -twarns about "conflicting server name". - "502 Bad Gateway": the site's pool isn't running, or
fastcgi_passnames the wrong socket. Comparels /run/php/with the Nginx file. - "403 Forbidden" on every page: Nginx can't open the site folder. Check that
www-datais in the site's group withid www-data, and that the folder has the permissions from step 2 of the pool guide. userdelsays the user "is currently used by process": a PHP worker, cron job or queue worker still runs as that user. Go back to steps 3 and 6.
Official sources
- How nginx processes a request and Server names (nginx.org)
- listen and default_server, ssl_reject_handshake and return (nginx.org)
- PHP-FPM configuration (php.net)
- User guide, including certbot delete (certbot docs)
- --resolve (curl manual)
Next steps: if one of the sites is WordPress, read how to run WordPress on your own server. And make sure your backups cover every site.
ServerNest is in development. Running several apps on one server, each kept apart from the others, is what it's being built for. Join early access to try it first.