Fixing 301 Redirect Loops in Nginx
A 301 redirect loop is the worst kind of redirect defect: instead of resolving in one extra hop, the URL bounces A β B β A β B forever until the client gives up. Browsers show "ERR_TOO_MANY_REDIRECTS," and Googlebot abandons the URL entirely β it never gets indexed, and every crawl attempt is wasted budget. Loops almost always come from two well-intentioned rules colliding: an HTTP-to-HTTPS upgrade stacked on a www/non-www canonical, or a return 301 fighting a rewrite. This guide walks the diagnosis with curl -IL, the three root causes, and the corrected single-hop config, building on broader redirect chain optimization.
What you will accomplish:
- Confirm and read a loop from
curl -ILoutput showingA β B β Aping-pong - Identify which of three classic Nginx misconfigurations is producing the loop
- Replace the conflicting rules with one deterministic
301that resolves in a single hop
Diagnosis: Reading the Loop from curl
The fastest confirmation is curl -IL, which follows redirects and prints each hop's status and Location. A healthy redirect shows one 301 then a 200; a loop repeats the same two URLs. Cap the follow count with --max-redirs so a true loop returns an error instead of hanging.
curl -sIL --max-redirs 5 https://example.com/about \
| grep -iE '^HTTP|^location'
Expected Output (a loop):
HTTP/2 301
location: https://www.example.com/about
HTTP/2 301
location: https://example.com/about
HTTP/2 301
location: https://www.example.com/about
curl: (47) Maximum (5) redirects followed
Explanation: The Location values alternate between example.com and www.example.com β classic host ping-pong. One server block forces www, another forces non-www, and neither wins. The curl: (47) error is the loop being cut off at the redirect cap. Before fixing, confirm what each code means in HTTP status codes in server logs: a loop is a chain of permanent 301s, so search engines cache it aggressively and the damage outlives the fix unless you flush it.
Concept: The Three Root Causes
1. Conflicting return 301 and rewrite. A server block has a top-level return 301 while a location block also rewrites the same path. Nginx evaluates return in the server context and rewrite in the location context, and the two send the request to different targets that point back at each other.
2. HTTPβHTTPS stacked with www canonicalization. You have one redirect that upgrades scheme and a separate one that adds or strips www. If they run in different server blocks without a combined final target, the request satisfies one rule, fails the other, gets redirected back, and loops.
3. proxy_pass losing the scheme behind a load balancer. When Nginx sits behind a TLS-terminating proxy or CDN, it sees inbound traffic as plain HTTP. A return 301 https://... rule then fires on every request β including ones the client already loaded over HTTPS β because Nginx cannot tell the original request was secure. The fix is to trust X-Forwarded-Proto rather than $scheme.
Step-by-Step: Replacing the Loop with One Hop
Step 1: Find every redirect rule touching the host. Grep the active config for the directives that produce loops so you see all of them at once, not just the one you remember writing.
grep -rnE 'return 30[12]|rewrite|server_name|proxy_pass' /etc/nginx/
Expected Output:
/etc/nginx/sites-enabled/example:12: server_name example.com;
/etc/nginx/sites-enabled/example:14: return 301 https://www.example.com$request_uri;
/etc/nginx/sites-enabled/example:31: server_name www.example.com;
/etc/nginx/sites-enabled/example:33: rewrite ^ https://example.com$request_uri permanent;
Explanation: Lines 14 and 33 are the culprits β one block forces www, the other forces non-www. Pick one canonical host and make every block agree on it. Mixing return 301 and rewrite ... permanent across blocks is exactly root cause #1 and #2 combined.
Step 2: Collapse to a single canonical redirect. Choose one canonical (here, non-www HTTPS) and write one dedicated redirect server block for every other variant. Each non-canonical host gets exactly one 301 straight to the final URL β no intermediate hop.
# Redirect block: all non-canonical variants -> https://example.com (one hop)
server {
listen 80;
listen 443 ssl;
server_name www.example.com example.com; # http + www variants
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
if ($host = www.example.com) { return 301 https://example.com$request_uri; }
if ($scheme = http) { return 301 https://example.com$request_uri; }
}
# Canonical block: serves content, never redirects to itself
server {
listen 443 ssl;
server_name example.com;
# ... root, location, etc.
}
Production Warning: Editing a live server block can take the site offline if the syntax is wrong or the canonical block is missing. Always validate before reloading, keep a backup of the working config, and reload (not restart) so existing connections drain gracefully. Never reload on a config that fails nginx -t.
Step 3: Test the config, then reload. nginx -t parses the full config and reports the first error without touching the running process. Only reload when it passes.
sudo nginx -t && sudo systemctl reload nginx
Expected Output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Explanation: The two "ok"/"successful" lines mean the parse and test passed and the reload proceeded. If nginx -t prints an error, the && short-circuits and reload never runs β the live site keeps serving the previous good config. This is the safest possible edit-and-apply loop for a production server block.
Edge-Case Handling
Loops behind a CDN or load balancer (root cause #3). If TLS terminates upstream, $scheme is always http inside Nginx and an if ($scheme = http) rule loops forever. Trust the forwarded header instead:
if ($http_x_forwarded_proto = "http") { return 301 https://example.com$request_uri; }
This fires only when the original client request was insecure, breaking the loop. Confirm your proxy actually sets X-Forwarded-Proto before relying on it; if it does not, the redirect will never trigger and HTTP will silently serve.
Trailing-slash ping-pong. A directory rule that adds a slash colliding with a try_files that strips one produces /page β /page/ β /page. Pick one form in your canonical and let Nginx's default directory handling do the rest β do not hand-write both directions. The pattern is identical to the chain detection in finding redirect chains in logs with awk, where slash-only redirects are the most common accidental loop.
Verification: Confirm a Single Hop
Re-run curl -IL against the previously looping URL and every variant. Each should now show one 301 (or zero, for the canonical) ending in a 200.
for u in http://example.com/about https://www.example.com/about; do
echo "== $u =="
curl -sIL "$u" | grep -iE '^HTTP|^location'
done
Expected Output:
== http://example.com/about ==
HTTP/2 301
location: https://example.com/about
HTTP/2 200
== https://www.example.com/about ==
HTTP/2 301
location: https://example.com/about
HTTP/2 200
Every variant lands on the canonical in exactly one hop, ending in 200. No alternating Location values, no curl: (47). The loop is resolved.
Catching the Loop Before It Ships
A redirect loop is far cheaper to catch in config review than in production, where it deindexes pages and burns crawl budget while you diagnose it. The check is mechanical: a loop exists whenever a redirect's target is, directly or transitively, also a redirect source that leads back to the start. Extract the sourceβtarget pairs from your Nginx config and look for cycles before deploying.
# Pull redirect pairs from the config and flag any target that is also a source
grep -hoE 'rewrite [^ ]+ [^ ]+|return 30[18] [^;]+' /etc/nginx/conf.d/*.conf \
| awk '{print $2, $3}' | sort -u
Expected Output: the redirect map as source/target pairs; a target that reappears as a source is a chain, and one that leads back to an earlier source is a loop. Building this into a pre-deploy check β fail the pipeline if a new rule closes a cycle β stops loops at the point of change, the prevention discipline the parent redirect chain optimization guide advocates.
Testing the Fix with curl Before Trusting Logs
After changing a redirect rule, verify it resolves cleanly in one hop before waiting on the next crawl to confirm from logs. curl -I -L follows the whole chain and shows every hop, so a lingering loop or extra hop is immediately visible.
curl -sIL https://example.com/products/old-sku | grep -iE '^HTTP|^location'
Expected Output: a single 301 hop landing on a final 200, e.g. HTTP/2 301 β location: /shop/new-sku β HTTP/2 200. Multiple 30x lines mean the chain or loop survives; a repeating location is the loop still present. This one-command check turns "I think I fixed it" into proof before the change ships, and the log then confirms the recovered crawl over the following days as described in the parent guide.
Common Mistakes
- Stacking scheme and host redirects in separate blocks. Upgrading to HTTPS in one block and canonicalizing
wwwin another is the single most common loop source. Combine both decisions into one redirect that jumps straight to the final scheme-and-host in a single301. - Using
$schemebehind a TLS-terminating proxy. Inside Nginx,$schemereflects the proxy connection, not the client's. It is alwayshttp, so the upgrade rule never stops firing. Switch to$http_x_forwarded_proto. - Restarting instead of reloading, or reloading a broken config.
systemctl restartdrops live connections; reload does not. And reloading without a passingnginx -tcan push a syntax error live. Always chainnginx -t && systemctl reload nginx.
Frequently Asked Questions
Why does my redirect loop only appear in production, not locally?
Almost always root cause #3: production sits behind a CDN or load balancer that terminates TLS, so Nginx sees plain HTTP and the $scheme-based HTTPS redirect fires endlessly. Locally there is no proxy, so $scheme is correct and the loop never forms. Switch the condition to $http_x_forwarded_proto.
Will fixing the Nginx config clear the loop in Google's index immediately?
No. A 301 is permanent and both browsers and Googlebot cache it. After deploying the fix, the loop can persist in client caches and Google's records for days. Verify with curl -IL (which ignores browser cache), and in stubborn cases temporarily serve the corrected redirect as a 302 until crawlers re-fetch, then switch back to 301.
Should I use return 301 or rewrite ... permanent?
Prefer return 301 β it is faster, unambiguous, and evaluated once. rewrite runs the regex engine and is easy to chain accidentally into a loop. Mixing the two across blocks for the same host (as shown in Step 1) is a classic cause. The deterministic format difference between servers is covered in Apache vs Nginx log formats.
Related Guides
- Finding Redirect Chains in Server Logs with awk β locate the looping URLs in your access logs first
- Understanding HTTP Status Codes in Server Logs β why
301loops are cached and outlive the fix - Apache vs Nginx Log Formats β how each server records the redirects you are debugging
Part of the Redirect Chain Optimization series.