How to host multiple websites on one VPS (and keep them separate)

How to host multiple websites on one VPS with nginx: one server block, Linux user, PHP-FPM pool, database and certificate per site, plus resource limits.

Diagram: one VPS split into separate boxes, each website with its own user, PHP pool and database

Key takeaways

  • nginx picks the website by the Host header, so many domains can share one IP address and one VPS.
  • Give every site its own Linux user, PHP-FPM pool and database user so a hacked site cannot read or change its neighbours.
  • Add a default_server block that returns 444 so unknown hostnames never fall through to a real site.
  • By default all PHP-FPM pools share one systemd service, so per-site CPU and RAM limits need a separate PHP-FPM master per site.

To host multiple websites on one VPS, point each domain's A record at the server's IP, then give each site its own nginx server block; nginx chooses the site from the Host header of each request. To keep the sites separate, also give each one its own Linux user, its own PHP-FPM pool running as that user, its own database and database user, and its own SSL certificate. Then a hacked or overloaded site stays a problem for that site only.

This guide does it by hand on Ubuntu 24.04 with nginx, PHP 8.3 and MariaDB. It assumes the stack is installed; if not, start with how to install a LEMP stack on Ubuntu 24.04. Here we focus on what changes when the second and the twentieth site arrive.

Why separation matters

The quick way to add a second site is to drop it in /var/www/site2 and let it run on the default www PHP pool. It works, but every site then runs PHP as the same user (www-data), so code in one site can read and write every other site's files, including the wp-config.php that holds database passwords.

This is not theory. Security firm Sucuri calls it cross-site contamination: one poorly secured site becomes the way into every other site on the same account or server. During the 2014 MailPoet campaign, researcher Daniel Cid found about 50,000 backdoored WordPress sites, and some of them never ran the vulnerable plugin: another site in the same shared account did.

Separation has a second purpose: resources. Without limits, one site hit by a bot flood or a runaway plugin can use all the CPU and RAM and slow every other site down.

The plan: what each site gets

Layer Shared by all sites One per site
IP address, nginx, MariaDB server Yes
DNS A record example.com, www.example.com
nginx server block and logs /etc/nginx/sites-available/example.com
Linux user and home folder example, /home/example
PHP-FPM pool and socket /run/php/php8.3-fpm-example.sock
Database and database user example_db, example_db@localhost
SSL certificate Let's Encrypt via Certbot
CPU, RAM and process limits (optional) systemd slice

We use example.com with the user example. Repeat every step with a new name for each site.

Step 1: Point DNS at the VPS

At your DNS provider, create an A record for example.com and for www with the VPS's IPv4 address (and AAAA records if the server has IPv6). Check before going further:

dig +short example.com A
dig +short www.example.com A

Both must print your server's IP, or Certbot will fail in step 7. More detail in how to point a domain to a VPS.

Step 2: Create a Linux user per site

sudo useradd --create-home --home-dir /home/example --shell /usr/sbin/nologin example
sudo mkdir -p /home/example/{public_html,tmp,logs}
sudo chown -R example:example /home/example
sudo chown example:www-data /home/example
sudo chmod 750 /home/example
sudo chmod 700 /home/example/tmp /home/example/logs

What the permissions do:

  • /home/example is 750, owned by example with group www-data. The site's user has full access, nginx (running as www-data) can enter to serve static files, and every other user is locked out, including the PHP users of other sites.
  • tmp and logs are 700: only the site's own PHP reads them.
  • The shell is nologin because the site user exists to own files and run PHP, not to log in.

Step 3: A PHP-FPM pool per site

Create /etc/php/8.3/fpm/pool.d/example.conf:

[example]
user = example
group = example

listen = /run/php/php8.3-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 5
pm.process_idle_timeout = 10s
pm.max_requests = 500

php_admin_value[open_basedir] = /home/example/public_html:/home/example/tmp
php_admin_value[upload_tmp_dir] = /home/example/tmp
php_admin_value[session.save_path] = /home/example/tmp
php_admin_value[sys_temp_dir] = /home/example/tmp
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /home/example/logs/php-error.log

The important lines:

  • user and group make this site's PHP run as example, so it can only touch files example may touch.
  • The socket belongs to www-data with mode 0660, so nginx can connect and other site users cannot.
  • pm = ondemand keeps no workers alive when the site is idle, which saves RAM when you host many small sites. pm.max_children caps how many requests this site can run in PHP at once.
  • php_admin_value settings cannot be overridden by the site's own code with ini_set(). Sessions and uploads go to the site's private tmp instead of the shared /tmp.

