Serving static files
Scope: the web-server job — root, index,
try_files, and alias — including the trailing-slash
trap that has bitten everyone who has ever used alias.
The minimal site
server {
listen 127.0.0.1:8080;
server_name localhost;
root /home/you/nginx-lab/www/main;
index index.html;
}
root is the directory the URL space maps onto: a
request for /hello.txt reads
<root>/hello.txt. index names the file served when
the request is for a directory itself. From the lab:
$ curl -s http://127.0.0.1:8080/
<!doctype html>
<h1>main root: index</h1>
$ curl -s http://127.0.0.1:8080/hello.txt
main root: plain file hello.txt
One line in the stock config earns its keep here:
include /etc/nginx/mime.types; in the http block
tells nginx which Content-Type goes with each file
extension. Leave it out and everything is served as the
default_type — your stylesheet arrives labelled
text/plain and the browser ignores it. With the
include in place:
$ curl -sI http://127.0.0.1:8080/style.css
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/css
Missing files, and your own error page
Ask for something that isn't there and the status is 404; a line lands in the error log naming the exact path nginx tried (chapter 10 teaches reading these):
2026/10/20 20:44:53 [error] ... open() "/home/you/nginx-lab/www/main/no-such-file.txt" failed (2: No such file or directory), client: 127.0.0.1, server: localhost, request: "GET /no-such-file.txt HTTP/1.1", host: "127.0.0.1:8080"
error_page swaps in your own page while keeping the
honest status code:
error_page 404 /404.html;
location = /404.html { internal; }
$ curl -s -w "\nstatus: %{http_code}\n" http://127.0.0.1:8080/no-such-file.txt
<!doctype html>
<h1>custom 404 page</h1>
status: 404
The internal location means visitors can't request
/404.html directly; it exists only as the target of
the error redirect.
A related status, seen in the same transcript set: asking for a directory that has files but no index file is a 403 —
2026/10/20 20:44:53 [error] ... directory index of "/home/you/nginx-lab/www/main/emptydir/" is forbidden, ...
— unless you turn on autoindex for it. Which brings
us to what you serve by accident.
try_files: fallback instead of 404
location /spa/ {
try_files $uri $uri/ /index.html;
}
try_files walks its list: serve the file if it
exists, else the directory's index, else — instead of
404ing — hand the request to /index.html. That last
step is what single-page applications need (the app
routes in the browser, so every path should return the
app):
$ curl -s http://127.0.0.1:8080/spa/some/deep/route
<!doctype html>
<h1>main root: index</h1>
The same mechanism with =404 as the final term is the
usual static-site form: try_files $uri $uri/ =404;.
root versus alias, and the trap
alias also maps a location to a directory, but it
replaces the location prefix instead of appending the
whole URI:
location /rooted/ { root /home/you/nginx-lab/www/rootbase; }
location /images/ { alias /home/you/nginx-lab/www/images/; }
$ curl -s http://127.0.0.1:8080/rooted/logo.txt
rootbase: rooted/logo.txt — the URI path survives under root
$ curl -s http://127.0.0.1:8080/images/logo.txt
images directory: logo.txt (reached through alias)
Same-looking setup, different path arithmetic:
/rooted/logo.txt reads rootbase + the full URI
(rootbase/rooted/logo.txt), while /images/logo.txt
reads images/ + only the part after the location
(images/logo.txt).
And now the trap, demonstrated. The same alias with
its trailing slash missing:
location /trap/ { alias /home/you/nginx-lab/www/images; }
$ curl -s -w "status: %{http_code}\n" http://127.0.0.1:8080/trap/logo.txt
<!doctype html>
<h1>custom 404 page</h1>
status: 404
$ tail -1 error.log
... open() "/home/you/nginx-lab/www/imageslogo.txt" failed (2: No such file or directory), ...
alias substituted the location text with the alias
text character for character: /trap/ became
/home/you/.../images and the remainder logo.txt was
glued straight on — imageslogo.txt. The rule that
keeps you safe: the slashes on location and alias
must match — both trailing or both not — and when in
doubt, prefer root, whose arithmetic never surprises.
(The error log, as usual, shows the exact path tried;
a glued-together path like imageslogo.txt is the
fingerprint of this mistake.)
Serving only what you mean to
Two lab fixtures exist to make this concrete.
Directory listings. With autoindex on, a
directory without an index becomes a public listing:
$ curl -s http://127.0.0.1:8080/list/
<title>Index of /list/</title>
...
<a href="notes.md">notes.md</a>
<a href="readme.txt">readme.txt</a>
Handy on a private file share; on a public server it advertises every filename you have. It is off by default — keep it that way unless the listing is the point.
Dotfiles. In the lab's first configuration, nothing stopped this:
$ curl -s http://127.0.0.1:8080/secret/.hidden
you should never see this over HTTP
Anything under your root is web-readable unless you
say otherwise — including the .git directory of a
deployed site, which hands out your source and history.
The standard guard, one line in the server block:
location ~ /\. { deny all; }
Added to the lab and reloaded (chapter 4's capture), the same request now answers 403. Check your own servers for this before you check anything fancier.
Check: request, on a site you run: a file that
exists, a file that doesn't, a directory with no index,
and /.git/config. The four answers — 200, your 404,
403, and refused — are the static-serving health check.