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.

TLS hardening layersCertificate chainfullchain.pemProtocolsTLS 1.2 and 1.3Ciphersmodern AEAD onlyHSTSDjango or NginxProtocol, cipher, chain, and HSTS settings together earn an A+ and real protection.
  1. Serve fullchain.pem, not cert.pem.
  2. ssl_protocols TLSv1.2 TLSv1.3; with Mozilla's "intermediate" cipher list for TLS 1.2.
  3. Enable HSTS once HTTPS works everywhere: SECURE_HSTS_SECONDS = 31536000 in Django (or the Nginx header, not both), with SECURE_HSTS_INCLUDE_SUBDOMAINS only if all subdomains are HTTPS.
  4. Enable OCSP stapling where your CA supports it, and session resumption with tickets or a cache.
  5. Redirect all HTTP to HTTPS and set Django's secure cookie settings.
  6. 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.

setting to outcomeEffectTLS 1.0 / 1.1 enabled✕grade capped at Bcert.pem without chain✕chain issues, some clients failno HSTS!A at bestHSTS 1 year✓needed for A+modern ciphers, 1.2 + 1.3✓A baselineA+ needs a clean A configuration plus a long-lived HSTS headerdjango-deployment.com
Grading criteria evolve; re-test periodically.

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.

TLS that earns an A+fullchain.pem servedcomplete chainTLS 1.2 and 1.3 onlyMozilla intermediate ciphersHSTS one year, set onceDjango or Nginx, not bothHTTP redirects to HTTPSsecure cookies in DjangoVerified with SSL Labs and testssl.shre-check after changesNginx handles the handshake; Django handles HSTS and cookiesdjango-deployment.com
Use Mozilla's SSL Configuration Generator to refresh settings over time.

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

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.

Learning path · step 11 of 15

Hardening a Django deployment

Keep going