# ERR_TOO_MANY_REDIRECTS: Find the Loop and Fix It

> ERR_TOO_MANY_REDIRECTS means two layers disagree about the right URL. One curl command shows the loop; its shape names the cause: HTTPS, host, slash or login.

1. [Home](/)
2. [Blog](/blog)
3. ERR\_TOO\_MANY\_REDIRECTS: Read the Location Chain, Find the Loop

DeploymentNovember 12, 202610 min read

# ERR\_TOO\_MANY\_REDIRECTS: Read the Location Chain, Find the Loop

A redirect loop is two layers disagreeing about the right URL. One curl command prints the whole chain, and the shape of that chain tells you which layers to look at: HTTPS behind a proxy, www, a trailing slash or a login check.

## The short version

ERR\_TOO\_MANY\_REDIRECTS means the browser followed about 20 redirects without reaching a page, because two layers disagree about the right URL. Print the chain with curl -sIL and read the Location headers. A URL that redirects to itself usually means an app behind a TLS proxy can't see HTTPS; fix it by trusting X-Forwarded-Proto, not by removing the redirect.

[Teo Marquardt](/about#author)· Facts last checked November 11, 2026

You deploy, open the site, and Chrome shows this instead of your app:

text

```
This page isn’t working
example.com redirected you too many times.
ERR_TOO_MANY_REDIRECTS
```

Below that, Chrome suggests clearing your cookies. Every response the browser got was a redirect, so it gave up. Somewhere in the path between the browser and your code, two layers each think the request belongs at a different URL and keep sending it to the other. Deleting cookies fixes one particular version of this. The other versions sit in server, proxy or CDN config, and the fastest way to tell them apart is to look at the chain itself rather than guess.

If the site isn't yours, cookies and a private window are all you can try. The rest of this post is for whoever runs the site.

## What ERR\_TOO\_MANY\_REDIRECTS means

ERR\_TOO\_MANY\_REDIRECTS is Chrome's error for a redirect chain that never ends: the browser followed 20 redirects, each a 3xx response with a `Location` header, without reaching a page. Firefox stops at 20 as well, through its `network.http.redirection-limit` setting, as of writing. Raising that limit wouldn't help anyone: the browser would wait longer and then show the same error.

### The same loop in other browsers and clients

| Client          | What it says                                                 | Gives up after                      |
| --------------- | ------------------------------------------------------------ | ----------------------------------- |
| Chrome, Edge    | ERR\_TOO\_MANY\_REDIRECTS, "redirected you too many times"   | 20 redirects                        |
| Firefox         | The page isn’t redirecting properly                          | 20 (network.http.redirection-limit) |
| Safari          | Safari Can’t Open the Page … "too many redirects occurred"   | not documented                      |
| curl -L         | curl: (47) Maximum (50) redirects followed                   | 50 (\--max-redirs)                  |
| Python requests | requests.exceptions.TooManyRedirects: Exceeded 30 redirects. | 30                                  |
| Node.js fetch   | TypeError: fetch failed, cause redirect count exceeded       | 20                                  |

The same redirect loop as each client reports it. Limits as of writing. The curl, requests and Node lines were reproduced on 11 Nov 2026 against a local A → B → A loop with curl 8.21, requests 2.34 and Node 24.

If a health check, webhook sender or another service reports one of the non-browser lines, it's the same loop seen by a client with no cached redirects and no HSTS memory, which makes it a cleaner witness than your browser.

## See the whole loop with one command

curl follows redirects with `-L` and prints only headers with `-I`. Cap the hops low so the output stays short, and keep only the status lines and `Location` headers:

bash

```
curl -sIL --max-redirs 6 http://example.com/dashboard | grep -iE '^(HTTP|location)'
```

A healthy site prints one or two redirects and then a 200\. A loop prints the same pair of `Location` values over and over, or the same URL again and again, which shows you both the cycle and how long it is. This one redirects to itself:

text

```
HTTP/1.1 308 Permanent Redirect
location: https://example.com/dashboard
HTTP/2 301
location: https://example.com/dashboard
HTTP/2 301
location: https://example.com/dashboard
HTTP/2 301
location: https://example.com/dashboard
```

curl doesn't send your browser's cookies or use its redirect cache, so it shows what the server does right now. To watch it in the browser, open DevTools, go to the Network tab, tick Preserve log and reload. Each hop shows up as its own row with its status and `Location`.

## The shape of the loop names the cause

What changes between hops, and what stays the same, points at the two layers that disagree. These five shapes cover most loops on a deployed app:

| What the chain does                        | Who disagrees                                                            | Where to look                                                                         |
| ------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| https://example.com/x redirects to itself  | A TLS-terminating proxy and an app that thinks the request is plain HTTP | X-Forwarded-Proto, the framework's trusted-proxy setting, the CDN's SSL mode          |
| Alternates http:// and https://            | One layer forces HTTPS, another sends to HTTP                            | A hard-coded http:// base URL (APP\_URL, WordPress Site Address), an old rewrite rule |
| Alternates example.com and www.example.com | Two layers chose different canonical hosts                               | Domain redirect at the host, DNS provider or CDN vs the redirect in your app          |
| Alternates /docs and /docs/                | Trailing-slash policy                                                    | The framework's slash setting vs a rule in nginx or on the host                       |
| /login → /dashboard → /login               | Two auth checks that disagree                                            | Middleware that checks a cookie exists vs a backend that rejects it                   |

Read the Location values from the curl output, then match the pattern.

Don't fix the loop by deleting the HTTPS redirect

Turning off `SECURE_SSL_REDIRECT` or `force_ssl` makes the error go away and leaves plain-HTTP requests unredirected wherever nothing else upgrades them. Keep the redirect and correct the app's idea of which scheme the request arrived on.

## A URL that redirects to itself: the app can't see HTTPS

This is the loop people hit on the first deploy behind a reverse proxy or a hosting platform. TLS ends at the proxy. The proxy forwards the request to your app over plain HTTP on a private network. Your app has a rule that says "if this request isn't HTTPS, redirect to HTTPS", looks at its own connection, sees HTTP, and redirects. The browser was already on HTTPS, so it requests the same URL again, the proxy forwards it over HTTP again, and the cycle repeats until the browser gives up.

The proxy tells the app the original scheme in a header, [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Proto): `X-Forwarded-Proto: https`. Most frameworks, Rails being the exception, ignore it by default, because a client can send that header too, and trusting it from anyone would let a request claim to be HTTPS when it isn't. The fix is to tell the framework that the proxy in front of it is trusted. Then it reads the header and stops redirecting.

Trust the header only when the app can be reached through your proxy alone, and when that proxy sets the header itself rather than passing on whatever the client sent. Every setting below has a stricter form that names the proxy's address; use it if your app is also reachable some other way.

### Django: SECURE\_PROXY\_SSL\_HEADER

python

```
# settings.py
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
```

Without the second line, `request.is_secure()` is always false behind a proxy, and `SECURE_SSL_REDIRECT` loops. The [Django docs](https://docs.djangoproject.com/en/stable/ref/settings/#secure-proxy-ssl-header) carry the same warning as above: set it only if your proxy strips the header from incoming requests or overwrites it.

### Rails: config.assume\_ssl

ruby

```
# config/environments/production.rb
config.assume_ssl = true   # Rails 7.1+: TLS ended at the proxy
config.force_ssl = true
```

Rails reads `X-Forwarded-Proto` through Rack on its own. `assume_ssl` is for the case where the proxy doesn't send it: Rails then treats every request as HTTPS, so `force_ssl` stops redirecting and secure cookies still get set. Only turn it on when nothing but the TLS proxy can reach the app.

### Express: app.set('trust proxy', 1)

javascript

```
const app = express();
app.set("trust proxy", 1); // trust the first proxy in front of the app

app.use((req, res, next) => {
  if (req.secure) return next();
  res.redirect(308, `https://${req.headers.host}${req.originalUrl}`);
});
```

Without `trust proxy`, `req.secure` and `req.protocol` describe the hop from the proxy, which is HTTP, so the middleware above redirects every request. The value `1` trusts exactly one hop; with a CDN in front of nginx in front of the app, it's `2`. Build the redirect from a fixed host rather than `req.headers.host` if anything other than your proxy can set that header; the [Express guide on proxies](https://expressjs.com/en/guide/behind-proxies.html) lists the other forms.

### Laravel: trustProxies

php

```
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})
```

`'*'` is safe only when the app can't be reached except through the proxy; otherwise list the proxy's address. Older Laravel versions set the same thing in `$proxies` on the `TrustProxies` middleware. The loop usually comes from a custom middleware or `.htaccess` rule that forces HTTPS while Laravel still sees HTTP. Check that `APP_URL` starts with `https://` too, or generated links will point back to HTTP.

