OneLinersCommand workbench
Guides
Services & Applications / Networking & DNS

Configure nginx server blocks for multiple sites

Create isolated roots, explicit hostnames, a rejecting default server, per-site logs, and deterministic routing tests for several domains.

45 min8 stepsChanges system stateRevision 2
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 8 steps completed
Goal

Prevent unmatched Host headers and one tenant's content from being served by another tenant's nginx configuration.

Supported environments
  • Ubuntu Server 24.04 LTS
  • nginx 1.24+
Prerequisites
  • Console and recovery access Keep an independent console session and a copy of the current nginx 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 nginx 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.
Operating boundary

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

System
  • Two explicit IPv4/IPv6 nginx server blocks with independent document roots, deployment ownership, deterministic content markers, exact hostnames, and per-site access/error logs.
  • A non-content default server that terminates requests for unknown Host headers instead of leaking the first tenant's content.
  • A recoverable site-selection layout with archived available/enabled state, parser and expanded-config gates, named loopback tests, cross-tenant negative paths, and an explicit unknown-host rejection test.
Observable outcome
  • Each intended domain returns only its own marker over IPv4 and IPv6 where configured, and an unknown Host gets no tenant response.
  • Every server_name appears in one reviewed block, the packaged sample site is disabled, roots/logs do not cross, and the default listener is explicit.
  • Operators can add another tenant by repeating a controlled pattern and can restore the previous symlink selection without deleting retained content or logs.

Architecture

How the parts fit together

nginx selects a server block first by listen address/default and then by Host/server_name. A dedicated `000-reject-default` owns IPv4 and IPv6 TCP/80 default status and returns nginx's nonstandard 444 close response without a content root. Two non-default blocks map exact names to distinct `/srv/www/<domain>/public` trees and log files. Ubuntu's sites-available files are activated by relative symlinks in sites-enabled. `nginx -t/-T` validates the include graph before a graceful reload, while `curl --resolve` proves routing independently of public DNS.

Rejecting default serverHandles unmatched/missing Host without selecting tenant content.
Site A server blockMaps only domain A names to its own root, index, route behavior, and logs.
Site B server blockRepeats the isolation boundary for domain B without shared root/log paths.
Independent document rootsSeparate deployments and prevent one tenant's files from being selected by another block.
available/enabled symlinksSeparate stored site definitions from active selection and make rollback auditable.
Parser and named probesValidate syntax/config scope and prove actual Host-to-content selection before DNS cutover.
  1. A connection reaches a specific IPv4/IPv6 listen socket.
  2. nginx compares the Host header with exact server_name values for that address.
  3. A known domain selects its tenant block/root/logs; try_files returns an existing file/directory or a clean 404.
  4. An unknown name selects the explicit default and the connection is closed without another tenant's page.
  5. Per-site logs and marker responses provide evidence for routing and later deployment changes.

Assumptions

  • The nginx baseline is healthy, TCP/80 listener/firewall ownership is known, and `/etc/nginx/sites-available` plus `sites-enabled` are authoritative.
  • Both domains and optional `www` aliases are intentionally owned and should resolve to this host at cutover; no wildcard tenant behavior is desired.
  • The deployment user `{{deployUser}}` is managed and should own only these site trees, while nginx workers require read/traverse but not broad write access.
  • The example static roots are commissioning markers. Dynamic applications, TLS, uploads, caching, PHP, and reverse proxy behavior need separate site-specific procedures.
  • IPv6 is either intentionally supported end-to-end or its DNS/listener/firewall state will be removed deliberately rather than left half-configured.

Key concepts

server_name selection
nginx chooses among blocks on the matched listen address using Host/SNI-related name rules; file order is not a tenant-isolation policy.
default_server
The block selected for a listen address when no configured name matches.
return 444
nginx-specific behavior that closes the connection without a normal HTTP response; useful for a simple non-content default.
try_files
Checks local filesystem candidates in order and uses the final item, here `=404`, when none exists.
document root
The filesystem prefix used to map request URIs to content for one server block.
cross-serving
The isolation failure where one tenant's hostname or an unknown Host receives another tenant's content.

