Fixing Nginx 'Internal Redirection Cycle' (try_files Loop)

intermediate Nginx2026-07-12| Nginx 1.18+, Ubuntu 22.04/24.04, Debian 12, Single Page Applications (React, Vue, Vite), PHP 8.2/8.3 FPM.

Error Message

rewrite or internal redirection cycle while processing
#nginx#devops#server-config#troubleshooting

The Problem

You've just finished deploying a Single Page Application (SPA) or a Laravel project. Everything seems perfect until you refresh the page and hit a 500 Internal Server Error. When you dive into your Nginx error logs, you find this specific headache:

rewrite or internal redirection cycle while processing "/index.html"

This happens because Nginx is stuck in a loop. It looks for a file, fails to find it, jumps to a fallback, and that fallback triggers the exact same search logic. Nginx has a hard limit of 10 internal redirects. Once it hits that 11th attempt, it kills the request to save your CPU from melting.

The Environment

  • OS: Modern Linux distros (Ubuntu 22.04+, Debian, RHEL)
  • Software: Nginx (any version)
  • Common Scenarios: Client-side routing in React/Vue, index.php routing in PHP frameworks, or custom 404 handlers.

How the Loop Starts

Usually, the trouble starts with a try_files directive that's a bit too optimistic. Look at this common (but flawed) configuration for a Vite-based React app:

server {
    listen 80;
    root /var/www/my-app/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

This tells Nginx to check for the literal file, then the directory. If both fail, it serves index.html.

The trap is simple: If index.html is actually missing from the /dist folder (perhaps due to a failed build), Nginx doesn't just stop. It redirects to /index.html, which then triggers the location / block again. It searches, fails, and redirects to /index.html for a second time. This repeats until Nginx hits the 10-cycle limit and gives up.

Step-by-Step Debugging

1. Inspect the Error Log

Identify which path is failing by tailing your logs in real-time. Run this command while refreshing your browser:

tail -f /var/log/nginx/error.log

Pay close attention to the path in the error. If it says while processing "/index.php", Nginx is telling you that index.php is the missing link in your chain.

2. Verify File Paths and Permissions

Check if the fallback file actually exists where you think it does. Run a quick ls on your root path:

ls -la /var/www/my-app/dist/index.html

If the file is there, Nginx might still be blocked from reading it. Use namei to check the full permission chain. Every directory from / down to your file must be executable (+x) by the www-data user:

namei -om /var/www/my-app/dist/index.html

3. Use Named Locations for Safety

Named locations are a cleaner alternative. They separate your file-checking logic from the actual fallback execution, preventing accidental recursion. This is the industry-standard way to handle SPAs.

For SPAs (React/Vue/Vite):

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

location @fallback {
    rewrite ^ /index.html break;
}

For PHP (Laravel/WordPress):

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

In PHP setups, the loop often happens inside the .php block. To stop the cycle, add =404 to the try_files directive inside your PHP handler. This ensures that if index.php is gone, Nginx returns a clean 404 instead of looping.

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

The Final Solution: A Robust Config

If you prefer a simple try_files setup, you must ensure the final parameter doesn't trigger a new search. Here is a battle-tested configuration for a modern web app:

server {
    listen 80;
    root /var/www/html;
    index index.html;

    location / {
        # Try the file, then directory. 
        # If both fail, send the request to index.html.
        try_files $uri $uri/ /index.html;
    }

    # Explicitly handle index.html to break potential loops
    location = /index.html {
        # The 'internal' directive prevents external users 
        # from calling this location directly if the file is missing.
        internal;
    }
}

Verification

Before you walk away, always validate your Nginx syntax. One missing semicolon can take down your whole site.

sudo nginx -t

If you see syntax is ok, apply the changes:

sudo systemctl reload nginx

Finally, test a non-existent route with curl. If you see a 200 OK (the SPA fallback) or a 404 instead of a 500, you've successfully broken the loop:

curl -I http://localhost/this-route-does-not-exist

Lessons Learned

  • Fallbacks are redirects: The last item in try_files is an internal redirect. If that file is missing, the search starts over.
  • Root typos kill: Double-check your root path. If Nginx is looking in /var/www/html but your files are in /var/www/dist, it will loop forever.
  • Named locations are safer: Use @name syntax for complex routing to keep your logic clean.
  • Permissions matter: Nginx cannot serve what it cannot read. Ensure www-data owns the files.

Related Error Notes