3.2 try_files: Fallbacks and the SPA Pattern
One directive that walks a list of candidate paths and picks the first that exists — and, in its last arm, jumps anywhere in your config. Small syntax, enormous leverage.
Basic
What you will learn
The exact semantics of each
try_filesarm — including the ones that look like files but aren't.The SPA pattern, end to end, tested against real requests.
Named-location fallbacks (
@gone) and the "code + jump" form.What
try_filesdoes to$uri(and why ordering afterrewritematters).The two production traps: cache-key blindness and directory-index surprises.
1. The semantics, arm by arm
location /app/ {
try_files $uri $uri/ @gone;
}When a request enters this location, NGINX checks the arms left to right, in the location's mapped filesystem (root or alias — Lesson 3.1):
Arm | Meaning | Checked how |
|---|---|---|
| does the mapped
file
|
|
| does the mapped
directory exist? — if yes, run
|
|
| internal redirect: re-run location matching with this new URI | config-level jump |
| jump to the named location — no URI change | config-level jump |
| stop; produce that status via error handling | terminal |
Two details separate professionals from tourists:
A plain path arm is an internal redirect, not a file check. The third arm
/spa/index.htmldoesn't stat that path in this location — it restarts location matching for URI/spa/index.htmlanywhere in the server. That's why the SPA fallback works even though the file is served by a different location.$uri/inherits the currentindexdirective. Ifindexisindex.htmland the directory exists, the request internally redirects to<dir>/index.html. If that file doesn't exist andautoindexis off → 403 (directory, no index) — the classic "403 out of nowhere".If the final arm is neither a URI nor
@namenor=code, it is served as-is in the current context — usually producing exactly one request's worth of confusion. Make your final arm explicit.
Two live patterns, both verified:
# Pattern A — the SPA (alias + fallback to the app shell):
location /spa/ {
alias www/spa/;
try_files $uri $uri/ /spa/index.html;
}
# Pattern B — the graceful API tree (named-location fallback):
location /app/ {
try_files $uri $uri/ @gone;
}
location @gone {
default_type text/plain;
return 404 "fell through to the @gone named location\n";
}2. The SPA pattern, end to end
Modern frontend apps are one
index.html plus client-side routing: the browser may request
/spa/settings/profile, which
does not exist on disk but must
return the app shell so JavaScript can route it. The whole trick is one
try_files:
GET /spa/assets/spa.js arm 1 ($uri): file EXISTS -> served 200
GET /spa/ arm 2 ($uri/): dir exists + index.html 200
GET /spa/settings/profile arm 1: missing; arm 2: not a dir;
arm 3: internal redirect to /spa/index.html
-> matched by THIS SAME location -> served 200Verified live (headers trimmed):
$ curl -s -D - -o /dev/null -H 'Host: static.example.test' \
http://127.0.0.1:8300/spa/nonexistent/route
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 442The deep route returns the shell with 200, not 404-then-rewrite — crucial for client routers and for crawlers. Three production notes:
The fallback URI must itself resolve.
/spa/index.htmlre-enters location matching and lands back in/spa/here — a loop-free round trip because the file exists. If your shell were outside the location, it'd resolve through a different one; both are fine, just be conscious.Cache the shell carefully (Module 8): the shell should be short- TTL or cache-busted; assets are content-addressed (long TTL). A blanket cache rule flattens this distinction.
Real files still win — arm 1 — so deploys of new assets are instantly live while old hashed names keep serving from disk if you keep them around. Zero-downtime frontends fall out of this naturally.
3. Named-location fallbacks
@gone demonstrates the other escape: a
named location — an unreachable-by-URI bundle of directives you jump into (Lesson 2.3 introduced them; here they earn their keep):
$ curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/app/nowhere
fell through to the @gone named locationWhy not just
=404? Because a named location can do
anything a location can: proxy to an app pool (
location @app { proxy_pass … } — Module 4's bread and butter), render a branded page with its own headers and logging, or serve a maintenance file. It's the "if all else fails, do THIS" hook — and it composes:
error_page can also target
@named (Lesson 3.5).
4. The two production traps
Trap 1 — cache-key blindness.
try_files fires
before caching decisions in the request pipeline, but the
URL the client sees is unchanged by the internal redirect. A cache keyed on
$uri (post-redirect) can serve the app shell's cached bytes under a deep route — usually exactly what you want — but a cache keyed carelessly on
$request_uri mixed with
$uri rules creates shell/asset mixups. Module 8 dedicates a sidebar to this; the rule of thumb:
one key variable, chosen deliberately.
Trap 2 — directory 403s. When
$uri/ matches but no index file exists and
autoindex off: 403. Authors expect 404 ("nothing there") and get "forbidden" — alarming in logs and to clients. Either give the directory an index, enable autoindex deliberately (Lesson 3.5), or drop the
$uri/ arm when directories shouldn't be servable at all.
Try it yourself
Walk the arms yourself. For each of these, predict the winning arm first, then verify (status + body):
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: static.example.test' \
http://127.0.0.1:8300/spa/assets/spa.js # ?
curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/spa/ | head -1
curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/spa/a/b/c | head -1
curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/app/nowhereBreak trap 2 on purpose. Create
/var/www/main/nodx/(no index file), addlocation /nodx/ { try_files $uri $uri/ =404; }, reload, request/nodx/— meet your 403. Then add an index file and watch it 200.Rearm the fallback. Change
@goneto=404, reload, and feel the difference (default error page returns). Restore the named location — note it kept your custom body and status.A fallback that proxies (Module 4 preview — safe to try): replace
@gone's return withproxy_pass http://127.0.0.1:9010;, reload, and request/app/anything— the python echo backend answers. You have just built the classic "static first, app second" edge.
Q&A
Q1. In
try_files $uri $uri/ /index.html;, what exactly does the second arm check and what can it trigger?
Answer
It stats the mapped directory; if it exists, the
index machinery runs — an internal redirect to the first existing index file, or (if none and no autoindex) a 403. It is not a "file named after the URI with a slash".
Q2. Why does the SPA fallback return 200 for
/spa/deep/route instead of 404?
Answer
The final arm
/spa/index.html is an
internal redirect: location matching restarts for that URI, the file exists, and it's served with 200. The client's URL never changes; only the internal routing did.
Q3. Give the one-line
try_files for: "serve the file if it exists; otherwise hand the request to the app pool" (no directory handling).
Answer
try_files $uri @app; with
location @app { proxy_pass http://app-pool; } — skipping
$uri/ deliberately when directories are meaningless for the route.
Q4. Requests to
/docs/ return 403 though
/docs/ exists on disk. Name the two most likely causes and their fixes.
Answer
(1)
$uri/ arm matched, no index file present,
autoindex off → add an index file or
autoindex on. (2) Directory permissions deny the NGINX worker user (OS-level) →
chmod/
chown the tree. The error log distinguishes them instantly (403 with no
open() failure vs a permission-denied line).
Takeaways
try_fileswalks arms left-to-right: file stat → directory+index → URI-redirect →@named→=code; the first satisfied arm wins.Plain-path arms are internal redirects that restart location matching — the mechanism behind the SPA pattern (deep links → app shell → 200).
Named locations are the "else" clause with superpowers (proxy, brand, log) — and the natural seam between static serving and applications, which is exactly where Module 4 takes over.
Watch the two production traps: cache-key discipline across the internal redirect, and the directory-without-index 403.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.