Configure HTTPS for Django with Let's Encrypt
A Django app running over plain HTTP is not safe for production. Login sessions, admin access, CSRF tokens, API traffic, and password resets can all be exposed in transit if TLS...
Problem statement
A Django app running over plain HTTP is not safe for production. Login sessions, admin access, CSRF tokens, API traffic, and password resets can all be exposed in transit if TLS is missing or misconfigured.
In most real deployments, Django is not the public TLS endpoint. A reverse proxy such as Nginx or Caddy terminates HTTPS and forwards requests to Gunicorn or Uvicorn. The deployment problem is enabling HTTPS for Django with Let’s Encrypt correctly without causing redirect loops, broken admin sessions, failed certificate renewals, or mixed-content errors.
This guide shows a practical production path for Let’s Encrypt with Nginx, plus a short note for Caddy-based deployments.
Quick answer
The recommended production path is:
- terminate TLS at the reverse proxy
- issue a Let’s Encrypt certificate with Certbot, or use Caddy’s built-in automatic TLS
- redirect HTTP to HTTPS at the proxy
- configure Django to trust the proxy’s HTTPS header
- enable secure cookies and browser HTTPS protections carefully
- verify certificate renewal and keep a rollback path
If you are using Nginx in front of Gunicorn or Uvicorn, Certbot plus Nginx is the standard setup.
Step-by-step solution
1. Confirm your production architecture before changing HTTPS
Identify where TLS should terminate
For most Django production setups:
- public internet -> Nginx or Caddy
- reverse proxy -> Gunicorn or Uvicorn
- Django app is not directly exposed on
:8000or a Unix socket
Do not try to make Django itself handle public TLS unless you have a specific reason. Keep TLS at the reverse proxy.
Verify DNS, ports, and firewall rules
Check that your domain points to the correct server:
dig example.com +short
dig www.example.com +short
nslookup example.com
Confirm ports 80 and 443 are reachable and that your app is currently working:
ss -tulpn | grep -E ':80|:443|:8000'
curl -I http://example.com
Let’s Encrypt HTTP validation normally requires port 80 unless you use a DNS challenge flow.
Snapshot current working config for rollback
Before any changes, back up the current Nginx config:
sudo cp /etc/nginx/sites-available/example.com /etc/nginx/sites-available/example.com.bak
sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
Also note service status:
systemctl status nginx
systemctl status gunicorn
Rollback note: if HTTPS changes break production, restore the previous Nginx site file, test the config with sudo nginx -t, reload Nginx, and return Django to its last known-good HTTP behavior before re-enabling redirects.
2. Install Let’s Encrypt tooling
Install Certbot for Nginx-based deployments
On Debian or Ubuntu:
sudo apt update
sudo apt install certbot python3-certbot-nginx
certbot --version
If your distribution recommends snap instead, use the supported package path for that OS. The important point is using a maintained Certbot install and confirming the binary works.
Decide between webroot and nginx plugin modes
Use the Nginx plugin when:
- your Nginx config is standard
- you want Certbot to help edit TLS config
- you want the simplest path
Use the webroot method when:
- you want full manual control of Nginx
- your proxy layout is custom
- you want Certbot to issue certs without rewriting site config
Note when Caddy removes the need for Certbot
If you use Caddy, it can obtain and renew certificates automatically. You still need Django HTTPS-aware settings such as secure cookies and proxy SSL headers. The Django-side settings in this guide still apply.
3. Issue a Let’s Encrypt certificate for your Django domain
Request a certificate with the Nginx plugin
For a standard Nginx deployment:
sudo certbot --nginx -d example.com -d www.example.com
If you only use one hostname, request only that hostname.
You can let Certbot update Nginx automatically, or use it only to obtain certificates and then manage Nginx manually.
Alternative: request a certificate with the webroot method
Create a shared ACME challenge directory:
sudo mkdir -p /var/www/certbot/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/certbot
Nginx location for ACME challenges:
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
Issue the certificate:
sudo certbot certonly --webroot -w /var/www/certbot -d example.com -d www.example.com
Verify certificate files and expiration
Expected live paths:
/etc/letsencrypt/live/example.com/fullchain.pem/etc/letsencrypt/live/example.com/privkey.pem
Check certificate details:
sudo openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates -subject -issuer
Verification check:
- correct hostname(s)
- expiry date present
- no issuance errors from Certbot
4. Configure Nginx to serve Django over HTTPS
Add the HTTPS server block
Example Nginx config for Gunicorn on a Unix socket:
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 20M;
location /static/ {
alias /srv/myapp/staticfiles/;
}
location /media/ {
alias /srv/myapp/media/;
}
location / {
proxy_pass http://unix:/run/gunicorn-myapp.sock:;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
}
}
If your app listens on localhost instead of a socket:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_redirect off;
}
Redirect HTTP to HTTPS
Use a dedicated HTTP server block:
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
This preserves the original host and request URI.
Pass the correct proxy headers to Django
The key headers are:
HostX-Forwarded-HostX-Forwarded-PortX-Forwarded-ProtoX-Forwarded-For
X-Forwarded-Proto is what allows Django to understand the original request was secure when behind the proxy.
Only set SECURE_PROXY_SSL_HEADER when Django is behind a reverse proxy you control, and ensure the proxy overwrites forwarded headers instead of passing untrusted client-supplied values through.
Keep static and media handling intact after enabling TLS
If Nginx serves static or media files today, keep those locations unchanged in the HTTPS block. Do not switch to HTTPS and accidentally remove location /static/ or location /media/, or you may break admin CSS, uploads, or app assets.
5. Update Django for secure HTTPS operation
Set secure proxy and cookie settings
In Django settings:
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
If settings come from environment variables, update your production environment and restart the app service safely.
For example, restart Gunicorn or your ASGI service after changing Django security settings so the new values are loaded.
Enable browser-side HTTPS protections
After verifying the proxy is correctly sending X-Forwarded-Proto, enable redirect enforcement:
SECURE_SSL_REDIRECT = True
Only do this when application traffic is coming through the reverse proxy. If Django is still reachable directly over plain HTTP for any path, health check, or internal route, fix that first or scope those checks carefully.
Then stage HSTS carefully:
SECURE_HSTS_SECONDS = 300
# Later increase after validation:
# SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
Do not enable preload early. Do not include subdomains unless every relevant subdomain is already HTTPS-ready.
Review allowed hosts and CSRF trusted origins
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
Verification check:
- admin login works
- form submissions succeed
- no CSRF errors after switching to HTTPS
Decide where to send HSTS headers
If Nginx fully terminates TLS, many teams prefer to send HSTS at the proxy layer instead of Django. Django HSTS settings are still valid, but choose one place deliberately and avoid conflicting behavior during rollout.
A typical Nginx header looks like this:
add_header Strict-Transport-Security "max-age=300" always;
If you manage HSTS at Nginx, keep the value staged the same way: start low, validate, then increase.
6. Reload services safely and verify HTTPS end to end
Test Nginx config before reload
sudo nginx -t
sudo systemctl reload nginx
If nginx -t fails, stop and fix the config before reload.
If you changed Django settings, restart the app service as well. For example:
sudo systemctl restart gunicorn
Adjust the service name to match your deployment.
Verify TLS and redirects
Check redirect behavior:
curl -I http://example.com
curl -Ik https://example.com
Inspect the live certificate:
openssl s_client -connect example.com:443 -servername example.com </dev/null
Verification checklist:
- HTTP returns
301or308to HTTPS - HTTPS presents the intended certificate
- hostname matches the certificate SAN
- no certificate warnings in browser
Verify Django behavior behind HTTPS
Test in a browser:
- load the site over
https:// - log in to Django admin
- inspect cookies for
Secure - confirm no mixed content warnings in dev tools
- confirm no redirect loop between Nginx and Django
If you hit a redirect loop, disable SECURE_SSL_REDIRECT temporarily and confirm SECURE_PROXY_SSL_HEADER plus Nginx X-Forwarded-Proto are aligned.
7. Configure automatic certificate renewal
Check the systemd timer or cron job installed by Certbot
Most modern installs use a systemd timer:
systemctl list-timers | grep certbot
systemctl status certbot.timer
Test renewal safely:
sudo certbot renew --dry-run
Reload the reverse proxy after renewal if needed
Nginx usually needs a reload to pick up renewed certificates. A deploy hook is a common approach:
sudo certbot renew --deploy-hook "systemctl reload nginx"
If you use the packaged certbot.timer, test this hook separately and confirm Nginx reloads cleanly with:
sudo nginx -t
Monitor renewal failures
At minimum, know where logs are and check them periodically:
journalctl -u certbot
A dry-run success plus confirmed Nginx reload covers the minimum operational check.
8. Keep a simple rollback path
If certificate issuance, redirects, or Django secure settings break production, use a short rollback sequence:
- restore the previous Nginx site file from backup
- run
sudo nginx -t - reload Nginx with
sudo systemctl reload nginx - disable
SECURE_SSL_REDIRECTif Django is looping - restart Gunicorn or Uvicorn so the previous settings are active
- verify the site responds over its last known-good path
This rollback does not fix the HTTPS issue, but it gets production back to a stable state while you correct cert paths, proxy headers, or redirect logic.
Explanation
This setup works because the reverse proxy handles all public TLS responsibilities while Django remains focused on application logic. Nginx presents the certificate, decrypts HTTPS traffic, and passes requests to Gunicorn or Uvicorn over a local socket or localhost port.
Django then needs one critical piece of trust configuration: SECURE_PROXY_SSL_HEADER. Without it, Django may think requests are plain HTTP and can mis-handle redirects, secure cookies, and CSRF behavior. That is a common cause of broken Django HTTPS deployments.
Choose the Nginx plugin when speed and simplicity matter. Choose webroot when you want tighter control over proxy config or already manage Nginx as code. If you use Caddy, certificate management becomes simpler, but Django secure settings still must be configured explicitly.
When manual HTTPS setup becomes repetitive
Once you deploy multiple Django apps, the same tasks repeat: Certbot install, ACME path setup, Nginx site generation, nginx -t, reload, and renewal checks. Those steps are good candidates for a small script or reusable deployment template. The first automation to add is usually proxy config generation plus renewal verification.
Edge cases / notes
- Do not expose Gunicorn or Uvicorn directly to the internet. Bind to localhost or a Unix socket.
- Static and media paths must still resolve after the HTTPS server block is added.
- Mixed-content errors often come from hardcoded
http://asset URLs, old frontend settings, or an incorrect CDN/storage base URL. - HSTS rollout should be staged. Start with a low value like
300, then increase after successful validation. - Private key permissions matter. Do not copy
privkey.peminto app directories or broad-access paths. - Certificate issuance can fail if DNS is wrong, port 80 is blocked, or another Nginx server block answers the hostname first.
- Temporary fallback: if HTTPS is breaking production, restore the previous Nginx config, reload Nginx, and disable
SECURE_SSL_REDIRECTuntil headers and cert paths are fixed. - Renewal failures are operational issues, not just initial setup issues. Test
certbot renew --dry-runbefore considering the deployment complete.
Internal links
For the proxy trust model and production checks, read Django Deployment Checklist for Production.
If you need the full WSGI baseline, see Deploy Django with Gunicorn and Nginx on Ubuntu.
If you are deploying an ASGI app, see Deploy Django ASGI with Uvicorn and Nginx.
If you want automatic TLS at the proxy instead of Certbot, see Deploy Django with Caddy and Automatic HTTPS.
FAQ
How do I configure Django so it knows the original request was HTTPS behind Nginx?
Set:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
And make sure Nginx sends:
proxy_set_header X-Forwarded-Proto $scheme;
Those two pieces must match in practice for HTTPS requests.
Should I enable SECURE_SSL_REDIRECT before or after the reverse proxy is configured?
After. First confirm Nginx is terminating TLS correctly and forwarding X-Forwarded-Proto. Then enable:
SECURE_SSL_REDIRECT = True
If enabled too early, Django may create redirect loops or interfere with direct HTTP health checks that should be handled at the proxy layer.
What is the safest way to add HSTS to a Django production deployment?
Start with a low value:
SECURE_HSTS_SECONDS = 300
Validate the site over HTTPS for all important flows, then increase gradually. Do not enable preload until you fully understand the long-lived browser impact.
If TLS is fully terminated at Nginx, you can also send HSTS from the proxy instead of Django.
Why does Let’s Encrypt certificate issuance fail even though my app is running?
Common causes are:
- DNS still points to the wrong server
- port 80 is blocked
- Nginx is serving a different hostname block
- ACME challenge files are not reachable
- firewall or cloud security group blocks HTTP validation
The app itself being reachable on HTTP does not guarantee the ACME challenge path is correct.
How do I renew Let’s Encrypt certificates automatically without downtime?
Use Certbot’s timer or cron-based renewal and confirm it works with:
sudo certbot renew --dry-run
If needed, reload Nginx after renewal:
sudo certbot renew --deploy-hook "systemctl reload nginx"
A reload is graceful and does not require taking the Django app offline.