Fix Too Many Redirects with Django HTTPS Behind a Proxy

Fix ERR_TOO_MANY_REDIRECTS on Django behind Nginx, a load balancer, or Cloudflare: SECURE_PROXY_SSL_HEADER, forwarded headers, and duplicate redirects.

Problem statement

After enabling HTTPS, the browser shows "This page isn't working: redirected you too many times" (ERR_TOO_MANY_REDIRECTS), and curl shows the same URL redirecting to itself:

HTTP/2 301
location: https://www.example.com/

The loop almost always involves Django's SECURE_SSL_REDIRECT = True combined with a reverse proxy that terminates TLS. The browser requests https://, the proxy decrypts it and forwards plain HTTP to Gunicorn, Django sees an HTTP request, and redirects to https://. The browser follows, and the cycle repeats.

Other loop sources:

  • two layers redirecting in opposite directions (for example www to apex in Nginx, apex to www at the CDN)
  • Cloudflare in Flexible SSL mode talking HTTP to an origin that redirects to HTTPS
  • APPEND_SLASH combined with a proxy rule that strips trailing slashes
  • a login-required middleware redirecting the login page to itself
A redirect loop behind a proxyBrowserhttps requestProxyterminates TLSDjangosees plain HTTPRedirectback to httpsSECURE_PROXY_SSL_HEADER tells Django the request was already secure.

Quick answer

  1. Reproduce with curl -sIL --max-redirs 5 https://www.example.com/ and note which host and scheme each redirect uses.
  2. Make the proxy send the original scheme: proxy_set_header X-Forwarded-Proto $scheme; in Nginx (load balancers and platforms usually add it already).
  3. Tell Django to trust it: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https").
  4. Use one layer for the HTTP-to-HTTPS redirect, preferably the proxy, and keep SECURE_SSL_REDIRECT consistent with it.
  5. Behind Cloudflare, use Full (strict) SSL mode, not Flexible.
  6. Align canonical host redirects so only one layer decides between www and apex.
  7. Verify that request.is_secure() returns True for proxied HTTPS requests.

Step-by-step solution

1. Trace the redirect chain

curl -sIL --max-redirs 6 https://www.example.com/ 2>&1 | grep -i -E "^HTTP|^location|^server"
the loop, one hop at a timeHTTP/2301location:https://www.example.com/server:nginxHTTP/2 301location: https://www.example.com/1231Redirect already on an HTTPS request.2To the same URL: the app thinks it is HTTP.3Passed through; Django issued it.A redirect from https to the same https URL is always a scheme problemdjango-deployment.com
The server header helps tell which layer issued each redirect.

