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.

What this chapter covers: Name-based vs IP-based virtual hosts. Apache's sites-available/sites-enabled architecture. Every directive in a vhost config block. Creating document root directories with correct permissions. Enabling and disabling sites with a2ensite/a2dissite. The default vhost catch-all. How Cloudflare Tunnel interacts with vhosts. Troubleshooting: 403 Forbidden, 404 Not Found, and wrong site served.

Name-Based vs IP-Based Virtual Hosts

Name-Based (what you'll use)
Apache reads the Host header in the HTTP request to decide which vhost to serve. Multiple domains share the same IP address.

  • One IP serves unlimited domains
  • Works on port 80 and 443
  • The standard for virtually all hosting
  • Config: VirtualHost *:80
Requirement: client must send a Host header — all modern browsers and HTTP/1.1+ clients do.
IP-Based (rare, legacy)
Each domain gets a dedicated IP address. Apache matches on the IP the request arrived on, not the hostname.

  • 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
Not covered here — name-based vhosts are the correct approach for your setup.

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.

HTTP request arrives at Apache (port 80): GET /index.html HTTP/1.1 Host: blog.osztromok.com ← Apache reads this header Apache checks enabled vhosts in order: ┌─────────────────────────────────────────────┐ │ VirtualHost 1: ServerName osztromok.com │ ✗ no match │ VirtualHost 2: ServerName blog.osztromok.com │ ✓ match → serve from /var/www/blog │ VirtualHost 3: ServerName shop.osztromok.com │ (not checked — match already found) └─────────────────────────────────────────────┘

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.

/etc/apache2/ ├── sites-available/ ← write your vhost configs here │ ├── 000-default.conf ← the catch-all default (ships with Apache) │ ├── osztromok.com.conf ← your main site │ └── blog.osztromok.com.conf ← second site │ ├── sites-enabled/ ← Apache reads ONLY these at startup (symlinks) │ ├── 000-default.conf → ../sites-available/000-default.conf │ ├── osztromok.com.conf → ../sites-available/osztromok.com.conf │ └── blog.osztromok.com.conf → ../sites-available/blog.osztromok.com.conf │ ├── mods-available/ mods-enabled/ ← same pattern for modules └── conf-available/ conf-enabled/ ← same pattern for global config snippets
Why this pattern? You can write a config and leave it inactive (in sites-available but not enabled). To disable a site without deleting its config, just run sudo a2dissite sitename.conf. The config stays in sites-available for later use.

Key commands

$ sudo a2ensite osztromok.com.conf # enable (creates symlink in sites-enabled) $ sudo a2dissite osztromok.com.conf # disable (removes symlink) $ sudo apache2ctl configtest # validate all config before reloading Syntax OK $ sudo systemctl reload apache2 # graceful reload — no dropped connections # Use reload (not restart) when only changing config. # restart kills and recreates the process; reload just re-reads config.

Anatomy of a Virtual Host Config

A complete, production-ready vhost config for osztromok.com:

# /etc/apache2/sites-available/osztromok.com.conf <VirtualHost *:80> # ── Identity ────────────────────────────────────────────────── ServerName osztromok.com ServerAlias www.osztromok.com ServerAdmin emubantam@gmail.com # ── Where your files live ───────────────────────────────────── DocumentRoot /var/www/osztromok.com/public_html # ── Permissions for the document root ──────────────────────── <Directory /var/www/osztromok.com/public_html> Options Indexes FollowSymLinks AllowOverride All Require all granted </Directory> # ── Per-site logs ───────────────────────────────────────────── ErrorLog ${APACHE_LOG_DIR}/osztromok_error.log CustomLog ${APACHE_LOG_DIR}/osztromok_access.log combined </VirtualHost>
VirtualHost *:80
Listen on all interfaces (*) on port 80. If you have multiple IPs and want to restrict to one, replace * with the IP address. Almost always use *.
ServerName
The primary hostname this vhost responds to. Apache matches this against the Host header in the request. Required — without it Apache guesses based on the server hostname.
ServerAlias
Additional hostnames that map to this same vhost. Space-separated. Use for www, other subdomains, or alternative domains. Wildcards allowed: *.osztromok.com.
ServerAdmin
Email address shown in Apache error pages. Optional but useful — helps identify which site an error belongs to.
DocumentRoot
The filesystem path where Apache looks for files to serve. A request for /index.html maps to DocumentRoot/index.html. The directory must exist and be readable by the Apache user (www-data).
Directory block
Sets permissions and behaviour for files in that path. Required — without it, Apache refuses to serve the files even if they exist.
Options Indexes FollowSymLinks
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.
AllowOverride All
Permits .htaccess files in this directory to override config (URL rewriting, auth, redirects). None ignores .htaccess entirely — faster but less flexible. Use All if the site uses WordPress, Laravel, or any framework with .htaccess rewrites.
Require all granted
Apache 2.4 access control — allow all requests to this directory. Without this, Apache returns 403 Forbidden regardless of file permissions.
ErrorLog / CustomLog
Per-site log files. ${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.

# Create the directory structure $ sudo mkdir -p /var/www/osztromok.com/public_html # Set ownership: your user owns the files, www-data is the group $ sudo chown -R philip:www-data /var/www/osztromok.com # Permissions: # 755 = directories: owner rwx, group+others r-x (readable, traversable) # 644 = files: owner rw-, group+others r-- (readable, not executable) $ sudo find /var/www/osztromok.com -type d -exec chmod 755 {} \; $ sudo find /var/www/osztromok.com -type f -exec chmod 644 {} \; # Create a test index page $ echo '<h1>osztromok.com — virtual host working</h1>' | sudo tee /var/www/osztromok.com/public_html/index.html
Avoid chmod 777. Making directories world-writable lets any process on the server write to your web files — a significant security risk. Use 755 for directories and 644 for files. Apache (running as www-data) can read 644 files because it has execute permission on 755 directories.

If you're uploading files via SSH (scp / rsync)

# After uploading as your user (philip), fix group ownership so Apache can read $ sudo chown -R philip:www-data /var/www/osztromok.com/public_html $ sudo find /var/www/osztromok.com/public_html -type f -exec chmod 644 {} \;

Scenario — Adding a Second Website to the Server

Setup Walkthrough · Complete
Add blog.osztromok.com as a separate website on the same server — different document root, different logs.
1
Create the document root.
$ sudo mkdir -p /var/www/blog.osztromok.com/public_html $ sudo chown -R philip:www-data /var/www/blog.osztromok.com $ sudo find /var/www/blog.osztromok.com -type d -exec chmod 755 {} \; $ echo '<h1>blog.osztromok.com</h1>' | sudo tee /var/www/blog.osztromok.com/public_html/index.html
2
Write the vhost config file.
$ sudo nano /etc/apache2/sites-available/blog.osztromok.com.conf
Enter this content:
<VirtualHost *:80> ServerName blog.osztromok.com DocumentRoot /var/www/blog.osztromok.com/public_html <Directory /var/www/blog.osztromok.com/public_html> Options Indexes FollowSymLinks AllowOverride All Require all granted </Directory> ErrorLog ${APACHE_LOG_DIR}/blog_error.log CustomLog ${APACHE_LOG_DIR}/blog_access.log combined </VirtualHost>
3
Enable the site, test config, reload.
$ sudo a2ensite blog.osztromok.com.conf Enabling site blog.osztromok.com. To activate the new configuration, you need to run: service apache2 reload $ sudo apache2ctl configtest Syntax OK $ sudo systemctl reload apache2
4
Create the DNS record for the subdomain. In Cloudflare's DNS dashboard (since DNS moved to Cloudflare in Chapter 3), add:

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.
5
Test from outside.
$ curl -H "Host: blog.osztromok.com" http://localhost <h1>blog.osztromok.com</h1> ← correct vhost served $ curl -H "Host: osztromok.com" http://localhost <h1>osztromok.com — virtual host working</h1> ← different vhost
Passing the -H "Host:" header lets you test vhost routing locally without needing public DNS.
Each site has its own document root (/var/www/osztromok.com and /var/www/blog.osztromok.com), its own config file, and its own log files. They're completely independent — changing one doesn't affect the other.

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.

# /etc/apache2/sites-available/000-default.conf (shipped with Apache) <VirtualHost *:80> ServerAdmin webmaster@localhost DocumentRoot /var/www/html # serves the Apache "It works!" page ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost>

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.
The "ServerName" warning on Apache startup — if you see 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.
# Fix the AH00558 ServerName warning $ echo "ServerName localhost" | sudo tee -a /etc/apache2/apache2.conf $ sudo apache2ctl configtest && sudo systemctl reload apache2

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.

Request flow with Cloudflare Tunnel + Apache vhosts: Visitor requests blog.osztromok.com │ ▼ Cloudflare edge receives it (Host: blog.osztromok.com) │ ▼ forwards through tunnel, preserving Host header cloudflared daemon on your server │ ▼ HTTP to localhost:80 with Host: blog.osztromok.com Apache │ ▼ checks ServerName of each enabled vhost blog.osztromok.com.conf → DocumentRoot /var/www/blog/public_html │ ▼ Response flows back through the same path to the visitor

The key point: when you add a new vhost for newsite.osztromok.com, you need to:

  1. Create the vhost config in Apache (this chapter)
  2. 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.

Testing vhosts locally (bypassing the tunnel): Use 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

# Print all enabled vhosts and which config file each came from $ sudo apache2ctl -S VirtualHost configuration: *:80 osztromok.com (/etc/apache2/sites-enabled/osztromok.com.conf:1) *:80 blog.osztromok.com (/etc/apache2/sites-enabled/blog.osztromok.com.conf:1) # If a vhost isn't listed, it's not enabled — check a2ensite or symlink # Watch live error log for the affected site $ sudo tail -f /var/log/apache2/blog_error.log # Watch access log to see what Apache is actually receiving $ sudo tail -f /var/log/apache2/blog_access.log
403 Forbidden — Apache returns 403 when trying to load the site
Usually a permissions problem. Apache (running as www-data) can't read the files or traverse the directory.
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
404 Not Found — site loads but pages return 404
File not found in the DocumentRoot — either the path in DocumentRoot is wrong or the file doesn't exist there. Check: 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).
Wrong site is served — Apache serves the wrong vhost's content
Run 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.
Config change has no effect after reload
Always run 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.
New vhost works locally (curl localhost) but not from outside
Apache is configured correctly but the DNS record or tunnel routing isn't set up. Check: (1) DNS — 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

CommandPurpose
sudo a2ensite site.confEnable a vhost — creates symlink from sites-available to sites-enabled
sudo a2dissite site.confDisable a vhost — removes the symlink (config file stays in sites-available)
sudo apache2ctl configtestValidate all Apache config files — always run before reloading
sudo apache2ctl -SList all enabled vhosts with their config file paths and port/hostname
sudo systemctl reload apache2Gracefully reload config — no dropped connections; use this over restart
sudo a2enmod rewriteEnable mod_rewrite — required for .htaccess URL rewriting (WordPress etc.)
curl -H "Host: x.com" http://localhostTest a specific vhost locally by spoofing the Host header
sudo tail -f /var/log/apache2/NAME_error.logWatch live errors for a specific vhost
DirectiveRequired?Purpose
ServerNameYesPrimary hostname for this vhost — must match Host header
ServerAliasOptionalAdditional hostnames (www, subdomains) — space-separated
DocumentRootYesFilesystem path to serve files from
Directory blockYesPermissions for the document root — without it you get 403
Require all grantedYesApache 2.4 access control — allows public access to the directory
AllowOverride AllFor .htaccessEnables .htaccess overrides — needed for WordPress, Laravel etc.
ErrorLog / CustomLogOptionalPer-site logging — highly recommended for any multi-vhost setup
PathPurpose
/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