Fix HTTPS Not Working for Django Behind Nginx

Fix a Django site where HTTPS does not load after installing a certificate: open port 443 in the firewall and cloud security group, add a listen 443 ssl block, check DNS and the certificate, and make Django aware of HTTPS.

Problem statement

http://example.com works, Certbot said "Congratulations", yet https://example.com times out, shows "connection refused", or loads a certificate warning. Sometimes HTTPS works on the server itself (curl -k https://localhost) but not from the internet.

HTTPS needs four things to line up: traffic on port 443 must reach the server, Nginx must listen on 443 with TLS, the certificate must match the name in the browser, and Django must know the original request was HTTPS. Which of those is missing is visible from the kind of failure you see.

Four things HTTPS needsPort 443 openfirewall and cloudNginx listenslisten 443 sslCert matchesname and chainDjango knowsproxy header setA timeout points at the firewall; refused points at Nginx; a warning points at the cert.
  1. Timeout: open TCP 443 in ufw and in the cloud provider's security group or firewall.
  2. Connection refused: add a server block with listen 443 ssl; and reload Nginx; check ss -ltnp | grep :443.
  3. Certificate warning: the certificate does not cover the hostname, or the chain is incomplete; use fullchain.pem.
  4. Redirect loop or CSRF failures after HTTPS works: set SECURE_PROXY_SSL_HEADER and X-Forwarded-Proto.
  5. Test from outside with curl -vI https://example.com.

Step-by-step solution

1. Classify the failure from outside the server

curl -vI https://example.com 2>&1 | head -20
  • Connection timed out → packets are dropped before reaching Nginx (firewall, security group, wrong IP in DNS).
  • Connection refused → the server answers but nothing listens on 443.
  • SSL certificate problem or no alternative certificate subject name matches → TLS works but the certificate is wrong.
  • A response, but the browser shows warnings or loops → Django or mixed content problems.

2. Open port 443 everywhere

On the server:

sudo ufw status
sudo ufw allow 'Nginx Full'      # opens 80 and 443

Then the provider's network firewall, which is separate and checked first: AWS security groups, DigitalOcean Cloud Firewalls, Hetzner Firewalls, Oracle Cloud security lists (plus its default iptables rules on the instance), and Google Cloud VPC firewall rules. Each needs an inbound rule for TCP 443 from anywhere. Forgetting this layer is the most common cause of HTTPS timeouts on a fresh server.

3. Make Nginx listen on 443

sudo ss -ltnp | grep -E ':(80|443) '

If only :80 appears, Certbot may have obtained the certificate with certonly without editing Nginx, or the edit went into a file that is not enabled. Add the HTTPS block yourself:

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;

    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;
    }
}
sudo nginx -t && sudo systemctl reload nginx
what curl -vI says from outsideSymptomLayer and fixconnection timed outfirewall or security groupdrops 443; open it in bothconnection refusedno listen 443 ssl block;add one and reloadcertificate name mismatchcert lacks this hostname;reissue with -d for each nameworks, then redirect loopDjango unaware of HTTPS;SECURE_PROXY_SSL_HEADEREach symptom belongs to a different layer, so read it before changing anythingdjango-deployment.com
Test from outside the server, because localhost bypasses the firewall layers.

4. Check the certificate

sudo certbot certificates
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -dates

The subjectAltName list must include the exact name in the browser (www.example.com and example.com are different names). Always use fullchain.pem, not cert.pem; without the intermediate certificate, some clients (curl, Android, Python requests) reject the connection even though desktop browsers may accept it.

5. Tell Django the request was HTTPS

Once TLS terminates at Nginx, Django receives plain HTTP from the proxy. Without telling it otherwise, request.is_secure() is false, so SECURE_SSL_REDIRECT loops and CSRF origin checks fail:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

Only set SECURE_PROXY_SSL_HEADER when Nginx always sets X-Forwarded-Proto itself, as in the block above; otherwise a client could forge the header.

make Django agree with the proxySECURE_PROXY_SSL_HEADERtrust X-Forwarded-Proto from NginxSECURE_SSL_REDIRECT = Trueor redirect in Nginx, not both badlySecure session and CSRF cookiesnever sent over plain HTTPCSRF_TRUSTED_ORIGINS with https://every public hostnameHeader trusted without Nginx setting itclients could forge itWithout these, HTTPS loads but logins, forms, and redirects misbehavedjango-deployment.com
Set the proxy header in Nginx and trust it in Django; one without the other breaks.

Verification check:

curl -sI http://example.com | grep -i location     # 301 to https
curl -sI https://example.com | head -1             # HTTP/2 200
python manage.py check --deploy

Also test from a phone on mobile data, which avoids local DNS caches and corporate proxies.

Explanation

Certbot's success message only means a certificate was issued; it confirms port 80 worked for the HTTP-01 challenge, not that port 443 is reachable. Cloud providers filter traffic before it reaches the VM, so ufw allow 443 alone does nothing if the security group blocks it. And because TLS ends at Nginx, the HTTPS-ness of the request is information Nginx must forward and Django must be told to trust, which is the step that turns "HTTPS loads" into "HTTPS works".

Edge cases / notes

  • IPv6. If DNS has an AAAA record but the server has no IPv6 listener or firewall rule, some clients fail while others work. Add listen [::]:443 ssl; or remove the AAAA record.
  • Cloudflare. With the proxy enabled, Cloudflare connects to your origin; set SSL mode to Full (strict) and install an origin or Let's Encrypt certificate.
  • Load balancers. If an ALB terminates TLS, Nginx receives HTTP and should pass the load balancer's X-Forwarded-Proto rather than $scheme.
  • HSTS. Add SECURE_HSTS_SECONDS only after HTTPS works on every subdomain you include.

Next steps

FAQ

Certbot succeeded, why does HTTPS time out?

Certbot validated over port 80. Port 443 is probably blocked by the cloud firewall or security group.

Do I need to restart Gunicorn after adding HTTPS?

Only after changing Django settings such as SECURE_PROXY_SSL_HEADER. Nginx needs a reload for its own changes.

Why does curl reject the certificate when the browser accepts it?

You are probably serving cert.pem without the intermediate. Use fullchain.pem.

Should Nginx or Django redirect HTTP to HTTPS?

Either works. Redirecting in Nginx is cheaper; keep SECURE_SSL_REDIRECT consistent with it.

Keep going