### FastAPI and Uvicorn: --forwarded-allow-ips

bash

```
uvicorn app.main:app --host 0.0.0.0 --port 8000 \
  --proxy-headers --forwarded-allow-ips="*"
```

Uvicorn reads proxy headers by default, but only from the addresses in `--forwarded-allow-ips`, which defaults to `127.0.0.1` as of writing. A proxy on another container or host isn't on that list, so its `X-Forwarded-Proto` is ignored and FastAPI's `HTTPSRedirectMiddleware` loops. Gunicorn has the same setting as `--forwarded-allow-ips`. Put the proxy's address there, or `*` if nothing but the proxy can reach the app.

### Flask: ProxyFix

python

```
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
```

Each number is how many proxies to trust for that header, counted from the app outwards. Spring Boot's `native` strategy below hands the job to the embedded server, which on Tomcat trusts only private-network addresses by default.

### Spring Boot: server.forward-headers-strategy

properties

```
server.forward-headers-strategy=native
```

### WordPress in Docker behind a proxy

WordPress decides it's on HTTPS by looking at `$_SERVER['HTTPS']`, which the proxy hop never sets. With the Site Address set to `https://`, every request redirects to itself. The official Docker image's generated config already contains this check; if you mount your own `wp-config.php`, add it above the line that says to stop editing:

