Fix build_absolute_uri Returning http Instead of https
Fix Django request.build_absolute_uri and is_secure returning http behind an HTTPS proxy: set SECURE_PROXY_SSL_HEADER, forward X-Forwarded-Proto from Nginx or the load balancer, and fix OAuth callbacks, API pagination links, and emails.
Problem statement
Your site is served over HTTPS, but URLs Django generates start with http://:
- Django REST Framework pagination returns
"next": "http://api.example.com/items/?page=2". - OAuth providers reject the login because the callback is
http://example.com/accounts/google/login/callback/. - Links in emails, sitemaps, and RSS feeds use
http. request.is_secure()returnsFalse, so secure cookies orSECURE_SSL_REDIRECTmisbehave.
TLS ends at Nginx, a load balancer, or Cloudflare, and the connection from there to Gunicorn is plain HTTP. Django only knows the original scheme if the proxy forwards it and Django is told which header to trust.
- In Nginx:
proxy_set_header X-Forwarded-Proto $scheme;. - In Django:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"). - Behind a load balancer that terminates TLS, forward its
X-Forwarded-Protoinstead of$scheme. - Make sure clients cannot reach Gunicorn directly, or they could forge the header.
- Fix URLs built outside requests (emails, Celery tasks) with a configured base URL.
Step-by-step solution
1. Check what Django thinks
def scheme_debug(request):
return JsonResponse({
"is_secure": request.is_secure(),
"scheme": request.scheme,
"xfp": request.META.get("HTTP_X_FORWARDED_PROTO"),
"absolute": request.build_absolute_uri("/"),
})
Request it over HTTPS (and remove the view afterwards). xfp: null means the proxy is not sending the header; xfp: "https" with is_secure: false means Django is not configured to trust it.
2. Forward the scheme from Nginx
location / {
proxy_pass http://unix:/run/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 requests that arrived on the TLS server block. Note that Debian's proxy_params include does not set X-Forwarded-Proto, so add it explicitly.
3. Trust it in Django
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Now request.is_secure() is true when the header says https, and build_absolute_uri, DRF hyperlinks, reverse with request, and SECURE_SSL_REDIRECT all use https.
4. Two proxies: forward, do not overwrite
If an AWS ALB or Cloudflare terminates TLS and talks HTTP to Nginx, $scheme inside Nginx is http. Pass the outer header through:
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
proxy_set_header X-Forwarded-Proto $forwarded_proto;
Only do this if Nginx is reachable exclusively through the load balancer (security group or firewall), so the incoming header can be trusted.
5. Do not let clients reach Gunicorn directly
SECURE_PROXY_SSL_HEADER trusts a header any client can send. If Gunicorn listens on a public port, someone can send X-Forwarded-Proto: https over plain HTTP. Bind Gunicorn to a Unix socket or 127.0.0.1, or firewall its port to the proxy only. Also make the proxy overwrite the header rather than append to whatever the client sent.
6. URLs built without a request
Celery tasks and management commands have no request, so build_absolute_uri is not available. Use a setting:
SITE_URL = env("SITE_URL", default="https://example.com")
url = f"{settings.SITE_URL}{reverse('order-detail', args=[order.pk])}"
For django-allauth OAuth callbacks, also set ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https".
Verification check: request an API list endpoint over HTTPS and confirm next and previous links use https; run python manage.py check --deploy and confirm no warning about SECURE_PROXY_SSL_HEADER-related settings.
Explanation
WSGI sets wsgi.url_scheme from the connection Gunicorn received, which is HTTP from the proxy. Django cannot tell on its own that a proxy terminated TLS in front of it, and trusting forwarded headers by default would let clients lie about the scheme. SECURE_PROXY_SSL_HEADER is the explicit opt-in: you declare which header your proxy sets and which value means HTTPS, and Django uses it for is_secure() and every URL that depends on it.
Edge cases / notes
- Cloudflare Flexible SSL connects to your origin over HTTP;
X-Forwarded-Protoishttps, but traffic to your server is unencrypted. Use Full (strict). - Gunicorn's
forwarded_allow_ipscontrols whether Gunicorn itself applies forwarded headers towsgi.url_scheme; Django's setting is independent and usually enough. - Mixed content in pages is a related symptom when templates build URLs with
http; see the mixed content guide. - Header name. Some load balancers send
X-Forwarded-ProtocolorX-Forwarded-Ssl: on; match whatever yours sends.
Next steps
- Pages with blocked assets need Fix Django Mixed Content on HTTPS.
- If redirects loop instead, see Fix Django Too Many Redirects With HTTPS.
FAQ
Why does build_absolute_uri return http behind Nginx?
Gunicorn receives plain HTTP from Nginx. Django needs X-Forwarded-Proto from the proxy and SECURE_PROXY_SSL_HEADER to know it was HTTPS.
Is SECURE_PROXY_SSL_HEADER a security risk?
Only if clients can reach Django without passing through your proxy. Keep Gunicorn private.
How do I fix DRF pagination links?
They use build_absolute_uri, so the same two settings fix them.
What about URLs in Celery emails?
Tasks have no request. Build URLs from a configured SITE_URL setting.