Before you copy

Values used in this guide

{{domainA}}

First exact tenant hostname and root/log filename prefix.

Example: alpha.example.com
{{domainB}}

Second exact tenant hostname and root/log filename prefix.

Example: beta.example.com
{{deployUser}}

Managed non-nginx identity that owns controlled deployment roots.

Example: webdeploy

Security and production boundaries

  • An unknown Host must never fall through to the first customer site; use an explicit default on every exposed address/port, including IPv6 and later TLS.
  • Do not make roots writable by the nginx worker. A compromised request handler should not be able to replace executable/static site content.
  • The deployment identity is a trust boundary; separate higher-risk tenants into different users, services, containers, or hosts.
  • Keep uploads and generated files outside executable/config/source trees and apply route-specific content-type and execution rules.
  • Exact server_name entries avoid accidental wildcard capture; add aliases only after DNS and ownership review.
  • A 444 close can complicate monitoring and client diagnostics. The security property is no tenant content; a minimal 400/404 default is an acceptable alternative.

Stop before continuing if

  • Stop if either name resolves to another active service, ownership is disputed, existing site selection is not archived, or a root contains unbacked production data.
  • Do not enable/reload when duplicate server_name/default listeners, parser warnings, wrong roots, or shared logs appear.
  • Do not accept tests that only return HTTP success; the exact body/tenant marker and negative cross-path behavior must match.
  • Stop if IPv4 and IPv6 route differently or an unknown Host receives a normal tenant status/body.
  • Do not remove old roots/logs during cutover or rollback; retire data only through a separate backed-up process.
01

verification

Verify every site name and local address

read-only

Confirm all names point to the host and identify IPv4/IPv6 listeners before writing server blocks.

Why this step matters

Name and address inventory prevents writing site policy for domains that resolve elsewhere or publishing IPv6 that selects a different service.

What to understand

Compare A/AAAA answers with all host interfaces, routes, NAT/load balancer ownership, TTLs, and planned cutover.

Confirm ownership of bare and www names and search current nginx expanded names for conflicts/wildcards.

Use local `--resolve` later even when DNS is not cut over; public DNS is a separate external acceptance path.

System changes

  • No persistent change; reads DNS, interface, route, and current nginx naming state.

Syntax explained

getent ahosts
Uses the host resolver to display IPv4/IPv6 address records.
ip -brief address
Shows local interface state and addresses compactly.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

getent ahosts {{domainA}} && getent ahosts {{domainB}} && ip -brief address
Example output / evidence
203.0.113.10 STREAM example-a.test
203.0.113.10 STREAM example-b.test

Checkpoint: Checkpoint: dns

Continue whenBoth domains/aliases have deliberate ownership and addresses that match the planned nginx ingress for IPv4/IPv6.

Stop whenA name resolves elsewhere, AAAA is inaccurate, ownership is disputed, or an existing exact/wildcard block overlaps.

If this step fails

getent returns multiple unexpected addresses.

Likely causeOld DNS, CDN/load balancer pool, split DNS, or another service still owns traffic.

Safe checks
  • dig +trace alpha.example.com
  • dig A alpha.example.com
  • dig AAAA alpha.example.com

ResolutionResolve routing/ownership and TTL/cutover before enabling the tenant.

Security notes

  • Do not hijack an existing hostname by relying on local Host tests alone.

Alternatives

  • Stage using temporary internal names or curl --resolve until authoritative DNS cutover is approved.

Stop conditions

  • A name resolves elsewhere, AAAA is inaccurate, ownership is disputed, or an existing exact/wildcard block overlaps.
02

command

Archive enabled and available sites

caution

Preserve file contents plus symlink selections so the prior routing order can be restored.

Why this step matters

Both file content and enabled symlink selection determine nginx routing; the archive must preserve them together before changing defaults or tenants.

What to understand

Archive sites-available and sites-enabled relative to `/etc/nginx`, list symlink entries, verify checksum, and record current `nginx -T` securely.