php

```
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}
```

## nginx X-Forwarded-Proto behind a CDN

Sometimes the framework does trust the header and the loop is still there. That usually means the header says `http`, and the culprit is a second proxy. A common setup is a CDN in front, then your own nginx, then the app. When the CDN terminates TLS and connects to nginx over plain HTTP, nginx runs the line most tutorials copy and gets it wrong:

nginx

```
location / {
    proxy_pass http://app:3000;
    # The CDN reached nginx over plain HTTP, so $scheme is "http" here.
    # This overwrites the CDN's "https" and the app redirects forever.
    proxy_set_header X-Forwarded-Proto $scheme;
}
```

Pass on what the first proxy saw instead, and fall back to nginx's own scheme when the header is missing:

nginx

```
# http {} context
map $http_x_forwarded_proto $forwarded_proto {
    default $http_x_forwarded_proto;
    ""      $scheme;
}

server {
    location / {
        proxy_pass http://app:3000;
        proxy_set_header X-Forwarded-Proto $forwarded_proto;
    }
}
```

`$scheme` is the scheme of the connection nginx received. It's right when nginx terminates TLS, and also when the CDN connects to nginx over HTTPS (Cloudflare's Full and Full (strict) modes do); it's wrong only when the hop into nginx is plain HTTP. If a longer chain sends a list such as `https, http`, the map passes the whole list on, and most frameworks read only the first value. The `map` block goes in the `http` context, next to your `server` blocks. Passing the header on is only safe if nginx can be reached through the CDN alone; otherwise anyone can connect to nginx directly and claim `https`. The same `proxy_set_header` line shows up in our [502 Bad Gateway write-up](/blog/502-bad-gateway-nginx), where it's correct, because there nginx is the TLS end.

### ERR\_TOO\_MANY\_REDIRECTS with Cloudflare Flexible SSL

