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 -IL output showing A → B → A ping-pong
  • Identify which of three classic Nginx misconfigurations is producing the loop
  • Replace the conflicting rules with one deterministic 301 that 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.

Common Mistakes

  • Stacking scheme and host redirects in separate blocks. Upgrading to HTTPS in one block and canonicalizing www in 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 single 301.
  • Using $scheme behind a TLS-terminating proxy. Inside Nginx, $scheme reflects the proxy connection, not the client's. It is always http, so the upgrade rule never stops firing. Switch to $http_x_forwarded_proto.
  • Restarting instead of reloading, or reloading a broken config. systemctl restart drops live connections; reload does not. And reloading without a passing nginx -t can push a syntax error live. Always chain nginx -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.

Part of the Redirect Chain Optimization series.