Document whether configuration management regenerates these directories; an on-host archive is not authoritative when another controller will overwrite it.

Back up application data separately and keep new roots/logs during rollback for evidence.

System changes

  • Creates `/root/nginx-sites-before.tgz` and checksum with site definitions/selections.

Syntax explained

tar -C /etc/nginx
Archives site paths relative to the nginx configuration root.
-czf
Creates a gzip-compressed protected archive.
sha256sum
Records integrity evidence.
Command
sudo tar -C /etc/nginx -czf /root/nginx-sites-before.tgz sites-available sites-enabled && sudo sha256sum /root/nginx-sites-before.tgz
Example output / evidence
e44c...  /root/nginx-sites-before.tgz

Checkpoint: Checkpoint: backup

Continue whenArchive lists both directories/symlinks, is protected, verifies, and has a parser-before-reload restore procedure.

Stop whenArchive is incomplete/unprotected, prior roots/data lack recovery, or a controller will race edits.

If this step fails

Restored files exist but prior selection differs.

Likely causeSymlinks were not preserved or restore extracted into the wrong directory.

Safe checks
  • sudo tar -tvzf /root/nginx-sites-before.tgz | grep sites-enabled

ResolutionCreate/verify an archive retaining symlinks before any change.

Security notes

  • Site archives expose internal names/paths and possibly auth directives; keep root-only.

Alternatives

  • Use a signed version-controlled config release and deployment rollback with equivalent selection evidence.

Stop conditions

  • Archive is incomplete/unprotected, prior roots/data lack recovery, or a controller will race edits.
03

command

Create independent roots and markers

caution

Use separate deployment-owned roots and deterministic pages for routing validation.

Why this step matters

Independent roots and markers provide a visible tenant boundary and prevent a routing test from passing merely because both names serve identical files.

What to understand

Verify the deployment user and parents first. nginx workers need traverse/read; deployment writes should be controlled through releases rather than shared ad hoc edits.

The deterministic marker includes the expected domain and contains no secrets. Inspect ownership/modes after sudo tee, which may create root-owned files.

Real applications need separate writable runtime/upload areas; do not grant nginx/application broad write over source.

System changes

  • Creates two `/srv/www/<domain>/public` trees and marker index files.

Syntax explained

install -d -m 0755
Creates traversable roots with explicit mode/owner/group.
-o deployUser -g www-data
Assigns deployment ownership and nginx-readable group to directories.
tee index.html
Writes exact marker content for routing evidence.
Command
Fill variables0/3 ready

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
Example output / evidence
/srv/www/example-a.test/public/index.html
/srv/www/example-b.test/public/index.html

Checkpoint: Checkpoint: roots

Continue whenRoots are distinct, paths/parents traversable, markers differ by domain, and no secret/production data is overwritten.

Stop whenPaths exist with unknown owner/data, deploy user is shared/unmanaged, or backup/permission model is absent.

If this step fails

nginx cannot read a marker.

Likely causeA parent lacks execute permission or file owner/mode/ACL differs.

Safe checks
  • namei -l /srv/www/alpha.example.com/public/index.html
  • sudo -u www-data cat /srv/www/alpha.example.com/public/index.html

ResolutionGrant minimal read/traverse on the exact tree; never world-write it.

Security notes

  • Deployment ownership is privileged content control; nginx workers should not share it.

Alternatives

  • Use immutable root-owned release directories selected by a deployment symlink.

Stop conditions

  • Paths exist with unknown owner/data, deploy user is shared/unmanaged, or backup/permission model is absent.
04

config

Create a non-content default server

caution

Reject unmatched Host headers instead of serving the first tenant's files.

Why this step matters

A listener-level default prevents unknown/malformed Host from inheriting the first tenant block and makes fallback behavior testable on IPv4 and IPv6.

What to understand

Declare default_server on both address families and no document root. `server_name _` is conventional, while default_server is the selection mechanism.

444 closes without an HTTP response. Monitor the listener/service separately because simple HTTP monitors may see an intentional protocol error.