Cloudflare's Flexible mode is this same problem, built in. The browser talks to Cloudflare over HTTPS, and Cloudflare talks to your origin over HTTP. If the origin redirects HTTP to HTTPS, Cloudflare passes that redirect back to a browser that was already on HTTPS, and the loop starts. [Cloudflare's own troubleshooting page](https://developers.cloudflare.com/ssl/troubleshooting/too-many-redirects/) gives the fix: switch the encryption mode to Full or, better, Full (strict), which needs a valid certificate on the origin, and let the origin keep its redirect. Cloudflare does send `X-Forwarded-Proto: https` in Flexible mode, so trusting the header fixes a redirect made by the app, but not an nginx rule that redirects everything arriving on port 80.

## example.com ↔ www.example.com: two layers pick a different host

A host loop alternates between the bare domain and `www`. The usual cause is a domain redirect set up in one place (the hosting dashboard, the DNS provider's forwarding feature, a CDN rule) pointing one way, and a redirect in the app or nginx pointing the other. Each half was correct when someone added it. Together they never settle.

Pick one layer to own the canonical host and delete the rule everywhere else. If your host or CDN can redirect `www` to the apex, or the reverse, let it do that and remove the app-level redirect. A redirect at the edge is also faster, because it answers before the request reaches your app.

## /docs ↔ /docs/: trailing slash

A slash loop is the same disagreement over a single character. Next.js, with `trailingSlash` left at its default of `false`, answers `/docs/` with a 308 to `/docs`. A rewrite rule in nginx, or a static host that serves folders as `/docs/`, adds the slash back. Decide on one convention, set it in the framework, and remove the opposing rule. Static hosts differ on how they handle slashes, which is one of the details worth checking when you [move a static site between hosts](/blog/netlify-alternatives-europe).

## /login ↔ /dashboard: auth checks that disagree

This is the loop that clearing your cookies actually fixes. A typical version: middleware on `/login` sees a `session` cookie and sends the user to `/dashboard`, because they look logged in. The dashboard validates that session against the backend, finds it expired or signed with a rotated key, and sends the user back to `/login`. The cookie is still there, so the middleware sends them to the dashboard again.

Deleting the cookie breaks the cycle for one user. The fix is to make the two checks agree: either the middleware validates the session instead of only checking that the cookie exists, or the dashboard clears the invalid cookie before it redirects. If the loop only shows up for some users, or only after a deploy that changed a signing secret, this is the shape to suspect.

## Fixed on the server, still looping in the browser

You fix the config, curl prints a clean chain, and the browser still shows the error. The browser remembers things curl doesn't.

First, the redirect cache. 301 and 308 are cacheable by default under [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-15.4), and browsers may replay them without contacting the server at all. Test in a private window, or clear the site's cached data. While you debug, use 302 or 307, which browsers don't cache unless told to, and switch to a permanent code once the chain is right.

Second, HSTS. It doesn't cache a loop, but it changes what the browser sends. Once a site sends `Strict-Transport-Security`, the browser rewrites `http://` to `https://` before any request leaves; DevTools shows that as `307 Internal Redirect`, and the server never sees it. If any rule on your side redirects HTTPS back to HTTP, the browser and the server loop together while curl, which ignores HSTS, shows a different chain. Remove that rule; in Chrome you can clear a test domain's policy at `chrome://net-internals/#hsts`.

## How Runsite handles it

On [Runsite web services with automatic HTTPS on European infrastructure](/services/web-services), TLS ends at the platform edge, which takes care of most of the layers above:

* Every service gets HTTPS on its `runsite.app` subdomain and on custom domains, with Let's Encrypt certificates issued and renewed automatically.
* A request to `http://` gets a `308 Permanent Redirect` to `https://` at the edge, before it reaches your app. You don't need an HTTPS redirect in the app at all.
* Public traffic reaches your app only through the platform edge, which sets the header itself. Your app receives plain HTTP with `X-Forwarded-Proto: https`. If you keep `SECURE_SSL_REDIRECT`, `force_ssl` or a similar rule, turn on your framework's trusted-proxy setting from the sections above, or it will loop.
* When you add both the apex and `www`, the dashboard makes one of them primary and redirects the other to it with a 301 at the edge. Don't add a redirect in the opposite direction in your app.

The same applies to [static site hosting with custom domains and automatic certificates](/services/static-sites): certificates are handled for you, so the rules left to reconcile are your slash convention and any redirects in your build config.

## The short version

* ERR\_TOO\_MANY\_REDIRECTS means two layers disagree about the right URL. The browser gave up after about 20 hops.
* Print the chain with `curl -sIL --max-redirs 6 <url> | grep -iE '^(HTTP|location)'` and read what changes between hops.
* A URL that redirects to itself behind a proxy means the app can't see HTTPS. Trust `X-Forwarded-Proto` from your proxy; keep the redirect.
* Host, slash and login loops come from two rules pointing opposite ways. Keep one and delete the other.
* If curl is clean and the browser still loops, it's a cached 301/308\. Test in a private window.

Related service

## Web Services on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore Web Services](/services/web-services)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### How do I fix err\_too\_many\_redirects?

Find the two layers that disagree. Run curl -sIL --max-redirs 6 against the URL and read the Location headers. If the URL redirects to itself and the app sits behind a TLS proxy or CDN, make the app trust X-Forwarded-Proto (SECURE\_PROXY\_SSL\_HEADER in Django, trust proxy in Express, assume\_ssl in Rails) or switch Cloudflare from Flexible to Full (strict). If it alternates between www and the bare domain, or between /path and /path/, remove one of the two opposing rules.

### How do I fix the "ERR\_TOO\_MANY\_REDIRECTS" error in Chrome?

As a visitor, delete the site's cookies and cached data, or open it in an incognito window. That fixes loops caused by a stale login cookie or a cached 301\. If the error comes back in a clean window, the loop is on the server and only the site owner can fix it. As the owner, check with curl, which ignores Chrome's caches, before and after each change.

### How do I fix the "err\_too\_many\_redirects" error in WordPress?

Check that WordPress Address and Site Address both use the same scheme and host. If WordPress runs behind a proxy, load balancer or Docker setup that terminates TLS, add a check to wp-config.php that sets $\_SERVER\['HTTPS'\] to 'on' when HTTP\_X\_FORWARDED\_PROTO is https; without it WordPress thinks every request is HTTP and redirects forever. With Cloudflare, use the Full (strict) SSL mode instead of Flexible.

### How do I delete too many redirects?

You can't remove a redirect from the browser side; you remove the rule that sends it. Find which layer answers each hop (CDN, proxy, host dashboard or app) and delete the rule that points the opposite way. The browser may keep replaying a cached 301 or 308 after the server is fixed, so clear the site's data or test in a private window.

Keep reading

## Related articles

[Deployment9 min read502 Bad Gateway in Nginx After Deploy: Read the Error LogA 502 Bad Gateway page from nginx says nothing about the cause. The matching line in error.log does: connection refused, a worker that died mid-response, a keep-alive reset or an oversized header. What each one means and how to fix it after a deploy.Oct 9, 2026Read](/blog/502-bad-gateway-nginx)[Deployment11 min read504 Gateway Timeout in Nginx: Which Timeout Fired and WhyA 504 Gateway Timeout means a proxy stopped waiting for your application. Which proxy, and which of its timeouts, decides the fix: find the layer that sent it, read the upstream timed out line in error.log, line the timeouts up, and deal with the slow request instead of waiting longer for it.Oct 27, 2026Read](/blog/504-gateway-timeout-nginx)[Deployment10 min readConnection Reset by Peer: Who Sent the RST, and When"Connection reset by peer" and ECONNRESET mean the other side of the socket sent a TCP reset. The moment it arrives (on connect, after idle, mid-response, mid-upload) tells you who sent it and what to fix.Oct 31, 2026Read](/blog/connection-reset-by-peer)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/err-too-many-redirects