About open_basedir: the PHP manual calls it "just an extra safety net" that "can not be relied upon when security is needed", and notes that it disables the realpath cache. The real wall is the separate Linux user and the 750 home folder. Treat open_basedir as a second layer.

Test and reload:

sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
ls -l /run/php/

Once every site has its own pool, you can remove or empty the default www.conf pool if nothing uses it. Need different PHP versions per site? The same pool pattern works with each version's FPM service; see running multiple PHP versions on one server.

Step 4: An nginx server block per site

Create /etc/nginx/sites-available/example.com:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /home/example/public_html;
    index index.php index.html;

    access_log /var/log/nginx/example.com.access.log;
    error_log  /var/log/nginx/example.com.error.log;

    disable_symlinks if_not_owner;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm-example.sock;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}

Each site gets its own access and error log, so you can see which site is being hammered. Files in /var/log/nginx/ are rotated by the nginx package's logrotate rule. disable_symlinks if_not_owner stops nginx from following a symlink that points to a file owned by someone else, a classic trick for reading another site's config.

Enable it:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Step 5: Add a catch-all default server

When a request arrives with a hostname nginx does not know, or just the bare IP, nginx sends it to the default server for that port. If you have not chosen one, that is the first server block it loaded, which means a random real site answers for any domain someone points at your IP. Make the default explicit and empty. Create /etc/nginx/sites-available/000-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;
    server_name _;
    ssl_reject_handshake on;
}

444 is nginx's non-standard code that closes the connection without sending a response. ssl_reject_handshake (nginx 1.19.4 and later; Ubuntu 24.04 ships 1.24) refuses HTTPS handshakes for unknown names, so you need no dummy certificate. Remove Ubuntu's own default site, which also claims default_server, then enable this one:

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

Step 6: A database and database user per site

sudo mariadb -e "CREATE DATABASE example_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'example_db'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON example_db.* TO 'example_db'@'localhost';
ALTER USER 'example_db'@'localhost' WITH MAX_USER_CONNECTIONS 20;"

The grant covers only example_db.*, so stolen credentials from one site open only that site's data. MAX_USER_CONNECTIONS keeps one busy site from using all of MariaDB's connections. Never reuse one database user for several sites.

Step 7: An SSL certificate per site

sudo certbot --nginx -d example.com -d www.example.com

Certbot adds the HTTPS settings to that site's server block and the Ubuntu package installs a timer that renews certificates automatically. Run sudo certbot renew --dry-run once to check. Background on how it works: free SSL with Let's Encrypt.

Step 8 (optional): CPU and RAM limits per site

The steps above isolate files. They do not stop one site from eating the whole CPU. The catch: every pool in pool.d runs under one master process in php8.3-fpm.service, so all sites' PHP lives in the same cgroup and you cannot limit one site there. We explain this in detail in hosting account resource limits.

The fix is to give a site its own PHP-FPM master inside its own systemd slice. Move the pool out of pool.d into /etc/php/8.3/fpm/example-fpm.conf, with a [global] section on top:

[global]
pid = /run/php-fpm-example/php-fpm.pid
error_log = /home/example/logs/php-fpm.log

[example]
; the same pool settings as step 3, but with:
listen = /run/php-fpm-example/php.sock

Then create the slice /etc/systemd/system/site-example.slice:

[Slice]
CPUQuota=100%
MemoryMax=1G
TasksMax=100

and the service /etc/systemd/system/php-fpm-example.service:

[Unit]
Description=PHP-FPM for example.com
After=network.target

[Service]
Type=notify
Slice=site-example.slice
RuntimeDirectory=php-fpm-example
ExecStart=/usr/sbin/php-fpm8.3 --nodaemonize --fpm-config /etc/php/8.3/fpm/example-fpm.conf
ExecReload=/bin/kill -USR2 $MAINPID

[Install]
WantedBy=multi-user.target
sudo php-fpm8.3 -t -y /etc/php/8.3/fpm/example-fpm.conf
sudo systemctl daemon-reload
sudo systemctl enable --now php-fpm-example

