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
wwwto apex in Nginx, apex towwwat the CDN) - Cloudflare in Flexible SSL mode talking HTTP to an origin that redirects to HTTPS
APPEND_SLASHcombined with a proxy rule that strips trailing slashes- a login-required middleware redirecting the login page to itself
Quick answer
- Reproduce with
curl -sIL --max-redirs 5 https://www.example.com/and note which host and scheme each redirect uses. - Make the proxy send the original scheme:
proxy_set_header X-Forwarded-Proto $scheme;in Nginx (load balancers and platforms usually add it already). - Tell Django to trust it:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"). - Use one layer for the HTTP-to-HTTPS redirect, preferably the proxy, and keep
SECURE_SSL_REDIRECTconsistent with it. - Behind Cloudflare, use Full (strict) SSL mode, not Flexible.
- Align canonical host redirects so only one layer decides between
wwwand apex. - Verify that
request.is_secure()returnsTruefor 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"
Patterns:
- Same URL repeated: the scheme detection loop described above.
- Alternating URLs (
https://example.com/thenhttps://www.example.com/then back): conflicting host redirects in two layers. - Alternating
httpandhttps: 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(),
})
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;
}
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.
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_URLitself. Exempt the login, logout, and static paths. - Trailing slashes:
APPEND_SLASHredirects/aboutto/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/returns200without 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
- 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-Protoas well; Cloud Run and App Engine also set it. - Kubernetes ingress-nginx: sets
X-Forwarded-Protofor 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 = TruewithoutSECURE_PROXY_SSL_HEADERbehind a proxy. - Setting
SECURE_PROXY_SSL_HEADERwhen the app server is directly reachable, which lets clients spoof HTTPS. - Using Cloudflare Flexible mode with an origin that redirects to HTTPS.
- Configuring
wwwredirects in both the CDN and Nginx in opposite directions.
Next steps
- Configure the CDN side correctly with Deploy Django Behind Cloudflare.
- Review certificate setup in Configure HTTPS for Django with Let's Encrypt.
- If logins fail after fixing HTTPS, see Fix Django Login Not Persisting Behind HTTPS.
- For
403errors on forms, see CSRF Verification Failed in Django Production.
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.