Repeat the pattern for later TLS listeners with an intentional default certificate/rejection design.

System changes

  • Creates/enables `000-reject-default` for IPv4/IPv6 TCP/80.

Syntax explained

listen 80 default_server
Selects this block for unmatched IPv4 requests on the address.
listen [::]:80 default_server
Provides equivalent IPv6 fallback.
server_name _
Uses a conventional non-real name; it does not itself make the block default.
return 444
Closes the client connection without tenant content.
ln -sfn
Creates/replaces the intended enabled symlink after backup.
File /etc/nginx/sites-available/000-reject-default
Configuration
printf 'server {\n  listen 80 default_server;\n  listen [::]:80 default_server;\n  server_name _;\n  return 444;\n}\n' | sudo tee /etc/nginx/sites-available/000-reject-default && sudo ln -sfn ../sites-available/000-reject-default /etc/nginx/sites-enabled/000-reject-default
Example output / evidence
server_name _;
return 444;

Checkpoint: Checkpoint: reject default

Continue whenExactly one default exists per exposed address/port and it has no root/proxy/tenant content.

Stop whenAnother default remains, IPv6 scope differs, the target path exists with unknown content, or replacement is unreviewed.

If this step fails

nginx -t reports duplicate default server.

Likely causeThe packaged or another site still declares default_server on the same address.

Safe checks
  • sudo nginx -T 2>&1 | grep -n default_server
  • find /etc/nginx/sites-enabled -type l -ls

ResolutionArchive and disable the unintended default; require one explicit fallback per address.

Security notes

  • Fallback must never expose tenant roots, proxy backends, status, or admin interfaces.

Alternatives

  • Return a minimal 400/404 with no identifying body when connection-close behavior is unsuitable.

Stop conditions

  • Another default remains, IPv6 scope differs, the target path exists with unknown content, or replacement is unreviewed.
05

config

Define the first server block

caution

Declare exact names, root, index, no directory fallback, and per-site logs.

Why this step matters

An explicit first tenant block binds exact names to one root/log pair and uses a filesystem existence rule that cannot fall into another application.

What to understand

Review bare and www aliases; omit names not owned/served. Keep IPv4/IPv6 listener symmetry.

`try_files $uri $uri/ =404` serves existing local paths and fails closed. Dynamic front controllers need a different reviewed rule.

Per-site logs enable evidence without shared filenames; directory access policy still governs who can read them.

System changes

  • Creates/enables domain A server block and declares its root, index, logs, and route behavior.

Syntax explained

server_name domainA www.domainA
Matches only the listed exact tenant names.
root /srv/www/domainA/public
Sets this tenant's URI filesystem prefix.
index index.html
Selects the explicit default document.
try_files $uri $uri/ =404
Serves only existing paths/directories, otherwise 404.
access_log/error_log
Separates this tenant's protocol and failure evidence.
File /etc/nginx/sites-available/{{domainA}}
Configuration
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

printf 'server {\n  listen 80;\n  listen [::]:80;\n  server_name {{domainA}} www.{{domainA}};\n  root /srv/www/{{domainA}}/public;\n  index index.html;\n  access_log /var/log/nginx/{{domainA}}-access.log;\n  error_log /var/log/nginx/{{domainA}}-error.log warn;\n  location / { try_files $uri $uri/ =404; }\n}\n' | sudo tee /etc/nginx/sites-available/{{domainA}} && sudo ln -sfn ../sites-available/{{domainA}} /etc/nginx/sites-enabled/{{domainA}}
Example output / evidence
server_name example-a.test www.example-a.test;
root /srv/www/example-a.test/public;

Checkpoint: Checkpoint: site a

Continue whenOne enabled block contains only domain A names/root/logs and no default/wildcard/shared path.

Stop whenAny name/path/log collides, root is absent/unbacked, or file contains another tenant.

If this step fails

nginx warns domain A is conflicting and ignored.

Likely causeAnother exact/wildcard block on the same address already owns it.

