Introduction

An Nginx redirect loop occurs when the server continuously redirects a request from HTTP to HTTPS and back again, creating an infinite cycle. The browser eventually stops with "ERR_TOO_MANY_REDIRECTS". This commonly happens when the HTTP-to-HTTPS redirect logic conflicts with backend proxy behavior or when the $scheme variable is incorrectly evaluated behind a load balancer.

Symptoms

  • Browser displays "ERR_TOO_MANY_REDIRECTS" or "This page isn't redirecting properly"
  • curl -I http://example.com shows repeated 301/302 redirects between HTTP and HTTPS
  • Nginx access log shows the same URL requested multiple times with alternating 301 status codes
  • The redirect loop occurs only for specific paths or only when behind a load balancer/CDN

Common Causes

  • Redirect rule uses $scheme but Nginx is behind a TLS-terminating load balancer, so $scheme is always http
  • Conflicting server blocks: one redirects HTTP to HTTPS, another redirects back
  • Application backend issues its own HTTP-to-HTTPS redirect that conflicts with Nginx redirect
  • return 301 https://$host$request_uri used in a server block that is already listening on port 443
  • Misconfigured if block with rewrite causing unexpected redirect conditions

Step-by-Step Fix

  1. 1.Separate HTTP and HTTPS server blocks cleanly:
  2. 2.```nginx
  3. 3.# HTTP - redirect all to HTTPS
  4. 4.server {
  5. 5.listen 80;
  6. 6.server_name example.com www.example.com;
  7. 7.return 301 https://$host$request_uri;
  8. 8.}

# HTTPS - serve the actual content server { listen 443 ssl; server_name example.com www.example.com;

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ```

  1. 1.Handle load balancer TLS termination by trusting the X-Forwarded-Proto header:
  2. 2.```nginx
  3. 3.# When behind a TLS-terminating load balancer, port 80 receives HTTPS traffic
  4. 4.server {
  5. 5.listen 80;
  6. 6.server_name example.com;

# Only redirect if not already HTTPS from the load balancer if ($http_x_forwarded_proto != "https") { return 301 https://$host$request_uri; }

location / { proxy_pass http://127.0.0.1:3000; } } ```

  1. 1.Prevent application-level redirect conflicts by passing the correct protocol header:
  2. 2.```nginx
  3. 3.proxy_set_header X-Forwarded-Proto $scheme;
  4. 4.`
  5. 5.This tells the backend (Django, Rails, Laravel) that the original request was HTTPS, preventing it from issuing its own redirect.
  6. 6.Debug the redirect chain using curl:
  7. 7.```bash
  8. 8.curl -IL http://example.com 2>&1 | grep -E "< HTTP|< Location"
  9. 9.`
  10. 10.This shows each redirect step. Look for cycles like:
  11. 11.`
  12. 12.< HTTP/1.1 301
  13. 13.< Location: https://example.com/
  14. 14.< HTTP/1.1 301
  15. 15.< Location: http://example.com/
  16. 16.`
  17. 17.Check for conflicting rewrite rules that may cause redirects within the HTTPS block:
  18. 18.```nginx
  19. 19.# WRONG: This rewrites HTTPS to HTTP, causing a loop
  20. 20.rewrite ^(.*)$ http://$host$1 permanent;

# CORRECT: Remove or fix the rewrite to use HTTPS rewrite ^/old-path(.*)$ /new-path$1 permanent; ```

Prevention

  • Always set X-Forwarded-Proto $scheme when proxying to backends that perform their own redirects
  • Use return 301 instead of rewrite ... permanent for simple redirects - it is clearer and less error-prone
  • Document your TLS termination architecture (Nginx handles SSL vs load balancer handles SSL) so team members configure redirects correctly
  • Add a redirect test to your CI pipeline: curl -sIL http://example.com | grep -c "301" | awk '{if ($1 > 1) exit 1}'
  • Use browser developer tools Network tab to trace redirect chains during staging deployment