Point the site's fastcgi_pass at unix:/run/php-fpm-example/php.sock and reload nginx. CPUQuota=100% allows one full core, MemoryMax= is a hard ceiling and TasksMax= caps processes, as described in systemd.resource-control. Watch the result with systemd-cgtop. Cron jobs for the site should run in the same slice too (systemd-run --slice=site-example.slice --uid=example ...), or they escape the limit.

Step 9: Back up each site on its own

Per-site separation makes backups simple: one folder and one database per site.

sudo tar -czf /root/backups/example-$(date +%F).tar.gz -C /home example
sudo mariadb-dump --single-transaction example_db | gzip > /root/backups/example_db-$(date +%F).sql.gz

Run it from cron and copy the archives off the server; a backup on the same VPS dies with the VPS. See website backup strategy and backing up a VPS to S3.

The panel way: the same separation in ZoPanel

ZoPanel builds this structure for you. Each hosting account is its own Linux user; its websites live in /home/<account>/domains/<domain>/, and PHP runs in the account's own PHP-FPM pool, one per PHP version. Each account runs in its own systemd slice with the CPU, RAM, process and disk-speed limits of its package, and its PHP pools, apps, cron jobs and SSH sessions run inside that slice. Disk space and inodes are limited with filesystem quotas, and database connections per database user. nginx refuses symlinks to files owned by someone else, and SFTP is chrooted to the home folder (security, packages and limits).

Adding a site is a form: domain, website type (PHP, WordPress, Laravel, HTML, Node/proxy), PHP version 7.4 to 8.4, and Free SSL issued once DNS points at the server (websites and PHP). A domain can be used only once on the server, and a subdomain of a domain owned by another account is refused.

One honest difference: in ZoPanel, isolation is per account, not per website. Websites inside the same account share that account's user and pool, like addon domains in cPanel. If two sites belong to different people, or one runs risky plugins, put them in separate accounts. ZoPanel also needs a fresh Ubuntu 22.04/24.04/26.04 or Debian 12/13 server, so it does not install on top of a server you built by hand.

Manual vs control panel

By hand (this guide) ZoPanel
Cost Free Free plan (3 accounts, 10 sites); Pro $49/year per server
Time per new site 15–30 minutes of careful editing About a minute
User, pool, database per site Yes, if you never skip a step Automatic per account
CPU/RAM limits Extra FPM master + slice per site From the package
Disk quotas Set up quotas yourself From the package
SSL renewal Certbot timer Automatic
Backups Your own scripts Built in, with S3 copies
Control and learning Full; you know every file Less; the panel owns the config
Best for A few sites you know well Many sites, clients, agencies

For three or four sites of your own, doing it by hand is reasonable. The risk grows with the count: every new site is another chance to forget a chmod or reuse a pool.

Conclusion

Hosting many websites on one VPS is easy; keeping them separate is the real work. The recipe is the same at any scale: one server block, one Linux user with a 750 home, one PHP-FPM pool, one database user and one certificate per site, a catch-all default server, and, if you need it, a slice with CPU and RAM limits. If you would rather not repeat that for every site, ZoPanel's Free plan gives you 3 isolated hosting accounts with 10 websites and 10 databases, with no time limit. Install it on a fresh VPS with curl -fsSL https://get.zopanel.net | sudo bash and see pricing when you need more.

Frequently asked questions

Can I host multiple websites on one VPS with one IP address?

Yes. nginx reads the Host header and, for HTTPS, the SNI name in the handshake, then picks the matching server block. Thousands of domains can share one IPv4 address, each with its own Let's Encrypt certificate.

Is it safe to host several websites on one VPS?

It is safe when the sites are isolated: separate Linux users, separate PHP-FPM pools, private home folders and separate database users. If all sites run as www-data, one hacked site can read and change all the others, which is how cross-site contamination spreads.

Do I need a separate PHP-FPM pool for each website?

For sites you care about, yes. A pool is what decides which Linux user runs the code, so one pool per site is what keeps files apart. With pm = ondemand, idle pools use almost no memory, so many pools cost little.

Why does a wrong website show up for my domain?

nginx has no server block matching that hostname, so it falls back to the default server, which is the first one loaded if you never set default_server. Add a catch-all that returns 444 and check that the domain is listed in server_name of the right block.

How many websites can I host on a 4 GB VPS?

It depends on traffic, plugins and caching more than on the domain count. In our tests a 4 GB Ubuntu 24.04 VPS handled 100 WordPress sites without page cache and 250 with it, while also running other applications; see how many WordPress sites a 4 GB VPS can host.