skip to content

An nginx site serving a single-page app uses `try_files $uri $uri/ /index.html;` and its error log fills with "rewrite or internal redirection cycle while internally redirecting to /index.html". What does `try_files` actually do, and what produces that cycle?

level: seniorimportance: should knowfreq 46%

answer

  1. last argument is not tested
  2. fallback means internal redirect
  3. matching restarts from the top
  4. the missing file is the cause
  5. named location ends the chain

basics

~20 s

try_files tests each argument as a file or directory under the current root and serves the first that exists; the last argument is a fallback URI that triggers an internal redirect. If that fallback file is missing, the redirect re-enters the same location and loops until nginx gives up with a 500.

solid answer

~50 s

`try_files` walks its arguments left to right, testing each as a path under the location's `root` or `alias` — `$uri` as a file, `$uri/` as a directory — and serves the first that exists. The **last** argument is different: it is not tested, it is a fallback. If it is a URI, nginx performs an *internal redirect*, which restarts location matching for the rewritten path; if it is `=404`, nginx just returns that code. The cycle happens when the fallback URI lands back in the same location and `try_files` fails again: `/index.html` does not exist under that root — wrong root, wrong `alias`, an unbuilt frontend — so nginx redirects to `/index.html` again, and again. After about ten internal redirects it aborts with 500 and logs that message. The fix is to make the fallback terminal: correct the root, or point it at a named location such as `@spa` that serves the file directly.

code

nginx · 17 lines
nginx
server {
    listen 80;
    server_name app.example.com;
    root /srv/app;

    location /assets/ {
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ @spa;
    }

    location @spa {
        try_files /index.html =404;
    }
}

go deeper

for a junior

Know that try_files checks each path in order and serves the first that exists, and that the final argument is a fallback rather than another candidate file.

for a middle

Explain the internal redirect: nginx rewrites the URI and restarts location matching, so a fallback that fails again re-enters the same block and loops until nginx aborts with 500.

for a senior

Diagnose it end to end — read the cycle message, resolve the fallback against the location's root, stat it as the worker user, and make the fallback terminal with a named location or =404 so the next failure is legible.

for a principal

Own the pattern across the estate: app routes and static assets get separate locations with different fallbacks, so a deploy that ships an incomplete build fails as a visible 404 rather than as HTML served with a 200 under every asset URL.

## What try_files evaluates ```nginx location / { root /srv/app; try_files $uri $uri/ /index.html; } ``` Nginx tests the arguments in order against the filesystem, rooted at the location's `root` (or `alias`): - `$uri` — does `/srv/app<uri>` exist as a file? Serve it. - `$uri/` — does it exist as a **directory**? Then the `index` directive takes over inside it. - the **last** argument — never tested. It is the fallback, and it is one of two things: a **URI**, which triggers an *internal redirect*, or `=<code>` (for example `=404`), which returns that status. The asymmetry between "tested" and "fallback" is the whole question. `try_files $uri $uri/ /index.html;` does not mean "serve /index.html if it exists" — it means "give up and re-request /index.html". ## What an internal redirect really is An internal redirect is not a 302. Nothing goes to the client. Nginx rewrites the request URI to the fallback and **restarts location matching from the top**. The new URI may well select the same `location /` block, which runs the same `try_files` again. So when `/srv/app/index.html` does not exist: ``` /dashboard -> $uri missing, $uri/ missing -> internal redirect to /index.html /index.html -> $uri missing, $uri/ missing -> internal redirect to /index.html ... x10 ``` Nginx caps internal redirects (ten in the standard build) and then aborts the request with **500 Internal Server Error**, logging: ``` rewrite or internal redirection cycle while internally redirecting to "/index.html" ``` The symptom for users is a blank 500 page; the cause is a **missing file**, which is why the message reads so misleadingly. The first thing to check is not the config syntax but whether `/srv/app/index.html` exists and is readable by the worker user. ## The three usual root causes 1. **The file genuinely is not there** — the frontend build never ran, or the deploy dropped `dist/` somewhere else. 2. **The root is wrong**, often because `root` was set inside `location /` while a different location has its own `root` and the fallback selects that one instead. 3. **`alias` plus `try_files`** — the combination has historically been fragile because the alias prefix is stripped before the tests, and the fallback then re-enters a location that resolves differently. Using `root` in a location that runs `try_files` avoids the whole class. ## Named locations: the terminal fallback A named location begins with `@`, takes no part in request matching, and can be reached only through an internal redirect. That makes it the clean way to end the chain: ```nginx location / { root /srv/app; try_files $uri $uri/ @spa; } location @spa { root /srv/app; try_files /index.html =404; } ``` Now if `index.html` is missing the request ends in an honest **404**, not a redirect loop and not a 500. Only the last argument of `try_files` may be a named location, and named locations may not be nested. ## The other terminal form For locations that serve assets rather than app routes, `=404` is the right fallback: ```nginx location /assets/ { root /srv/app; try_files $uri =404; } ``` This matters beyond tidiness: with the SPA fallback applied to `/assets/`, a mistyped bundle URL returns `index.html` with a 200 and `Content-Type: text/html`, and the browser reports a baffling syntax error instead of a missing file. Splitting assets into their own location with `=404` keeps failures legible. ## How to diagnose it in production 1. Read the error log at `error` level — the cycle message names the exact fallback URI. 2. Resolve that URI by hand against the location's `root` and `ls` the path as the worker user (`nginx` or `www-data`); permissions on a parent directory produce the same 404 as absence. 3. Confirm which location the fallback re-enters, by tagging locations with `set $loc ...` and logging the tag. 4. Make the fallback terminal — `@named` or `=404` — so the next occurrence surfaces as a 404 with an obvious cause rather than a 500.

  • What exactly is a named location, and why does routing the fallback into one break the cycle?
    A named location starts with `@` and is excluded from ordinary request matching — no request path can select it, only an internal redirect from `try_files` or `error_page`. Because the loop needs the fallback to re-select a location that runs the same failing `try_files`, sending it to a named location that ends in `=404` gives the chain a terminal step.
  • Why is `try_files $uri $uri/ /index.html;` a bad idea inside a location that serves static assets?
    A mistyped bundle URL then returns index.html with a 200 and `Content-Type: text/html`. The browser tries to parse HTML as JavaScript or CSS and reports a syntax error, hiding the real cause. Give asset locations their own block with `try_files $uri =404;` so a missing file fails as a missing file.
  • How would you confirm the cause is a missing file rather than a configuration error?
    Take the URI named in the cycle message, resolve it against that location's `root`, and stat the path as the worker user — nginx runs workers as `nginx` or `www-data`, so a directory the deploy left unreadable looks identical to absence. `nginx -t` passing tells you nothing here; the config is valid, the file is not there.

saying these in an interview costs you the question

  • Thinks try_files tests the last argument too
  • Reads the internal redirect as a 302 to the client
  • Blames the config syntax rather than a missing file
  • Adds the SPA fallback to asset locations as well
  • Believes a named location can be requested directly

context