Troubleshooting
HTTPS, TLS & DNS

Fix ERR_SSL_PROTOCOL_ERROR on a Django Site

Fix the browser error ERR_SSL_PROTOCOL_ERROR or SSL_ERROR_RX_RECORD_TOO_LONG for a Django site: find which layer answers HTTPS with plain HTTP, fix listen 443 ssl in Nginx, port mappings, proxy upstream schemes, and cached HSTS for development servers.

Problem statement

Chrome shows "This site can’t provide a secure connection" with ERR_SSL_PROTOCOL_ERROR. Firefox says SSL_ERROR_RX_RECORD_TOO_LONG. curl reports:

curl: (35) error:0A00010B:SSL routines::wrong version number

All three mean the browser started a TLS handshake and got something that is not TLS back, almost always a plain HTTP response from a server listening on port 443 without TLS, or from a port that should never have been reached over HTTPS. The fix is to find which layer answered and make it speak TLS, or to stop sending HTTPS to it.

Why the handshake failsBrowser startsTLS on :443Server repliesplain HTTPBytes not TLSprotocol errorFix that layerenable sslThe browser expected TLS and received plain text from the server on that port.
  1. Test what the port speaks: curl -v http://example.com:443/. If that returns HTML, port 443 serves plain HTTP.
  2. In Nginx, the listener needs ssl: listen 443 ssl; plus certificate directives.
  3. Check port mappings: Docker "443:80" maps HTTPS traffic to a plain HTTP port.
  4. If only localhost:8000 fails, the browser is forcing HTTPS on the development server; see the runserver fix.
  5. Behind Cloudflare or a load balancer, make sure the origin port and scheme match the proxy's SSL mode.

Step-by-step solution

1. Ask the port which protocol it speaks

curl -sv http://example.com:443/ -o /dev/null 2>&1 | grep -E "^< HTTP|Connected"
openssl s_client -connect example.com:443 -servername example.com </dev/null | head -5

If plain http:// on port 443 returns an HTTP status, the listener has no TLS. If openssl s_client shows a certificate chain, TLS works on the server and the problem is elsewhere, such as a proxy in between or a client issue.

2. Fix the Nginx listener

A common mistake is listen 443; without ssl, or certificate lines in a different server block:

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://app; }
}

Also check for a second server block, perhaps a default site, that has listen 443 without ssl. Nginx applies the ssl flag per address and port, and conflicting definitions give confusing results. sudo nginx -T | grep -n "listen" shows every listener.

which layer spoke plain HTTPWhat you findCause and fixhttp:// on :443 returns HTMLlisten 443 withoutssl; add ssl and certsDocker maps 443:80HTTPS sent to plainport; map to TLS portonly localhost:8000browser HSTS on runserver; use httpproxy_pass https:// to Gunicornupstream has no TLS; use http://Find the first hop that answers TLS with plain textdjango-deployment.com
Each hop either terminates TLS or forwards plain HTTP; mixing them causes this error.

3. Check container port mappings

In Docker Compose, ports: ["443:80"] publishes host port 443 but sends traffic to the container's plain HTTP port 80. Browsers then get HTTP on 443. Map 443 to the port where the proxy container terminates TLS:

proxy:
  image: caddy:2
  ports: ["80:80", "443:443"]

Gunicorn itself should not be exposed on 443 unless it is configured with certificates, which is unusual.

4. Check proxy upstream schemes

A proxy that forwards with proxy_pass https://127.0.0.1:8000 makes Nginx start TLS to Gunicorn. Nginx logs an upstream SSL error and the browser may see a 502 rather than a protocol error, but load balancers configured with an HTTPS target group pointing at a plain HTTP port produce the same mismatch one hop later. Use plain HTTP to app servers on a private network and terminate TLS at the edge.

5. Development servers

If the error appears only for localhost:8000, the browser upgraded to HTTPS because of cached HSTS or a redirect. Django's runserver speaks only HTTP. Follow Fix "You’re Accessing the Development Server Over HTTPS".

a typical request pathBrowserhttps:// on 443Nginx or load balancerlisten 443 ssl, terminatesGunicorn or Uvicornplain http on 8000DjangoSECURE_PROXY_SSL_HEADERTLS ends once, at the edge; every hop after it is plain HTTPdjango-deployment.com
If any hop expects TLS from a hop that sends plain text, the handshake fails.

Explanation

A TLS connection begins with the client sending a ClientHello. A plain HTTP server reading those bytes either ignores them or replies with an HTTP error page in plain text. The browser then reads HTTP/1.1 400 Bad Request where it expected a TLS record header, sees an impossible record length or version, and aborts with ERR_SSL_PROTOCOL_ERROR or SSL_ERROR_RX_RECORD_TOO_LONG. The error therefore always points to a listener that speaks the wrong protocol for the URL.

Edge cases / notes

  • Antivirus and proxies. Some endpoint security tools intercept TLS and break it for certain sites; test from another network to rule them out.
  • Old TLS versions. A server configured with only very old or very new protocol versions can give a handshake failure instead; that is a different error, usually ERR_SSL_VERSION_OR_CIPHER_MISMATCH.
  • IPv6. A missing listen [::]:443 ssl; makes IPv6 clients hit a different server block or nothing.

Next steps

FAQ

What does SSL_ERROR_RX_RECORD_TOO_LONG mean in Firefox?

The same as Chrome's ERR_SSL_PROTOCOL_ERROR: the server answered a TLS handshake with plain HTTP.

Is my certificate wrong?

Usually not. Certificate problems produce certificate errors. This error means TLS was not spoken at all.

Can Gunicorn serve HTTPS directly?

It can with --certfile and --keyfile, but terminating TLS in Nginx, Caddy, or a load balancer is the common and simpler setup.

Why does it work with http:// but not https://?

The server listens for HTTP only. Add a TLS listener with certificates.

Keep going