Apache Virtual Hosts
Chapter 5 — Apache Virtual Hosts
A single Apache server can host dozens of websites simultaneously. Virtual hosts let Apache serve different content depending on which domain name the visitor requested — all on the same IP address, the same port 80, the same machine. This chapter covers everything from the configuration file anatomy to enabling/disabling sites, setting up directory permissions, and understanding how virtual hosts interact with a Cloudflare Tunnel.
Name-Based vs IP-Based Virtual Hosts
- One IP serves unlimited domains
- Works on port 80 and 443
- The standard for virtually all hosting
- Config: VirtualHost *:80
- Requires multiple IPs or network interfaces
- Needed for TLS before SNI existed (pre-2012)
- Almost never used today
- Config: VirtualHost 192.168.1.10:80
When a request arrives, Apache goes through the enabled vhosts in order and picks the first one whose ServerName or ServerAlias matches the Host header. If no vhost matches, Apache serves the first enabled vhost alphabetically — this is the "default" behaviour covered later.
Apache's Site Configuration Structure
Apache on Debian/Ubuntu uses a clean two-directory pattern for managing vhosts. You write configs in sites-available and use a2ensite to activate them — it simply creates a symlink into sites-enabled. Apache only reads sites-enabled at startup.
sudo a2dissite sitename.conf. The config stays in sites-available for later use.
Key commands
Anatomy of a Virtual Host Config
A complete, production-ready vhost config for osztromok.com:
*) on port 80. If you have multiple IPs and want to restrict to one, replace * with the IP address. Almost always use *.*.osztromok.com./index.html maps to DocumentRoot/index.html. The directory must exist and be readable by the Apache user (www-data).Indexes: show a file listing if no index.html exists (useful during dev, disable in prod). FollowSymLinks: required for mod_rewrite to work. None disables all options.None ignores .htaccess entirely — faster but less flexible. Use All if the site uses WordPress, Laravel, or any framework with .htaccess rewrites.${APACHE_LOG_DIR} expands to /var/log/apache2/. Separate logs per vhost make debugging vastly easier — you can tail just the log for the broken site.Creating the Document Root and Permissions
The DocumentRoot directory must exist and be readable by the Apache process user (www-data). The simplest approach: put your files under /var/www/ and set the owner to your own user with www-data as the group.
www-data) can read 644 files because it has execute permission on 755 directories.
If you're uploading files via SSH (scp / rsync)
Scenario — Adding a Second Website to the Server
— Type: CNAME | Name: blog | Target:
osztromok.com | Proxy: orange cloud (proxied)Or if you're using Cloudflare Tunnel: add
blog.osztromok.com as an additional public hostname in the tunnel config (Chapter 4). The tunnel will forward requests to localhost:80 with the original Host header intact, and Apache's ServerName matching will route them to the blog vhost.
-H "Host:" header lets you test vhost routing locally without needing public DNS.
The Default Virtual Host
When no enabled vhost matches the Host header, Apache falls back to the first vhost alphabetically in sites-enabled. On a fresh Debian/Ubuntu install, that's 000-default.conf — the 000 prefix puts it first.
This vhost has no ServerName — it never matches any specific request. Its role is purely to be the catch-all fallback. Common approaches:
- Leave it enabled — requests with unknown hostnames (e.g. someone hitting your IP directly) see the Apache default page. Fine for most cases.
- Return 444 / 403 for unmatched requests — add a catch-all vhost that returns an error instead of leaking your default site.
- Disable it — run
sudo a2dissite 000-default.conf. The first enabled named vhost becomes the fallback. Acceptable if you're fine with any unmatched request seeing your main site.
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, add ServerName localhost to /etc/apache2/apache2.conf. This is a cosmetic warning, not an error — your vhosts work fine without it.
Cloudflare Tunnel + Virtual Hosts
When traffic arrives at your server via Cloudflare Tunnel, Apache's vhost routing works exactly as if the client connected directly — because Cloudflare preserves the original Host header when forwarding the request through the tunnel to localhost:80.
The key point: when you add a new vhost for newsite.osztromok.com, you need to:
- Create the vhost config in Apache (this chapter)
- Create the DNS record in Cloudflare or add a public hostname to the tunnel config (Chapter 4)
Apache handles which content to serve. The tunnel handles how traffic gets to the server. They're independent layers.
curl -H "Host: blog.osztromok.com" http://localhost on the server itself. This hits Apache directly without going through the tunnel and is the fastest way to verify a new vhost is working before touching DNS.
Troubleshooting
Check 1 — directory permissions:
ls -la /var/www/yourdomain.com/ — parent directories need execute (x) bit for www-data.
Check 2 —
Require all granted missing from the Directory block in the vhost config. Without it, Apache denies all access regardless of file permissions.
Check 3 —
Options Indexes is off and there's no index.html. Apache refuses to list the directory. Add an index.html or add Options Indexes.
Fix:
sudo chown -R philip:www-data /var/www/yourdomain.com && sudo chmod -R 755 /var/www/yourdomain.com
ls /var/www/yourdomain.com/public_html/. Also check the error log: it shows the exact path Apache was looking for. If using URL rewriting (.htaccess), ensure AllowOverride All is set and mod_rewrite is enabled (sudo a2enmod rewrite).
sudo apache2ctl -S to see the vhost list order. The wrong site being served usually means: (1) the correct vhost's ServerName doesn't match the incoming Host header exactly, (2) the correct vhost isn't enabled, or (3) there's a typo in ServerName/ServerAlias. Test with curl -H "Host: exact.domain.name" http://localhost to confirm what Apache receives.
sudo apache2ctl configtest before reload — if the config has a syntax error, systemctl reload apache2 fails silently and the old config stays active. The configtest output will show the exact line and error. Fix the syntax, re-run configtest, then reload.
dig blog.osztromok.com should resolve to a Cloudflare IP. (2) If using Cloudflare Tunnel, confirm the new hostname is in the tunnel's ingress rules (config.yml or dashboard public hostname tab). (3) Run sudo systemctl status cloudflared to check the tunnel is running.
Quick Reference — Chapter 5
| Command | Purpose |
|---|---|
| sudo a2ensite site.conf | Enable a vhost — creates symlink from sites-available to sites-enabled |
| sudo a2dissite site.conf | Disable a vhost — removes the symlink (config file stays in sites-available) |
| sudo apache2ctl configtest | Validate all Apache config files — always run before reloading |
| sudo apache2ctl -S | List all enabled vhosts with their config file paths and port/hostname |
| sudo systemctl reload apache2 | Gracefully reload config — no dropped connections; use this over restart |
| sudo a2enmod rewrite | Enable mod_rewrite — required for .htaccess URL rewriting (WordPress etc.) |
| curl -H "Host: x.com" http://localhost | Test a specific vhost locally by spoofing the Host header |
| sudo tail -f /var/log/apache2/NAME_error.log | Watch live errors for a specific vhost |
| Directive | Required? | Purpose |
|---|---|---|
| ServerName | Yes | Primary hostname for this vhost — must match Host header |
| ServerAlias | Optional | Additional hostnames (www, subdomains) — space-separated |
| DocumentRoot | Yes | Filesystem path to serve files from |
| Directory block | Yes | Permissions for the document root — without it you get 403 |
| Require all granted | Yes | Apache 2.4 access control — allows public access to the directory |
| AllowOverride All | For .htaccess | Enables .htaccess overrides — needed for WordPress, Laravel etc. |
| ErrorLog / CustomLog | Optional | Per-site logging — highly recommended for any multi-vhost setup |
| Path | Purpose |
|---|---|
| /etc/apache2/sites-available/ | Write vhost configs here — Apache doesn't read this directly |
| /etc/apache2/sites-enabled/ | Symlinks to active vhosts — Apache reads only these at startup |
| /var/www/yourdomain.com/public_html/ | Conventional document root location for each site |
| /var/log/apache2/ | Apache log directory — ${APACHE_LOG_DIR} in config expands to this |