3.5 Error Pages, index, autoindex, and Redirects
The last 10% of a static site is what users remember when things go wrong. NGINX's finishing tools — and one Location-header behavior that surprises everyone once.
Basic
What you will learn
error_pagein full: single codes, code lists,=coderewriting, and@namedtargets.index— the quiet machinery behind/— andautoindexas a deliberate, format-choosing feature.Redirects with
return(norewriteneeded): 301/302/307/308 semantics.The
Locationheader surprise: absolute by default, relative withabsolute_redirect off— captured live.Dotfile defense: the one regex location every public site should carry.
1.
error_page — owning the failure moments
error_page 404 /errors/404.html;
error_page 503 502 504 /errors/50x.html; # one page, many codesWith both lines in place, the wiring is easy to prove end to end — the custom page, not the default gray one, answers a missing file:
$ curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/no/such/file
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>404 — gone fishing</title></head>
<body style="font-family:monospace;background:#111;color:#0f8;padding:3rem">
<h1>404</h1>
<p>Custom 404 page — if you can read this, error_page is wired correctly.</p>The mechanics that make it more than a cosmetic:
The error URI is an internal redirect.
/errors/404.htmlre-enters location matching (Lesson 3.2's mechanism) — so the error file can live anywhere, under any policy, even behindinternal(Module 11) or proxied to an app that renders branded errors.The original status survives — the response is still 404; the page content changed, not the semantics. (Search engines and monitors depend on this.)
=rewrites the status when you truly need to:error_page 404 =200 /shell.html;answers 200 with your SPA shell — the "soft 404" pattern. Use sparingly and knowingly: monitoring blind spots are the price.error_pagetargets can be@namedor full URLs (error_page 403 https://…issues a redirect) — the full toolkit from Lesson 3.2 returns.Proxied errors don't trigger it by default. A backend's 502/503 passes through unless
proxy_intercept_errors on;(Lesson 4.6 — filed here so the mental slot exists).
2.
index and
autoindex
index — what
/ really means
A request for a directory URI is a request for the directory's index file. NGINX walks your list, left to right, serving the first that exists:
index index.html index.htm; # the world's default
# index index.php index.html; # the classic PHP-app orderThe machinery is an internal redirect per candidate (Module 11 shows the subrequest economics; for now: it's cheap, cached in the location's resolution). The two production notes: the file is chosen by
existence, not preference — if both exist, the leftmost wins even if the rightmost is newer; and
index interacts with
try_files $uri/ from Lesson 3.2 — the
$uri/ arm hands control to exactly this walk.
autoindex — listings on purpose
With no index file present, a directory request 403s (Lesson 3.2's trap) — unless you choose to show a listing:
location /data/ {
autoindex on;
autoindex_format html; # html (default) | json | xml | plain
}Our example
/data/ is a live listing (trimmed):
$ curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/data/
<html>
<head><title>Index of /data/</title></head>
<body>
<h1>Index of /data/</h1><hr><pre><a href="../">../</a>
<a href="users.json">users.json</a> 22-Sep-2026 18:54 68
</pre><hr></body>
</html>Professional stances: most public sites leave it
off (information disclosure — backup files, dotfiles in listings, internal naming). It shines for internal tooling, release archives, and datasets — where
autoindex_format json becomes an
API for directory contents (automation-friendly: jq-able listings).
autoindex_exact_size off switches to human units;
autoindex_localtime on for local timestamps.
3. Redirects without
rewrite
Most "redirects" in a config are static URI→URI hops, and the rewrite engine (Module 11) is overkill for those.
return does it in the rewrite phase, before any content work:
location = /old { return 301 /about.html; } # permanent
location = /moved { return 308 /about.html; } # permanent, method-safe
location = /teapot { return 418 "short and stout\n"; } # status + bodyThe status-code decoder ring:
Code | Meaning | Method preserved? | Cacheable |
|---|---|---|---|
301 | moved permanently | ❌ (may flip POST→GET) | yes |
302 | moved temporarily | ❌ | not by default |
307 | temporary | ✅ | not by default |
308 | permanent | ✅ | yes |
The 307/308 pair exists precisely because 301/302 were defined in a browser era that rewrote methods — and forms that POST get silently converted to GET by 301, losing bodies. Rule of thumb: user-facing permanent moves → 308; anything else → 307; use 301/302 only for compatibility with ancient clients (and know they'll convert your POSTs).
return also takes full URLs (
return 301 https://…$request_uri; — Module 6's HTTP→HTTPS jump) and can end a chain with any status, body optional — the 418 teapot above being the friendly proof.
4. The
Location surprise (in practice)
Ask NGINX to redirect to a
relative URI — as our
/old does — and look at what goes on the wire:
$ curl -sI -H 'Host: static.example.test' http://127.0.0.1:8300/old | grep -i location
Location: http://static.example.test:8300/about.htmlNGINX absolutizes the target by default: scheme + Host header + your relative URI. Standards-compliant (RFC 7231 permits both forms), and usually what browsers want — but it causes two real-world problems:
Host leakage in multi-tenant setups — an internal alias or a request with a forged
Hostgets echoed into the redirect.Fronted/proxied deployments — behind an LB that speaks a different public name, the absolute URL points at the wrong origin.
The fix is one directive —
/moved demonstrates it side-by-side with
/old:
location = /old { absolute_redirect on; return 301 /about.html; }
absolute_redirect off; # server-level default
location = /moved { return 308 /about.html; }$ curl -sI -H 'Host: static.example.test' http://127.0.0.1:8300/moved | grep -i location
Location: /about.htmlRelative
Location, modern-clients-only, plays perfectly with fronted origins. Its siblings
server_name_in_redirect off; (default: use the request's Host, not the config's
server_name) and
port_in_redirect off; (drop
:port when behind a port-translating LB) solve the adjacent variants of the same class of problem.
5. Dotfile defense
The one regex every public vhost deserves:
location ~ /\.(?!well-known) {
deny all; # 403 for .git, .env, .htaccess, .DS_Store…
}The output below proves both halves:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: static.example.test' \
http://127.0.0.1:8300/.hidden/secret.txt
403
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: static.example.test' \
http://127.0.0.1:8300/secret.txt
200Why the
(?!well-known) carve-out: ACME challenges (Module 6's Let's Encrypt renewals) must answer under
/.well-known/acme-challenge/ while everything else dot-prefixed stays dark. And note what the regex
doesn't match:
/secret.txt — the pattern requires the dot immediately after a slash. Precise patterns beat broad ones; a blanket
location ~ /\. version of this rule once broke half the internet's ACME renewals.
(
deny here is an access-module directive — the full allow/deny/geo arsenal lands in Lesson 6.5. This is its cameo.)
Try it yourself
Tour the error pages. Hit
/no/such/file(404 custom), then break it: renameerrors/404.htmlto.bak, reload… actually — does it even need a reload? (No — static files are re-read per request; the fallback-to-built-in behavior kicks in immediately. Verify.)Status rewriting. Add
error_page 404 =200 /index.html;, reload, check the status of a missing file withcurl -s -o /dev/null -w '%{http_code}'. Feel the monitoring trade-off. Restore.The redirect A/B. Compare
/oldvs/movedLocation headers (§4); then flipport_in_redirect off;and watch the:8300vanish from/old's absolute URL.Autoindex formats. Set
autoindex_format json, reload,curl …/data/ | jq .— you've built a directory API in one line.Try to bypass the dotfile rule.
/hidden/secret.txt(no dot)?/.hidden(no trailing path)?/%2Ehidden/secret.txt(percent-encoded dot — recall Lesson 2.3: decoded before matching)? Predict, test, explain.
Q&A
Q1. Why does a custom
error_page 404 still return status 404, and when would you
want otherwise?
Answer
error_page swaps the body, not the status — clients, monitors, and crawlers need truthful semantics. You opt out deliberately with
error_page 404 =200 … for "soft 404" UX (SPA shells), accepting that monitoring must then watch something other than status codes.
Q2. A form POSTs to
/subscribe; you must redirect permanently without losing the POST body. Which code, and why?
Answer
308 — the method-preserving permanent redirect. A 301 invites the browser to convert the POST to a GET, dropping the body. (307 if the move is temporary.)
Q3. Behind a load balancer,
/old's redirects point users to
http://internal-name:8300/…. Name the two fastest fixes.
Answer
absolute_redirect off; (emit relative Location — the modern default choice) or fix the host source: ensure the LB passes the public Host and X-Forwarded-Proto, and set
port_in_redirect off; if the public edge hides the port. All three levers are this lesson's §4.
Q4. What does
(?!well-known) buy, and what class of bug does its absence cause in the wild?
Answer
It exempts
/.well-known/ (ACME challenges, security.txt) from dotfile denial. Blanket dot-denial without the carve-out breaks TLS certificate renewals fleet-wide — a legendary outage shape, and why the example config ships the carve-out as the default teaching shape.
Takeaways
error_page= internal redirect with status semantics: one page, many codes;=codeto rewrite;@namedand proxied targets are fair game.indexwalks a list on directory URIs;autoindexturns directories into listings (or a JSON API) — on purpose or not at all.Redirects:
returnbeatsrewritefor static hops; 307/308 preserve methods;absolute_redirect off+port_in_redirect offtame the Location-header class of fronted-deployment bugs.Dotfile defense is one anchored regex with a
.well-knowncarve-out — precise patterns, or you break ACME.Module 3 complete: URIs map to files, files to content types, content to wire — fast, and finished like a professional.
Next: Module 4 — the architecture shift that makes NGINX the internet's favorite front door: reverse proxying.
proxy_pass and the trailing-slash trap, header hygiene, FastCGI/uWSGI, WebSockets, and gRPC.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.