Skip to content

Virtual Hosts

A single server can host many websites at once using virtual hosting. The web server inspects the Host: header of each incoming request and routes it to the matching site. This is called name-based virtual hosting, and it is how one IP address serves example.com, shop.example.com, and blog.example.org simultaneously. This page shows the nginx and Apache approaches and a sane directory layout.

Tested on

AlmaLinux 9 / RHEL 9. Debian/Ubuntu differences are noted inline.

Give each site its own document root and its own log files. A common convention:

/var/www/
├── example.com/
│   ├── public/          # document root (only this is web-served)
│   └── logs/            # optional per-site app logs
├── shop.example.com/
│   └── public/
└── blog.example.org/
    └── public/

Serving from a public/ (or htdocs/) subdirectory keeps application code, configs, and .env files out of the web root.

sudo mkdir -p /var/www/example.com/public /var/www/shop.example.com/public
echo '<h1>example.com</h1>' | sudo tee /var/www/example.com/public/index.html
echo '<h1>shop.example.com</h1>' | sudo tee /var/www/shop.example.com/public/index.html

SELinux on RHEL/AlmaLinux

Every custom document root needs the httpd_sys_content_t label, or you get 403s:

sudo semanage fcontext -a -t httpd_sys_content_t "/var/www(/.*)?/public(/.*)?"
sudo restorecon -Rv /var/www

More detail in SELinux.

nginx approach: one server {} per domain

Put one file per site under /etc/nginx/conf.d/ (Debian: under sites-available, then symlink into sites-enabled). Each gets a distinct server_name, root, and its own log files. See Nginx for the full server {} anatomy.

/etc/nginx/conf.d/example.com.conf:

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

    root   /var/www/example.com/public;
    index  index.html index.php;

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

    location / {
        try_files $uri $uri/ =404;
    }
}

/etc/nginx/conf.d/shop.example.com.conf:

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

    root   /var/www/shop.example.com/public;
    index  index.html index.php;

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

    location / {
        try_files $uri $uri/ =404;
    }
}

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

nginx default / catch-all server

When a request's Host matches no server_name, nginx uses the default server for that port — by default, the first matching server block. To control what unknown hosts get (and to reject scrapers and bare-IP scans), define an explicit default:

# /etc/nginx/conf.d/00-default.conf
server {
    listen       80 default_server;
    listen       [::]:80 default_server;
    server_name  _;            # matches nothing real; only reached as the default
    return       444;          # nginx-specific: close the connection with no response
}

The default_server flag forces this block to handle unmatched requests. Using 444 quietly drops them; you could instead return 404; or serve a generic page.

Apache approach: multiple <VirtualHost> blocks

Apache does name-based virtual hosting with several <VirtualHost *:80> blocks, each with its own ServerName and DocumentRoot. See Apache for module and .htaccess details.

On RHEL/AlmaLinux, create /etc/httpd/conf.d/vhosts.conf (or one file per site):

<VirtualHost *:80>
    ServerName   example.com
    ServerAlias  www.example.com
    DocumentRoot /var/www/example.com/public

    <Directory /var/www/example.com/public>
        Require all granted
        AllowOverride All
    </Directory>

    ErrorLog  /var/log/httpd/example.com-error.log
    CustomLog /var/log/httpd/example.com-access.log combined
</VirtualHost>

<VirtualHost *:80>
    ServerName   shop.example.com
    DocumentRoot /var/www/shop.example.com/public

    <Directory /var/www/shop.example.com/public>
        Require all granted
        AllowOverride All
    </Directory>

    ErrorLog  /var/log/httpd/shop.example.com-error.log
    CustomLog /var/log/httpd/shop.example.com-access.log combined
</VirtualHost>

Test and reload:

sudo apachectl configtest && sudo systemctl reload httpd

Apache on Debian: a2ensite

On Debian, put each vhost in its own file under /etc/apache2/sites-available/ (e.g. example.com.conf, logs in /var/log/apache2/) and enable it:

sudo a2ensite example.com
sudo a2ensite shop.example.com
sudo systemctl reload apache2

Apache default / catch-all virtual host

Apache uses the first <VirtualHost> for the matching port as the default for unmatched hostnames. To make this deliberate, define a catch-all as the first block:

# Place this BEFORE your real vhosts so it is matched first for unknown hosts
<VirtualHost *:80>
    ServerName  catchall.invalid
    DocumentRoot /var/www/default
    <Directory /var/www/default>
        Require all granted
    </Directory>
</VirtualHost>

You can verify which vhost answers what with httpd -S (RHEL) or apache2ctl -S (Debian), which prints the virtual-host mapping.

Testing locally before DNS is ready

Before you point real DNS records at the server, you can test name-based vhosts by overriding name resolution on your client machine via /etc/hosts:

# On your laptop/workstation (Linux/macOS), as root:
# Map the hostnames to the server's IP
echo '203.0.113.10  example.com www.example.com shop.example.com' | sudo tee -a /etc/hosts

(On Windows the file is C:\Windows\System32\drivers\etc\hosts.) Now your browser and curl will send the right Host: header to the server's IP. Remove these lines once real DNS is live.

You can also test without touching /etc/hosts by setting the Host header explicitly:

# Force the Host header so the right server block answers
curl -H 'Host: example.com'      http://203.0.113.10/
curl -H 'Host: shop.example.com' http://203.0.113.10/

Verify your work

# 1. Config is valid (use the right one for your server)
sudo nginx -t                 # nginx
sudo apachectl configtest     # Apache

# 2. Each vhost returns its own content (Host header decides routing)
curl -s -H 'Host: example.com'      http://localhost/   # -> <h1>example.com</h1>
curl -s -H 'Host: shop.example.com' http://localhost/   # -> <h1>shop.example.com</h1>

# 3. Inspect the vhost map (Apache)
sudo httpd -S

# 4. An unknown host hits the catch-all, not a real site
curl -s -H 'Host: random.invalid' http://localhost/ -o /dev/null -w '%{http_code}\n'

Summary

  • Name-based virtual hosting routes requests to sites by the Host: header — many sites, one IP.
  • nginx: one server {} block per domain (one file per site under conf.d/ or sites-available), each with its own server_name, root, and logs. Set an explicit default_server.
  • Apache: multiple <VirtualHost *:80> blocks with ServerName + DocumentRoot; use a2ensite on Debian and define a first/catch-all vhost. Inspect with httpd -S.
  • Use the convention /var/www/<site>/public/ and label custom roots with httpd_sys_content_t on RHEL/AlmaLinux.
  • Test before DNS by editing the client /etc/hosts or sending an explicit Host header with curl.

Next: secure every vhost with TLS in HTTPS with Let's Encrypt.

Test yourself