Skip to content

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.