Safe checks
  • sudo nginx -T 2>&1 | grep -n 'server_name.*alpha.example.com'

ResolutionChoose one owner and disable the duplicate before reload.

Security notes

  • Do not use broad wildcards or shared writable roots to simplify tenant onboarding.

Alternatives

  • Use a versioned deployment-generated site file when the root/release changes frequently.

Stop conditions

  • Any name/path/log collides, root is absent/unbacked, or file contains another tenant.
06

config

Define the second server block

caution

Repeat the isolation pattern without sharing root or log files.

Why this step matters

Repeating the complete pattern for the second tenant, rather than aliasing the first, creates evidence that content, paths, logs, and ownership are actually isolated.

What to understand

Compare the two files side by side and require differences only where tenant identity demands; accidental copied domain/root values are common.

Use a distinct marker and request a path present only in the other root as a negative cross-tenant test.

Keep deployment/backup/privacy ownership explicit for both tenants even if one operator manages them.

System changes

  • Creates/enables domain B server block with independent root, index, logs, and route behavior.

Syntax explained

server_name domainB
Selects only the second tenant's intended names.
root /srv/www/domainB/public
Separates filesystem content from domain A.
per-site logs
Keeps routine and incident evidence distinguishable.
File /etc/nginx/sites-available/{{domainB}}
Configuration
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

printf 'server {\n  listen 80;\n  listen [::]:80;\n  server_name {{domainB}} www.{{domainB}};\n  root /srv/www/{{domainB}}/public;\n  index index.html;\n  access_log /var/log/nginx/{{domainB}}-access.log;\n  error_log /var/log/nginx/{{domainB}}-error.log warn;\n  location / { try_files $uri $uri/ =404; }\n}\n' | sudo tee /etc/nginx/sites-available/{{domainB}} && sudo ln -sfn ../sites-available/{{domainB}} /etc/nginx/sites-enabled/{{domainB}}
Example output / evidence
server_name example-b.test www.example-b.test;
root /srv/www/example-b.test/public;

Checkpoint: Checkpoint: site b

Continue whenDomain B has one distinct enabled block and no domain A name/root/log appears in it.

Stop whenCopy/paste leaves shared identity/path/log, or application owners have not approved the block.

If this step fails

Domain B returns alpha marker.

Likely causeCopied root/server_name, request Host mismatch, or domain B block was not enabled.

Safe checks
  • readlink -f /etc/nginx/sites-enabled/beta.example.com
  • sudo nginx -T 2>&1 | grep -n -A12 'server_name beta.example.com'

ResolutionCorrect the explicit block/symlink and test via loopback Host before public DNS.

Security notes

  • Filesystem and logging separation must follow the distinct domain trust boundary.

Alternatives

  • Use separate instances/hosts when process-level tenant isolation is required.

Stop conditions

  • Copy/paste leaves shared identity/path/log, or application owners have not approved the block.
07

verification

Remove the sample site and validate routing declarations

read-only

Disable only the packaged default symlink, test the full configuration, and list every server_name before reload.

Why this step matters

Disabling sample content and parsing/dumping the complete tree must happen before reload so duplicate names, defaults, roots, and symlink errors cannot affect users.

What to understand

Remove only the packaged default symlink after verifying its target; keep the available file for package reference/rollback.

Require `nginx -t` with no warnings, then inspect every listen/default/server_name/root/log directive in expanded config.

Count each intended exact name and default; a grep listing is a review aid, not a substitute for the full protected dump.

System changes

  • Removes the packaged default enabled symlink; does not yet reload runtime.

Syntax explained

rm -f sites-enabled/default
Disables the packaged sample selection while retaining its available file.
nginx -t
Parses all enabled configuration and referenced paths.
nginx -T
Prints the expanded active config for name/root/default review.
Command
sudo rm -f /etc/nginx/sites-enabled/default && sudo nginx -t && sudo nginx -T 2>&1 | grep -E 'listen|server_name|root '
Example output / evidence
nginx: configuration file /etc/nginx/nginx.conf test is successful
server_name example-a.test www.example-a.test;
server_name example-b.test www.example-b.test;

