Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Beginner ~35 min read

Basic

What you will learn

  1. The exact semantics of each try_files arm — including the ones that look like files but aren't.

  2. The SPA pattern, end to end, tested against real requests.

  3. Named-location fallbacks ( @gone) and the "code + jump" form.

  4. What try_files does to $uri (and why ordering after rewrite matters).

  5. The two production traps: cache-key blindness and directory-index surprises.


1. The semantics, arm by arm

nginx
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

$uri

does the mapped file <root>/<uri> exist?

stat() on the final path

$uri/

does the mapped directory exist? — if yes, run index machinery (which may trigger a subrequest or internal redirect to the index file)

stat() + index module

/spa/index.html (any URI)

internal redirect: re-run location matching with this new URI

config-level jump

@name

jump to the named location — no URI change

config-level jump

=404 (or any =code)

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.html doesn't stat that path in this location — it restarts location matching for URI /spa/index.html anywhere in the server. That's why the SPA fallback works even though the file is served by a different location.

  • $uri/ inherits the current index directive. If index is index.html and the directory exists, the request internally redirects to <dir>/index.html. If that file doesn't exist and autoindex is off → 403 (directory, no index) — the classic "403 out of nowhere".

  • If the final arm is neither a URI nor @name nor =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:

nginx
# 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:

text
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 200

Verified live (headers trimmed):

bash
$ 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: 442

The deep route returns the shell with 200, not 404-then-rewrite — crucial for client routers and for crawlers. Three production notes:

  1. The fallback URI must itself resolve. /spa/index.html re-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.

  2. 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.

  3. 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):

bash
$ curl -s -H 'Host: static.example.test' http://127.0.0.1:8300/app/nowhere
fell through to the @gone named location

Why 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

  1. Walk the arms yourself. For each of these, predict the winning arm first, then verify (status + body):

bash
   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/nowhere
  1. Break trap 2 on purpose. Create /var/www/main/nodx/ (no index file), add location /nodx/ { try_files $uri $uri/ =404; }, reload, request /nodx/ — meet your 403. Then add an index file and watch it 200.

  2. Rearm the fallback. Change @gone to =404, reload, and feel the difference (default error page returns). Restore the named location — note it kept your custom body and status.

  3. A fallback that proxies (Module 4 preview — safe to try): replace @gone's return with proxy_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_files walks 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.