Configure Apache name-based virtual hosts
Serve multiple domains from isolated document roots, select an intentional default host, validate host matching, and keep separate access and error logs.
Host multiple sites on one Apache listener without cross-serving content when a Host header is missing or incorrect.
- Ubuntu Server 24.04 LTS
- Apache HTTP Server 2.4.x
- Console and recovery access Keep an independent console session and a copy of the current Apache configuration before changing listeners, modules, or access rules.
- DNS and network plan Resolve every production hostname to the intended public address and list the exact management and client networks that require access.
getent ahosts {{domain}} && ip route show - Port ownership Confirm that TCP port 80 is free or already belongs to the intended Apache instance.
sudo ss -lntp 'sport = :80' - Rollback artifact Snapshot the host or create an archive of the active configuration and web roots before the first material change.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- Two DNS names served from separate deployment-owned document roots with explicit Apache Directory policy and independent access/error logs.
- A root-owned rejecting default virtual host that returns 404 for missing, unknown, direct-IP, or malicious Host values instead of leaking either tenant.
- A deterministic vhost map and positive/negative routing test set that proves aliases, fallback behavior, content isolation, log separation, and rollback.
- Requests for domain A and B reach only their own markers and logs; an unknown Host receives the rejecting response and never another site's content.
- Apache's `-S` map identifies exactly one intentional default and one definition for each name/alias, with no duplicate enabled symlink.
- Document roots are not shared or writable by the Apache runtime, DNS/certificate prerequisites are explicit, and the previous site layout can be restored.
Architecture
How the parts fit together
One Apache TCP/80 listener selects a virtual host from the HTTP Host header. The lexically first enabled definition is the fallback, so a dedicated `000-reject.conf` owns that position and returns 404. Each real site declares canonical ServerName, optional www alias, isolated `/srv/www/<domain>/public`, explicit Directory authorization, disabled indexing/overrides, and separate logs. DNS only routes clients to the server; Apache still performs Host selection.
- Verify every A/AAAA record and intended server address, then archive available and enabled site state including symlink selection.
- Create separate deployment-owned roots and deterministic markers without granting www-data write access.
- Define each site with explicit name, alias, root, Directory access, indexing policy, overrides policy, and log files.
- Create and enable a rejecting default, disable the Ubuntu sample, and enable each real site exactly once.
- Review configtest and the complete vhost map before reload.
- Test A, B, aliases, an unknown Host, and direct-address behavior; inspect per-site logs and ensure the reject path contains no tenant marker.
Assumptions
- The Apache baseline is healthy, TCP/80 exposure is intentional, and no reverse proxy or CDN rewrites Host without a documented trust model.
- Both apex and optional www names resolve to the intended address for public use; loopback `--resolve` tests can precede DNS cutover.
- The two sites are separate security and deployment boundaries even if one team currently owns both.
- TLS is configured in the next stage with certificates covering every enabled name; this guide proves HTTP routing first.
- Application writable data and uploads are outside public executable roots and have separate ownership and serving rules.
Key concepts
- Name-based virtual host
- A site selected from local address/port plus the HTTP Host field rather than a dedicated server address.
- Default virtual host
- The first matching address/port definition used when no ServerName or ServerAlias matches.
- ServerAlias
- An additional hostname accepted by one virtual host; every alias should have deliberate DNS and certificate coverage.
- DocumentRoot
- The filesystem directory mapped to URL paths for a virtual host; Directory authorization is configured separately.
- AllowOverride None
- Prevents per-directory `.htaccess` files from changing policy, keeping configuration centralized and reviewable.
Before you copy
Values used in this guide
{{domain}}Representative name used by the shared prerequisite DNS check.
Example: example-a.test{{domainA}}Canonical first site name, without scheme or path.
Example: example-a.test{{domainB}}Canonical second site name, without scheme or path.
Example: example-b.test{{deployUser}}Non-runtime deployment account that owns published content.
Example: webdeploySecurity and production boundaries
- The Host header is client-controlled. A rejecting default prevents accidental content disclosure but is not an application authorization boundary.
- Do not make www-data or unrelated tenant deployment accounts owners of another site's files.
- AllowOverride None prevents hidden `.htaccess` policy, while Options -Indexes avoids directory listing; neither blocks vulnerable application files.
- Per-site logs can contain credentials in URLs, personal data, and internal paths. Restrict, rotate, redact, and retain them deliberately.
- Add TLS for all canonical names and aliases before serving authentication or sensitive content.
Stop before continuing if
- Stop if DNS points to another system, an enabled vhost is undocumented, roots overlap, or the deployment owner is unclear.
- Do not reload when configtest or `apache2ctl -S` shows duplicate names, duplicate enabled files, or the wrong default.
- Do not accept routing until both positive hosts, aliases, unknown Host, and direct-address requests have expected distinct results.
- Do not delete old roots or site definitions until content is backed up and rollback no longer needs them.
verification
Verify DNS for every virtual host
Resolve the apex and www names before changing Apache; mismatched DNS cannot be repaired by a virtual-host declaration.
Why this step matters
DNS evidence prevents an Apache routing change from being blamed for names that actually resolve to another server or address family.
What to understand
Resolve apex and aliases through the production resolver, compare every A/AAAA result with host addresses, TTL, proxy/CDN, and cutover plan.
An IPv6 AAAA record is independently reachable even when IPv4 is correct; test and firewall both.
Use local `--resolve` fixtures for pre-cutover configuration, but do not confuse them with public DNS proof.
System changes
- No persistent change; reads DNS and local interface state.
Syntax explained
getent ahosts- Resolves through host NSS configuration and displays address families.
ip -brief address- Shows addresses actually assigned to the server.
Values stay on this page and are never sent or saved.
getent ahosts {{domainA}} && getent ahosts {{domainB}} && ip -brief address203.0.113.10 STREAM example-a.test 203.0.113.10 STREAM example-b.test
Checkpoint: Checkpoint: dns
Continue whenEvery intended name/address is documented and either points here or has a scheduled cutover; no stray AAAA remains.
Stop whenA name resolves elsewhere, ownership is unclear, address family is unfiltered, or certificate coverage is unplanned.
If this step fails
Apex resolves correctly but www points elsewhere.
Likely causeStale or independently managed DNS record.
getent ahosts www.example-a.test
ResolutionCorrect or deliberately retire the alias before enabling it in Apache.
Security notes
- DNS ownership is part of site control; protect registrar and DNS credentials with MFA and audit.
Alternatives
- Test only with `--resolve` until the approved DNS cutover window.
Stop conditions
- A name resolves elsewhere, ownership is unclear, address family is unfiltered, or certificate coverage is unplanned.
command
Archive active site definitions
Preserve both available and enabled site state so the exact symlink selection can be reconstructed.
Why this step matters
Archiving both available files and enabled symlinks preserves not only content but the exact active selection and default order.
What to understand
Store the archive outside web roots, record checksum/listing, and confirm enough storage.
Enabled symlinks determine lexical default and duplicate activation; they are essential rollback state.
Document any configuration included outside the two standard directories separately.
System changes
- Creates a compressed root-owned archive of sites-available and sites-enabled plus checksum evidence.
Syntax explained
tar -C /etc/apache2- Archives paths relative to the Apache configuration root.
-czf- Creates a gzip-compressed archive file.
sha256sum- Records integrity evidence.
sudo tar -C /etc/apache2 -czf /root/apache-sites-before-vhosts.tgz sites-available sites-enabled && sudo sha256sum /root/apache-sites-before-vhosts.tgz4c1f... /root/apache-sites-before-vhosts.tgz
Checkpoint: Checkpoint: backup
Continue whenArchive is non-empty, checksum verifies, and listing includes every current file and enabled symlink.
Stop whenArchive misses external includes, checksum fails, storage is insufficient, or current selection is undocumented.
If this step fails
Restored files exist but the active sites differ.
Likely causeSymlink selection/ordering was not preserved or restored.
tar -tzf /root/apache-sites-before-vhosts.tgzfind /etc/apache2/sites-enabled -type l -ls
ResolutionRestore both directories as one reviewed unit and validate -S before reload.
Security notes
- Archives expose domain topology and possibly credentials; keep them root-only.
Alternatives
- Use a version-controlled configuration plus filesystem snapshot when both are tested for symlink restoration.
Stop conditions
- Archive misses external includes, checksum fails, storage is insufficient, or current selection is undocumented.
command
Create isolated document roots
Use one root per site, owned by its deployment account rather than the Apache runtime user; publish deterministic health pages for routing tests.
Why this step matters
Separate roots and a non-runtime deployment owner prevent one site's release process or compromised Apache worker from modifying another tenant's content.
What to understand
Directories use deployment-user ownership and www-data group read/traversal; Apache should not own writable source trees.
Deterministic markers make routing assertions unambiguous before applications introduce caches or redirects.
Keep uploads and mutable state outside public roots with explicit handlers and backup policy.
System changes
- Creates two `/srv/www/<domain>/public` trees and deterministic index markers with deployment ownership.
Syntax explained
install -d -m 0755- Creates traversable/readable directories with explicit modes.
-o deployUser -g www-data- Separates deployment ownership from runtime group access.
tee index.html- Writes an exact hostname marker for routing tests.
Values stay on this page and are never sent or saved.
sudo install -d -m 0755 -o {{deployUser}} -g www-data /srv/www/{{domainA}}/public /srv/www/{{domainB}}/public && printf '<h1>{{domainA}}</h1>\n' | sudo tee /srv/www/{{domainA}}/public/index.html >/dev/null && printf '<h1>{{domainB}}</h1>\n' | sudo tee /srv/www/{{domainB}}/public/index.html >/dev/null/srv/www/example-a.test/public/index.html /srv/www/example-b.test/public/index.html
Checkpoint: Checkpoint: roots
Continue whenRoots resolve to distinct paths, markers differ, deployment user owns them, and www-data cannot write.
Stop whenPaths overlap/symlink unexpectedly, content would be overwritten, or runtime/unrelated tenant can write.
If this step fails
Apache cannot read a root despite readable index.
Likely causeA parent directory lacks traversal, Directory policy differs, or mandatory access control denies it.
namei -l /srv/www/example-a.test/public/index.html
ResolutionGrant the minimum parent traversal/read path and inspect the site error log.
Security notes
- Never solve access by recursively setting 777 or making www-data the deployment owner.
Alternatives
- Deploy immutable version directories and atomically switch a root-owned `current` symlink.
Stop conditions
- Paths overlap/symlink unexpectedly, content would be overwritten, or runtime/unrelated tenant can write.
config
Define the first named host
Declare ServerName, optional alias, document root, explicit directory access, and per-site logs in one file.
Why this step matters
A complete site definition keeps name, alias, root, authorization, override/index policy, and logs together for review and rollback.
What to understand
ServerName is canonical; enable the www alias only when DNS/TLS and redirect/content behavior are deliberate.
Require all granted is scoped to the exact public root, while AllowOverride None centralizes policy and Options -Indexes prevents listing.
Separate logs preserve tenant routing evidence and simplify incident isolation.
System changes
- Creates the root-owned site A virtual-host configuration under sites-available.
Syntax explained
<VirtualHost *:80>- Attaches this named host to all configured port-80 listener addresses.
ServerName / ServerAlias- Declares canonical and accepted HTTP Host names.
DocumentRoot / Directory- Maps content and authorizes access to the exact path.
Options -Indexes +FollowSymLinks- Disables directory listing while permitting reviewed symlink traversal.
CustomLog combined- Writes site-specific standard access-log fields.
/etc/apache2/sites-available/{{domainA}}.confValues stay on this page and are never sent or saved.
printf '<VirtualHost *:80>\n ServerName {{domainA}}\n ServerAlias www.{{domainA}}\n DocumentRoot /srv/www/{{domainA}}/public\n <Directory /srv/www/{{domainA}}/public>\n Require all granted\n AllowOverride None\n Options -Indexes +FollowSymLinks\n </Directory>\n ErrorLog ${APACHE_LOG_DIR}/{{domainA}}-error.log\n CustomLog ${APACHE_LOG_DIR}/{{domainA}}-access.log combined\n</VirtualHost>\n' | sudo tee /etc/apache2/sites-available/{{domainA}}.conf<VirtualHost *:80> ServerName example-a.test DocumentRoot /srv/www/example-a.test/public
Checkpoint: Checkpoint: site a
Continue whenThe file contains only domain A names/paths/logs, is root-owned, and has no wildcard aliases or writable policy.
Stop whenAlias, root, Directory path, log path, override, option, or ownership is wrong.
If this step fails
Apache warns that DocumentRoot does not exist.
Likely causeDomain placeholder differs from the created directory or a typo is present.
sudo apache2ctl configtestnamei -l /srv/www/example-a.test/public
ResolutionCorrect the exact path and keep the site disabled until parser and access checks pass.
Security notes
- A wildcard ServerAlias can capture unrelated domains; avoid it unless a validated application requires it.
Alternatives
- Redirect www to the canonical apex in a separate explicit vhost instead of serving both.
Stop conditions
- Alias, root, Directory path, log path, override, option, or ownership is wrong.
config
Define the second named host
Mirror the isolation pattern with the second domain and its own logs; never share a writable document root between unrelated sites.
Why this step matters
Mirroring the reviewed isolation pattern with entirely separate names, root, and logs prevents accidental coupling between the two sites.
What to understand
Do not copy domain A without replacing every name/path/log occurrence; review a unified diff between definitions.
Document root sharing undermines deployment and incident isolation even when pages initially match.
Keep per-site authorization explicit because global Directory policy may change later.
System changes
- Creates the root-owned site B virtual-host configuration under sites-available.
Syntax explained
ServerName domainB- Selects the second site for its canonical Host.
ServerAlias www.domainB- Optionally selects it for the reviewed alias.
ErrorLog / CustomLog- Separates diagnostic and request evidence from site A.
AllowOverride None- Prevents hidden per-directory policy changes.
/etc/apache2/sites-available/{{domainB}}.confValues stay on this page and are never sent or saved.
printf '<VirtualHost *:80>\n ServerName {{domainB}}\n ServerAlias www.{{domainB}}\n DocumentRoot /srv/www/{{domainB}}/public\n <Directory /srv/www/{{domainB}}/public>\n Require all granted\n AllowOverride None\n Options -Indexes +FollowSymLinks\n </Directory>\n ErrorLog ${APACHE_LOG_DIR}/{{domainB}}-error.log\n CustomLog ${APACHE_LOG_DIR}/{{domainB}}-access.log combined\n</VirtualHost>\n' | sudo tee /etc/apache2/sites-available/{{domainB}}.conf<VirtualHost *:80> ServerName example-b.test DocumentRoot /srv/www/example-b.test/public
Checkpoint: Checkpoint: site b
Continue whenSite B names, root, and logs are unique and no reference to domain A remains.
Stop whenAny name/path/log is shared unintentionally or the runtime can write site B content.
If this step fails
Site B requests appear in site A logs.
Likely causeThe request matched site A/default because site B name or enablement is wrong.
sudo apache2ctl -Ssudo tail -n 20 /var/log/apache2/example-a.test-access.log
ResolutionCorrect site B ServerName/enablement and repeat a named loopback request.
Security notes
- Separate logs and roots reduce accidental disclosure but do not isolate processes for dynamic applications.
Alternatives
- Use separate hosts/containers when tenant process isolation is required.
Stop conditions
- Any name/path/log is shared unintentionally or the runtime can write site B content.
command
Enable sites and choose the default deliberately
Create a rejecting default so unknown Host values cannot reveal a tenant, disable the sample host, and enable both named definitions exactly once.
Why this step matters
A dedicated lexically first rejecting vhost prevents unknown Host values from falling into tenant A, while each real site is enabled exactly once.
What to understand
The command writes `000-reject.conf`, disables Ubuntu's sample, and enables reject/A/B through normal a2ensite symlinks.
The rejecting host uses no tenant DocumentRoot and returns 404 for every path. It owns only the fallback position.
Avoid manually adding a second 000-domain symlink beside an a2ensite link; duplicate enablement produces misleading maps and behavior.
System changes
- Creates/enables a rejecting default, disables 000-default, and enables domain A and B exactly once.
Syntax explained
000-reject.conf- Sorts before ordinary domain files and becomes the `*:80` fallback.
Redirect 404 /- Returns 404 for all paths without serving tenant content.
a2dissite 000-default- Removes Ubuntu's sample host selection.
a2ensite- Creates one managed enabled symlink per reviewed site.
Values stay on this page and are never sent or saved.
printf '<VirtualHost *:80>\n ServerName _default.invalid\n Redirect 404 /\n</VirtualHost>\n' | sudo tee /etc/apache2/sites-available/000-reject.conf >/dev/null && sudo a2dissite 000-default && sudo a2ensite 000-reject {{domainA}} {{domainB}}Site 000-default disabled. Enabling site 000-reject. Enabling site example-a.test. Enabling site example-b.test.
Checkpoint: Checkpoint: enable
Continue whenOne reject, one domain A, and one domain B symlink exist; reject is the default and no duplicate real site is enabled.
Stop whenSample remains enabled, reject is not first/default, a real site is duplicated, or unknown Host can reach tenant content.
If this step fails
a2ensite says a site is already enabled but duplicates appear.
Likely causeA manually named symlink also targets the same available file.
find /etc/apache2/sites-enabled -maxdepth 1 -type l -printf '%f -> %l\n'
ResolutionRemove only the confirmed duplicate symlink, retain the managed site link, and validate -S.
Security notes
- The fallback must not proxy, redirect to a tenant, or expose filesystem information.
Alternatives
- Return 421 Misdirected Request through mod_rewrite when operational policy prefers that status and compatibility is tested.
Stop conditions
- Sample remains enabled, reject is not first/default, a real site is duplicated, or unknown Host can reach tenant content.
verification
Inspect Apache's host matching table
Use configtest and -S to confirm both names, aliases, file origins, and the intended default before reloading.
Why this step matters
The parser and vhost map are authoritative pre-reload evidence for default order, aliases, duplicates, source files, and listener coverage.
What to understand
Review every `*:80` entry and the words `default server`; the rejecting file must own the default.
Each canonical and alias name should map once to the expected source file.
Configtest cannot prove response content, so route fixtures follow after reload.
System changes
- No persistent change; parses configuration and displays the effective vhost selection table.
Syntax explained
configtest- Parses all enabled Apache configuration.
-S- Dumps address/port defaults, names, aliases, and source lines.
sudo apache2ctl configtest && sudo apache2ctl -SSyntax OK *:80 example-a.test (/etc/apache2/sites-enabled/000-example-a.test.conf:1) port 80 namevhost example-b.test
Checkpoint: Checkpoint: validate
Continue whenSyntax OK, reject is default, each site appears once, aliases map correctly, and no sample/duplicate remains.
Stop whenAny warning/error, wrong default, duplicate name, missing alias, or unexpected source file appears.
If this step fails
The intended reject host is not default.
Likely causeAnother enabled filename sorts earlier or binds more specifically.
sudo apache2ctl -Sls -la /etc/apache2/sites-enabled
ResolutionCorrect enable order/address overlap and repeat the map; never rely on assumed lexical order.
Security notes
- Unknown enabled vhosts can expose old applications or admin tools; treat them as blockers.
Alternatives
- Use DUMP_VHOSTS alongside automated assertions in deployment CI.
Stop conditions
- Any warning/error, wrong default, duplicate name, missing alias, or unexpected source file appears.
verification
Reload and prove content isolation
Resolve both names to localhost and assert that each returns only its own marker; then inspect the two access logs.
Why this step matters
Positive and negative HTTP fixtures prove actual post-reload Host selection, content isolation, fallback behavior, and per-site logging independently of DNS.
What to understand
Use `--resolve` for each canonical and alias so destination is loopback but Host remains realistic.
Send an explicit unknown Host and a direct-address request; both must receive reject status without either marker.
Inspect both access logs to ensure only the matching site's request was recorded, then review journal for reload errors.
System changes
- Reloads Apache and creates bounded request plus access/error-log evidence; site content/config remains unchanged.
Syntax explained
systemctl reload- Gracefully applies the validated enabled-site layout.
curl --resolve- Overrides DNS while preserving the requested Host.
--fail- Treats HTTP error responses as command failures for positive fixtures.
-H 'Host: unknown.invalid'- Exercises the rejecting default explicitly.
Values stay on this page and are never sent or saved.
sudo systemctl reload apache2 && curl --fail --resolve '{{domainA}}:80:127.0.0.1' http://{{domainA}}/ && curl --fail --resolve '{{domainB}}:80:127.0.0.1' http://{{domainB}}/ && sudo tail -n 2 /var/log/apache2/{{domainA}}-access.log /var/log/apache2/{{domainB}}-access.log<h1>example-a.test</h1> <h1>example-b.test</h1> GET / HTTP/1.1" 200
Checkpoint: Checkpoint: route tests
Continue whenA and B return their own markers/logs, aliases behave deliberately, and unknown/direct-IP requests return only reject status.
Stop whenCross-content appears, unknown Host returns a tenant, logs mix, aliases surprise, reload/journal errors, or either positive fixture fails.
If this step fails
The correct marker appears but in both log files.
Likely causeA global CustomLog duplicates requests or both vhosts share a log path.
grep -R --line-number 'CustomLog' /etc/apache2
ResolutionRemove unintended global/duplicate logging or correct per-site paths, then repeat one request at a time.
Security notes
- Use harmless test paths and avoid placing secrets in URLs that logs will retain.
Alternatives
- Run the same matrix from an external probe after DNS cutover in addition to local fixtures.
Stop conditions
- Cross-content appears, unknown Host returns a tenant, logs mix, aliases surprise, reload/journal errors, or either positive fixture fails.
Finish line
Verification checklist
sudo apache2ctl -SLists every ServerName once and identifies the intended default.for d in {{domainA}} {{domainB}}; do curl --fail --silent --resolve "$d:80:127.0.0.1" "http://$d/"; doneEach request returns the marker belonging to that hostname.Recovery guidance
Common problems and safe checks
Both names return site A.
Likely causeSite B is not enabled, DNS/request Host is wrong, ServerName is misspelled, or the default catches the request.
sudo apache2ctl -Scurl -v --resolve 'example-b.test:80:127.0.0.1' http://example-b.test/
ResolutionCorrect the exact enabled file and name mapping, then repeat the local Host fixture before DNS changes.
apache2ctl -S lists the same domain twice.
Likely causea2ensite and a manual 000-prefixed symlink both enable the same file, or duplicate configs declare it.
find /etc/apache2/sites-enabled -maxdepth 1 -type l -printf '%f -> %l\n'grep -R --line-number 'ServerName example-a.test' /etc/apache2
ResolutionKeep one real-site symlink and the separate rejecting default; remove only the confirmed duplicate after backup.
Unknown Host returns tenant content.
Likely causeThe rejecting file is disabled, sorted after another definition, or does not share the same address/port.
sudo apache2ctl -Scurl -i -H 'Host: unknown.invalid' http://127.0.0.1/
ResolutionMake the root-owned reject vhost the first enabled definition for `*:80`, validate, reload, and repeat negative fixtures.
Apache returns 403 for the correct site.
Likely causeDirectory Require policy, traversal permissions, root ownership/mode, AppArmor, or wrong DocumentRoot prevents access.
namei -l /srv/www/example-a.test/public/index.htmlsudo tail -n 50 /var/log/apache2/example-a.test-error.log
ResolutionCorrect the minimum traversal/read permission or exact Directory path; do not make the tree world-writable.
Reference
Frequently asked questions
Why not make domain A the default?
Unknown, direct-IP, stale-DNS, and malicious Host requests would receive tenant A content. A rejecting default avoids that cross-serving.
Does DNS decide the virtual host?
DNS chooses an address. Apache chooses the vhost from local address/port and the HTTP Host header.
Should www be a ServerAlias?
Only when DNS and TLS cover it and serving or redirecting it is intentional.
Recovery
Rollback
Disable the new host files and restore the archived enabled-site layout without deleting retained document roots.
- Run a2dissite for 000-reject and both new domains, then remove only the rejecting site file created by this guide.
- Restore sites-available and sites-enabled from /root/apache-sites-before-vhosts.tgz.
- Run apache2ctl configtest and apache2ctl -S before reloading.
- Keep /srv/www roots until their content has been backed up and explicitly retired.
Evidence