Patterns:

  • Same URL repeated: the scheme detection loop described above.
  • Alternating URLs (https://example.com/ then https://www.example.com/ then back): conflicting host redirects in two layers.
  • Alternating http and https: one layer downgrades (often a proxy or CDN talking HTTP to the origin) while another upgrades.

The server header helps identify which layer issued each redirect (nginx, cloudflare, gunicorn is not usually shown, but a response without the proxy's server header often comes from the app through the proxy).

2. Check what Django thinks the scheme is

Add a temporary diagnostic view, or use the Django shell with the test client for local logic. On the running site, a minimal view is fastest:

# core/views.py (temporary)
from django.http import JsonResponse


def scheme_debug(request):
    return JsonResponse({
        "is_secure": request.is_secure(),
        "scheme": request.scheme,
        "x_forwarded_proto": request.META.get("HTTP_X_FORWARDED_PROTO"),
        "host": request.get_host(),
    })
why the loop never endsBrowser: https://TLS to NginxNginx → Gunicornplain HTTPDjango sees httpno trusted header301 to https://SECURE_SSL_REDIRECTBrowser retriessame result, foreverX-Forwarded-Proto plus SECURE_PROXY_SSL_HEADER breaks the loopdjango-deployment.com
Browsers stop after about 20 hops with ERR_TOO_MANY_REDIRECTS.

Temporarily disable SECURE_SSL_REDIRECT to reach the view, request it over HTTPS, and check the output. If x_forwarded_proto is https but is_secure is False, SECURE_PROXY_SSL_HEADER is missing. If x_forwarded_proto is empty, the proxy is not sending the header. Remove the view after debugging.

3. Send the forwarded scheme from Nginx

server {
    listen 443 ssl;
    server_name www.example.com;

    location / {
        proxy_pass http://unix:/run/myapp/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

$scheme is https for this server block. Make sure the location actually serving the request has the header; proxy_set_header directives in a location replace, rather than add to, ones defined at the server level.

If Nginx itself sits behind a load balancer that terminates TLS, Nginx receives HTTP and $scheme is http. In that case pass through the load balancer's header instead:

proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;

Only do this when Nginx is not reachable directly from the internet, otherwise clients can set the header themselves.

4. Configure Django to trust the header

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Django documents this setting with a clear warning: only set it if your proxy strips or overwrites any X-Forwarded-Proto header sent by clients. Otherwise a client can claim a plain HTTP request is secure.

Gunicorn has related settings (--forwarded-allow-ips) that control which proxies it trusts for some headers, but Django's SECURE_PROXY_SSL_HEADER reads request.META directly, and Gunicorn passes X-Forwarded-Proto through as HTTP_X_FORWARDED_PROTO.

5. Choose one place for the HTTP to HTTPS redirect

Redirecting at the proxy is simpler and cheaper:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://www.example.com$request_uri;
}
redirect in one place onlyGood choice whenCDN or load balancerit terminates TLS for youNginx port 80 block✓Nginx terminates TLS (most VPS setups)Django SECURE_SSL_REDIRECTthere is no proxy you controlTwo layers redirecting with different ideas of the scheme loopdjango-deployment.com
Django can still redirect as a backstop once scheme detection is right.

With that in place, SECURE_SSL_REDIRECT = True in Django is a second safety net that never triggers for correctly proxied requests. Keeping it on is fine once SECURE_PROXY_SSL_HEADER works. If you cannot make scheme detection reliable (for example on a platform with unusual headers), turn SECURE_SSL_REDIRECT off and rely on the proxy redirect.

6. Cloudflare and CDNs

With Cloudflare's Flexible SSL mode, Cloudflare connects to your origin over HTTP. Nginx's port 80 block redirects to HTTPS, Cloudflare fetches that over HTTP again, and the loop begins, even before Django is involved.

Flexible SSL causes the classic loopFlexible✕Cloudflare → origin over HTTP✕origin redirects to HTTPS✕Cloudflare fetches HTTP again: loopFull (strict)✓Cloudflare → origin over HTTPS✓origin certificate validated✓redirects agree on the schemeSwitch the zone to Full (strict) after installing an origin certificatedjango-deployment.com
A Cloudflare Origin CA certificate is free and valid for this.

Fix: install a certificate on the origin and set the SSL mode to Full (strict). Also check Cloudflare redirect rules and "Always Use HTTPS"; they are fine alongside an origin redirect but must not conflict with host canonicalization.

7. Host canonicalization conflicts

Pick one canonical host and one layer to enforce it:

  • Nginx redirects apex to www (as above), and
  • the CDN, platform domain settings, and Django (PREPEND_WWW) do not redirect in the opposite direction.

PREPEND_WWW = True in Django adds www. to hosts that lack it. Combined with a CDN rule that removes www, it loops. Prefer handling host redirects at the edge and leave PREPEND_WWW off.

8. Loops on specific pages

If only certain URLs loop:

  • Login page: a custom middleware that requires login may apply to LOGIN_URL itself. Exempt the login, logout, and static paths.
  • Trailing slashes: APPEND_SLASH redirects /about to /about/; a proxy rewrite that strips trailing slashes sends it back. Remove one of the two behaviors.
  • Language prefixes: i18n redirects combined with a proxy rewrite of the prefix can loop similarly.

9. Verify

curl -sI https://www.example.com/ | head -1
curl -sI http://www.example.com/ | grep -i -E "^HTTP|^location"
curl -sI https://example.com/ | grep -i -E "^HTTP|^location"
curl -sIL --max-redirs 3 http://example.com/ | grep -i -E "^HTTP|^location"

Verification check:

  • https://www.example.com/ returns 200 without a redirect
  • HTTP and apex requests reach the canonical HTTPS URL in one or two hops
  • the diagnostic view (if you used it) reports is_secure: true
  • login, admin, and form submissions work over HTTPS

Explanation

Django decides whether a request is secure from the connection it sees. Behind a TLS-terminating proxy, that connection is always plain HTTP, so Django needs a trusted signal from the proxy. X-Forwarded-Proto is that signal, and SECURE_PROXY_SSL_HEADER tells Django to use it. Redirect loops appear when layers disagree about scheme or host, and each layer "fixes" the request in a way the next layer undoes.

Edge cases / notes

Platforms with built-in TLS

Heroku, Render, Fly.io, Railway, and similar platforms terminate TLS and send X-Forwarded-Proto. The same Django setting applies; you do not configure the proxy yourself.

Multiple proxies adding headers

Some load balancers append to X-Forwarded-Proto, producing values such as https,http. Django compares the header value's first entry. If your stack produces unusual values, normalize the header in the proxy closest to Django.

HSTS already enabled

If you enabled HSTS while misconfigured, browsers insist on HTTPS for the domain until the max-age expires. Fix the loop rather than disabling HTTPS.

Testing scheme detection in automated tests

You can pin the expected behavior in tests so a settings change cannot silently reintroduce a loop:

from django.test import TestCase, override_settings


@override_settings(
    SECURE_SSL_REDIRECT=True,
    SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https"),
    ALLOWED_HOSTS=["www.example.com"],
)
class HttpsRedirectTests(TestCase):
    def test_plain_http_redirects_to_https(self):
        response = self.client.get("/", HTTP_HOST="www.example.com")
        self.assertEqual(response.status_code, 301)
        self.assertTrue(response["Location"].startswith("https://www.example.com/"))

    def test_proxied_https_is_not_redirected(self):
        response = self.client.get(
            "/", HTTP_HOST="www.example.com", HTTP_X_FORWARDED_PROTO="https"
        )
        self.assertNotEqual(response.status_code, 301)

The second test is the one that fails when SECURE_PROXY_SSL_HEADER is removed or misspelled.

Load balancer specifics

keep the original scheme across two proxiesLoad balancersets X-Forwarded-Proto: httpsNginxpasses $http_x_forwarded_proto onGunicornplain HTTP from NginxDjangotrusts the header, sees httpsNginx must forward the incoming header, not its own $schemedjango-deployment.com
Using $scheme in Nginx here reports http and restarts the loop.
  • AWS Application Load Balancer: sends X-Forwarded-Proto. If Nginx runs behind it on the instances, pass the header through as shown above rather than overwriting it with Nginx's own $scheme.
  • Google Cloud load balancers: send X-Forwarded-Proto as well; Cloud Run and App Engine also set it.
  • Kubernetes ingress-nginx: sets X-Forwarded-Proto for requests to the backend service; if another load balancer terminates TLS in front of it, configure the controller to trust forwarded headers from that load balancer.

In every case, confirm the header value Django receives with the diagnostic view before enabling redirects.

Mapping a mixed header safely in Nginx

When Nginx sits behind a trusted TLS terminator but can also be reached directly for internal health checks over HTTP, set the forwarded scheme explicitly from the incoming header with a fallback:

map $http_x_forwarded_proto $forwarded_scheme {
    default $scheme;
    https   https;
    http    http;
}
proxy_set_header X-Forwarded-Proto $forwarded_scheme;

Only use this when Nginx's public exposure is limited to the trusted load balancer, because clients reaching Nginx directly could set the header.

Common mistakes

  • Setting SECURE_SSL_REDIRECT = True without SECURE_PROXY_SSL_HEADER behind a proxy.
  • Setting SECURE_PROXY_SSL_HEADER when the app server is directly reachable, which lets clients spoof HTTPS.
  • Using Cloudflare Flexible mode with an origin that redirects to HTTPS.
  • Configuring www redirects in both the CDN and Nginx in opposite directions.

Next steps

FAQ

Why does SECURE_SSL_REDIRECT cause a redirect loop in Django?

Behind a proxy that terminates TLS, Django receives plain HTTP and keeps redirecting to HTTPS. Set SECURE_PROXY_SSL_HEADER so Django recognizes requests the proxy received over HTTPS.

Is it safe to set SECURE_PROXY_SSL_HEADER?

Yes, when your proxy always sets or overwrites X-Forwarded-Proto and clients cannot reach the app server directly. Otherwise clients could make HTTP requests look secure.

How do I fix too many redirects with Cloudflare and Django?

Install a certificate on the origin and switch Cloudflare's SSL mode to Full (strict). Flexible mode sends HTTP to the origin, which then redirects back to HTTPS.

Should the HTTPS redirect be in Nginx or Django?

Redirecting in Nginx is simpler and avoids reaching Django for plain HTTP requests. Keeping SECURE_SSL_REDIRECT on in Django as a backup is fine once scheme detection works.

Keep going