How to Fix Nginx try_files Fallback Loop: Resolving the Internal Redirection Cycle Error
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke:
try_filesis falling back to a URI that re-enters the samelocationblock, creating an infinite internal redirect until Nginx hitsrecursive_error_pagesor worker connection limits and throws HTTP 500. - How to fix it: The fallback (last) argument in
try_filesmust be a named location (@fallback) or a direct file path — never a URI that matches the current location block. - Use the Client-Side Sandbox above to paste your
nginx.confand auto-refactor thetry_fileschain without leaking your config to a third-party server.
The Incident (What Does the Error Mean?)
Your Nginx error log is printing:
2024/01/15 03:42:17 [error] 1234#1234: *998 rewrite or internal redirection cycle
while internally redirecting to "/index.php", client: 10.0.0.1,
server: app.internal, request: "GET /missing-page HTTP/1.1"
Nginx enforces a hard internal redirect limit (default: 10 cycles). Once hit, it immediately serves a 500 Internal Server Error to the client. The worker process is not crashed, but every request hitting this code path returns 500 — meaning your entire application is down for any URL that triggers the fallback.
The Attack Vector / Blast Radius
This is not a theoretical edge case. The loop activates on any request for a non-existent file — a 404 path, a missing asset, a bot scan. The blast radius:
- Every 404-class request returns 500 instead, destroying SEO crawl signals and error monitoring fidelity.
- Under load, bots or scanners hitting random paths will saturate Nginx's internal redirect counter per worker, compounding latency across legitimate requests sharing the same worker pool.
- If you're running PHP-FPM behind this block, the cycle may cause FPM to receive the same request repeatedly before the cycle limit is hit, creating phantom process spawns under high concurrency.
- Upstream health checks (ALB, Kubernetes liveness probes) hitting a path covered by this location block will fail, potentially triggering cascading pod evictions or target deregistration.
How to Fix It (The Solution)
Root Cause
try_files $uri $uri/ /index.php — the final fallback /index.php is a URI rewrite, not a named location. If the location block that catches /index.php is the same block (or a block that itself calls try_files again), Nginx loops.
Basic Fix — Use a Named Location as the Fallback
server {
listen 80;
server_name app.internal;
root /var/www/html;
index index.php index.html;
location / {
- try_files $uri $uri/ /index.php;
+ try_files $uri $uri/ @php_fallback;
}
+ location @php_fallback {
+ fastcgi_pass unix:/run/php/php8.2-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root/index.php;
+ include fastcgi_params;
+ }
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
Named locations (@php_fallback) are terminal — Nginx does not re-evaluate location matching from them, breaking the cycle by design.
Enterprise Best Practice — Explicit SCRIPT_FILENAME + Separate Ingress Routing
For production environments behind a load balancer or Kubernetes Ingress:
location / {
- try_files $uri $uri/ /index.php?$query_string;
+ try_files $uri $uri/ @rewrite_fallback;
}
+location @rewrite_fallback {
+ # Explicitly pass to FPM with a hardcoded entry point.
+ # Never use $uri here — that re-introduces the cycle risk.
+ internal;
+ fastcgi_pass unix:/run/php/php8.2-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root/index.php;
+ fastcgi_param QUERY_STRING $query_string;
+ fastcgi_param REQUEST_URI $request_uri;
+ include fastcgi_params;
+}
# Deny direct .php execution outside of the named location
location ~ \.php$ {
+ # Only reachable via explicit file match, not fallback
try_files $fastcgi_script_name =404;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
The internal directive on the named location ensures it cannot be hit by external requests directly — tightening the attack surface while eliminating the loop.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. nginx -t in every pipeline stage — mandatory, not optional.
Add this as a pre-merge gate:
# .github/workflows/nginx-lint.yml
- name: Validate Nginx Config
run: |
docker run --rm \
-v ${{ github.workspace }}/nginx:/etc/nginx:ro \
nginx:alpine nginx -t
Note: nginx -t does not catch logical redirect loops — it only validates syntax. You need the steps below for semantic validation.
2. gixy — Nginx Static Security Analyzer
gixy by Yandex specifically detects try_files loop patterns (ssrf, add_header_redefinition, alias_traversal):
pip install gixy
gixy /etc/nginx/nginx.conf
# PROBLEM: [try_files] Possible internal redirect loop detected
Integrate into CI as a blocking step.
3. OPA/Conftest Policy for Nginx
If you manage Nginx config as code (Helm, Ansible, Terraform templatefile), enforce a Rego policy:
# policy/nginx_try_files.rego
package nginx
deny[msg] {
block := input.servers[_].locations[_]
try_files := block.try_files
# Last argument must start with @ (named location) or be =CODE
last_arg := try_files[count(try_files)-1]
not startswith(last_arg, "@")
not startswith(last_arg, "=")
msg := sprintf("try_files last argument '%v' is a URI fallback — use a named location to prevent redirect loops.", [last_arg])
}
4. Integration Test with curl + loop detection
# In your smoke test suite post-deploy
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" https://app.internal/nonexistent-path-$(date +%s))
if [ "$HTTP_CODE" = "500" ]; then
echo "FATAL: 500 on non-existent path — possible try_files loop. Check Nginx error log."
exit 1
fi
A 404 is expected. A 500 on a random nonexistent path is the canary for this exact bug.