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() returns False, so secure cookies or SECURE_SSL_REDIRECT misbehave.

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.

Where https gets lostBrowserhttps://example.comTLS endsat Nginx or the LBGunicorn getsplain http requestDjango buildshttp:// URLsForward X-Forwarded-Proto and set SECURE_PROXY_SSL_HEADER so Django knows it was HTTPS.
  1. In Nginx: proxy_set_header X-Forwarded-Proto $scheme;.
  2. In Django: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https").
  3. Behind a load balancer that terminates TLS, forward its X-Forwarded-Proto instead of $scheme.
  4. Make sure clients cannot reach Gunicorn directly, or they could forge the header.
  5. 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.

the scheme must survive every hopBrowserhttps://example.comLoad balancer or CDNterminates TLS, setsX-Forwarded-ProtoNginxpasses the header on, or sets$schemeGunicornplain HTTP, header in request.METADjangoSECURE_PROXY_SSL_HEADER trusts itEach hop must pass the original scheme, not its owndjango-deployment.com
With two proxies, the inner one must forward the outer one's header, not overwrite it.

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".

https everywhere Django builds a URLProxy sets X-Forwarded-ProtoNginx $scheme or LB headerSECURE_PROXY_SSL_HEADER configuredrequest.is_secure() is trueGunicorn not reachable directlyheader cannot be forgedSITE_URL for emails and tasksno request availableallauth protocol set to httpsOAuth callbacks matchRequest-based URLs need the header; request-free URLs need a settingdjango-deployment.com
Cover both kinds of URL generation and https sticks everywhere.

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-Proto is https, but traffic to your server is unencrypted. Use Full (strict).
  • Gunicorn's forwarded_allow_ips controls whether Gunicorn itself applies forwarded headers to wsgi.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-Protocol or X-Forwarded-Ssl: on; match whatever yours sends.

Next steps

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.

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.

Keep going