Checkpoint: Checkpoint: validate

Continue whenParser is clean; one rejecting default and one exact block per domain appear with distinct roots/logs on both address families.

Stop whenAny warning, duplicate/missing name/default, wrong root/log, dangling symlink, or unrelated site change exists.

If this step fails

Config parser succeeds with a conflicting name warning.

Likely causenginx can ignore a duplicate and continue, which is not acceptable for isolation.

Safe checks
  • sudo nginx -t 2>&1
  • sudo nginx -T 2>&1 | grep -n server_name

ResolutionTreat warnings as failure, remove conflict, and require clean output.

Security notes

  • Expanded config can reveal sensitive paths; protect captured output.

Alternatives

  • Perform identical parser/diff checks through configuration management before deployment.

Stop conditions

  • Any warning, duplicate/missing name/default, wrong root/log, dangling symlink, or unrelated site change exists.
08

verification

Reload and prove name isolation

read-only

Request both names against loopback and verify an unknown name is rejected rather than mapped to either root.

Why this step matters

Only real named requests prove Host selection and marker identity; an explicit failing unknown-host branch prevents earlier positive failures from being masked by shell `||` behavior.

What to understand

Reload only after the clean parser, then use `--resolve` so each URL carries the intended Host while reaching loopback.

Verify exact marker bodies and request cross-only paths. Repeat over IPv6 when enabled.

The unknown Host command must fail. The structured if statement turns accidental success into an explicit nonzero test rather than swallowing prior errors.

System changes

  • Gracefully reloads nginx and writes deterministic per-site/default access/error evidence.

Syntax explained

curl --resolve domain:80:127.0.0.1
Sets both local destination and intended Host deterministically.
--fail
Makes HTTP errors fail positive site tests.
if curl unknown; then exit 1
Requires the rejecting default to prevent a normal successful response.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

sudo systemctl reload nginx && curl --fail --resolve '{{domainA}}:80:127.0.0.1' http://{{domainA}}/ && curl --fail --resolve '{{domainB}}:80:127.0.0.1' http://{{domainB}}/ && if curl --max-time 5 --header 'Host: unknown.invalid' http://127.0.0.1/; then echo 'Unexpected content for unknown host' >&2; exit 1; else echo 'Unknown host rejected'; fi
Example output / evidence
<h1>example-a.test</h1>
<h1>example-b.test</h1>
Unknown host rejected

Checkpoint: Checkpoint: route tests

Continue whenA returns only A, B only B, cross-only files 404, IPv4/IPv6 agree, and unknown Host is rejected.

Stop whenAny marker/status/address family/unknown-host result differs, logs cross, or another site regresses.

If this step fails

Unknown test prints rejected even though an earlier positive test failed.

Likely causeAn old `A && B && unknown || echo` chain masked failure.

Safe checks
  • Run each curl separately and inspect exit status

ResolutionUse the explicit if structure and preserve nonzero positive-site failures.

Security notes

  • Validate unknown/malformed Host through every real edge; upstream proxies may normalize it differently.

Alternatives

  • Use an automated browser/HTTP routing matrix with exact status/body assertions before every reload.

Stop conditions

  • Any marker/status/address family/unknown-host result differs, logs cross, or another site regresses.

Finish line

Verification checklist

Expanded configurationsudo nginx -t && sudo nginx -T 2>&1 | grep -E 'server_name {{domainA}}|server_name {{domainB}}'Configuration succeeds and both names appear exactly once.
Tenant isolationfor d in {{domainA}} {{domainB}}; do curl --fail --silent --resolve "$d:80:127.0.0.1" "http://$d/"; doneEach hostname returns only its own marker.

Recovery guidance

Common problems and safe checks

Both hostnames return the same page.

Likely causeOne request lacks the intended Host, a name is duplicated/missing, both blocks share a root, or another edge caches/routes traffic.

