You’ve set up your SSL certificate, configured Nginx or Caddy to listen on port 443, and changed your Django settings to use SECURE_SSL_REDIRECT = True. You open your browser, hit your domain, and see the glorious lock icon.
Then you open the browser console and see a wall of red:
Mixed Content: The page at
https://yourdomain.com/was loaded over HTTPS, but requested an insecure stylesheethttp://yourdomain.com/static/css/main.css. This request has been blocked; the content must be served over HTTPS.
Your images are broken, your styles are missing, and your JavaScript forms are refusing to submit. You double-check your code, and every internal URL uses relative paths or explicitly points to https. Why is Django still generating http:// links?
The culprit isn’t your Django code. It’s a communication breakdown between your reverse proxy and your WSGI/ASGI server (Gunicorn, Uvicorn), and it comes down to a single HTTP header you’re likely ignoring: X-Forwarded-Proto.
The Root Cause: The Proxy Blindspot
In a standard production environment, your architecture looks something like this:
[Browser] --- (HTTPS) ---> [Reverse Proxy (Nginx/Caddy)] --- (HTTP) ---> [Gunicorn/Django]
- The user talks to Nginx over a secure, encrypted HTTPS connection.
- Nginx terminates the SSL encryption and passes the raw request downstream to Gunicorn/Django over a local HTTP connection.
Because the connection between Nginx and Gunicorn is just plain HTTP, Django believes the user accessed the site via HTTP.
When Django functions like request.is_secure(), absolute_uri(), or the {% static %} template tag execute, they check the underlying connection protocol. Seeing plain HTTP, Django builds absolute URLs using the http:// schema. Your browser, rightly protective of security boundaries, blocks these insecure assets, triggering the dreaded Mixed Content error.
The Fix, Step 1: Tell Your Proxy to Speak Up
To fix this, your reverse proxy needs to explicitly tell Django what the original protocol was before SSL termination happened. We do this using the X-Forwarded-Proto header.
If you use Nginx:
Add proxy_set_header X-Forwarded-Proto $scheme; inside your location block.
server {
listen 443 ssl;
server_name yourdomain.com;
# ... SSL configurations ...
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# The critical line:
proxy_set_header X-Forwarded-Proto $scheme;
}
}
If you use Caddy:
Caddy actually handles this proxy header automatically when using the reverse_proxy directive, but ensuring your block looks clean is always good practice:
yourdomain.com {
reverse_proxy 127.0.0.1:8000
}
The Fix, Step 2: Teach Django to Listen
Passing the header from Nginx isn't enough. By default, Django ignores X-Forwarded-Proto for security reasons. If it trusted this header blindly out of the box, a malicious user could spoof it over public HTTP to trick Django into thinking a connection was secure.
You must explicitly tell Django to trust this header only when it comes from your trusted proxy. Open your settings.py file and add the following configuration:
# settings.py
# Trust the X-Forwarded-Proto header passed by your proxy
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
Note on Syntax: Django automatically converts incoming HTTP headers to WSGI environment variables. It upper-cases the name, replaces dashes with underscores, and prepends
HTTP_. That is whyX-Forwarded-ProtobecomesHTTP_X_FORWARDED_PROTOin your configuration tuple.
What this line does:
It tells Django: "If an incoming request contains a header named X-Forwarded-Proto with the value https, treat request.is_secure() as True."
Step 3: Verify the Solution
Once you have updated your proxy configuration and your settings.py, restart both services:
sudo systemctl restart nginx
sudo systemctl restart gunicorn # or your specific process manager
Clear your browser cache (or open an Incognito window) and reload your application. Inspect the console. The Mixed Content errors should be completely gone, and all your dynamic asset links will correctly output https://.
Security Warning: Protect Your Headers
Never set SECURE_PROXY_SSL_HEADER without ensuring that your reverse proxy is the only entry point to your application server.
If your Gunicorn/Uvicorn port (e.g., 8000) is exposed directly to the public internet, an attacker could send a raw HTTP request with X-Forwarded-Proto: https manually attached. Django would mistake it for a secure connection, rendering your security middleware useless against things like session hijacking or insecure cookie transmission.
Ensure your firewall rules blocks public access to your application server ports, leaving only your proxy ports (80 and 443) open to the world.