Harden Nginx TLS for Django (SSL Labs A+)
Configure Nginx TLS for a Django site to earn an SSL Labs A+: TLS 1.2 and 1.3 only, modern ciphers, HSTS with Django settings, OCSP stapling, session settings, certificate chain, and verifying with SSL Labs and testssl.sh.
Problem statement
Certbot gives a Django site working HTTPS, but default or copied Nginx configs often allow old protocols, weak ciphers, or lack HSTS, which security reviews, enterprise customers, and SSL Labs flag. An A+ rating is not a goal in itself, but it reflects a configuration that resists downgrade attacks and protects session cookies. For Django, part of the configuration belongs in Nginx (protocols, ciphers, stapling) and part in Django (HSTS and secure cookies), and the two should agree.
- Serve
fullchain.pem, notcert.pem. ssl_protocols TLSv1.2 TLSv1.3;with Mozilla's "intermediate" cipher list for TLS 1.2.- Enable HSTS once HTTPS works everywhere:
SECURE_HSTS_SECONDS = 31536000in Django (or the Nginx header, not both), withSECURE_HSTS_INCLUDE_SUBDOMAINSonly if all subdomains are HTTPS. - Enable OCSP stapling where your CA supports it, and session resumption with tickets or a cache.
- Redirect all HTTP to HTTPS and set Django's secure cookie settings.
- Verify with SSL Labs and
testssl.sh, and re-check after changes.
Step-by-step solution
1. Nginx TLS settings
Based on Mozilla's intermediate profile:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:TLS:10m;
ssl_session_tickets off;
location / {
proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
TLS 1.3 cipher suites are not configured with ssl_ciphers; they are secure by default. Disabling TLS 1.0 and 1.1 is required for modern ratings and supported by all current browsers.
2. HSTS from Django
SECURE_HSTS_SECONDS = 31536000 # start with 300 while testing
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # only if every subdomain is HTTPS
SECURE_HSTS_PRELOAD = False # True only after deliberate preload submission
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
HSTS tells browsers to use HTTPS only for the given time; SSL Labs requires a long max-age for A+. Start with a short value, confirm nothing breaks, then raise it. Set HSTS in one place only (Django's SecurityMiddleware or Nginx's add_header Strict-Transport-Security ... always;), not both, to avoid duplicate headers.
3. OCSP stapling and certificate details
Stapling lets Nginx include the CA's revocation status in the handshake, saving clients a lookup. Let's Encrypt ended OCSP support in 2025 in favour of CRLs, so stapling does not apply to its certificates; for CAs that still provide OCSP:
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/ca-chain.pem;
resolver 1.1.1.1 9.9.9.9 valid=300s;
ECDSA certificates are smaller and faster than RSA; Certbot can issue them with --key-type ecdsa.
4. Redirect everything
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
5. Verify
Run the SSL Labs server test on your domain, and testssl.sh example.com from a terminal for detail (protocols, ciphers, vulnerabilities, header checks). python manage.py check --deploy confirms the Django side.
Verification check: SSL Labs reports A+ with no chain issues, curl -sI https://example.com | grep -i strict-transport shows one HSTS header, curl -I http://example.com returns 301 to HTTPS, and check --deploy raises no security warnings.
Explanation
TLS security depends on the protocol versions and cipher suites the server accepts (to prevent downgrade to weak cryptography), a complete certificate chain (so every client can validate it), and HSTS (so browsers never try plain HTTP after the first visit, blocking SSL-stripping attacks). Nginx controls the first two because it terminates TLS; Django's SecurityMiddleware can emit HSTS and enforces secure cookies and redirects at the application level. Configuring each in the right place gives strong, verifiable HTTPS.
Edge cases / notes
- Old clients (very old Android or Java) may fail with TLS 1.2+-only configs; Mozilla's profiles document compatibility trade-offs.
- HSTS preload is hard to undo; submit only when every subdomain is permanently HTTPS.
- Cloudflare or a load balancer terminating TLS means their TLS settings matter for visitors; configure minimum TLS versions there.
- HTTP/3 needs QUIC support in Nginx and UDP 443 open.
Next steps
- Issue certificates with Configure HTTPS With Let's Encrypt for Django.
- Add the rest of the headers with the Django Security Headers Checklist.
FAQ
How do I get an A+ on SSL Labs for a Django site?
Serve the full chain, allow only TLS 1.2 and 1.3 with modern ciphers, and send a long-lived HSTS header.
Should HSTS be set in Django or Nginx?
Either, but only in one place. Django's SECURE_HSTS_SECONDS keeps it with your other security settings.
Is TLS 1.2 still acceptable?
Yes, with modern AEAD ciphers. Disable TLS 1.0 and 1.1.
Do I need OCSP stapling with Let's Encrypt?
No. Let's Encrypt stopped providing OCSP; stapling applies only to CAs that still offer it.