Safe checks
  • sudo nginx -T 2>&1 | grep -nE 'listen|server_name|root '
  • curl -v --resolve alpha.example.com:80:127.0.0.1 http://alpha.example.com/
  • curl -v --resolve beta.example.com:80:127.0.0.1 http://beta.example.com/

ResolutionCorrect exact name/root mappings and retest locally before DNS/edge testing.

nginx reports a conflicting server name warning.

Likely causeThe same exact/wildcard name appears in another enabled file on the same listen address.

Safe checks
  • sudo nginx -T 2>&1 | grep -n 'server_name'
  • find /etc/nginx/sites-enabled -maxdepth 1 -type l -ls

ResolutionChoose one authoritative block for the name, archive/disable the duplicate, and require a clean parser.

Unknown Host still returns a tenant page.

Likely causeThe rejecting default does not cover the connected address/port, another default wins, or a load balancer changes Host.

Safe checks
  • sudo nginx -T 2>&1 | grep -n -A5 default_server
  • curl -v -H 'Host: unknown.invalid' http://127.0.0.1/
  • curl -g -v -H 'Host: unknown.invalid' http://[::1]/

ResolutionMake one rejecting default explicit for every exposed address/port and remove competing defaults.

A site returns 403 although index.html exists.

Likely causeParent traversal, file mode/owner, SELinux/AppArmor, index directive, or directory handling prevents access.

Safe checks
  • namei -l /srv/www/alpha.example.com/public/index.html
  • sudo -u www-data test -r /srv/www/alpha.example.com/public/index.html
  • sudo tail -n 50 /var/log/nginx/alpha.example.com-error.log

ResolutionGrant only required read/traverse access and correct the exact root/index; never chmod 777.

IPv4 works but IPv6 serves another/default site.

Likely causeThe site/default lacks `[::]:80`, an AAAA points elsewhere, or IPv6 firewall/listener differs.

Safe checks
  • dig +short AAAA alpha.example.com
  • sudo ss -lntp 'sport = :80'
  • curl -g -6 -v --resolve 'alpha.example.com:80:[::1]' http://alpha.example.com/

ResolutionConfigure and test the same tenant/default over IPv6 or remove inaccurate IPv6 publication intentionally.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Return a minimal 400 or 404 from the default server when standards-compliant observability matters more than silent close.
  • Use a dedicated default certificate and rejection block on TLS listeners so SNI/Host mismatch cannot leak a tenant certificate/content.
  • Manage full site files declaratively and deploy versioned symlinks rather than editing on the host.
  • Use separate nginx instances/containers/hosts for tenants requiring stronger process or update isolation.

Operate it safely

  • Add TLS server blocks/certificates and a rejecting TLS default, then test SNI, Host, chain, redirect, renewal, and IPv6 independently.
  • Replace markers through an immutable deployment process with release ownership, health checks, rollback, and no nginx-worker write access.
  • Add site-specific headers, limits, caching, compression, MIME, access controls, and per-site structured logs/monitoring.
  • Automate duplicate-name/root/log/default detection and named positive/negative probes before every reload.
  • Document tenant onboarding/offboarding, DNS cutover TTL, certificate lifecycle, backups, privacy, and incident ownership.

Reference

Frequently asked questions

Why not leave Ubuntu's default site?

A content-serving default can disclose sample or tenant content for unknown Host headers. An explicit rejection makes fallback behavior deliberate.

Is server_name `_` special?

It is merely a conventional invalid name; `default_server` on the listen directive determines fallback selection.

Why use curl --resolve?

It sets destination and Host deterministically without DNS propagation, making local routing evidence reproducible.

Recovery

Rollback

Restore the archived site selection and retain document roots until their data is deliberately retired.

  1. Remove only the new symlinks from sites-enabled.
  2. Restore sites-available and sites-enabled from /root/nginx-sites-before.tgz.
  3. Run nginx -t before reloading.
  4. Keep /srv/www content and logs until backups and ownership are confirmed.

Evidence

Sources and review

Verified 2026-07-25Review due 2026-10-23
Ubuntu: configure nginxofficialnginx documentationofficialnginx HTTP log moduleofficial