Why does nginx return 400 The plain HTTP request was sent to HTTPS port?
A client spoke plain HTTP to a listen ... ssl port. Where it comes from, the error_page 497 line that fixes it, and the three ways that line silently fails.
Key points
- The page means a client sent an unencrypted HTTP request to a port nginx has as
listen ... ssl. nginx recognises it from the first byte (a TLS handshake starts with0x16), reads the whole request in plain text, and answers with its private code 497, which goes out to the client and into the access log as400. - The fix for a port that people really do reach with
http://is one line at server level:error_page 497 =301 https://$host$request_uri;. Without=301nginx sends a 302. Use=308if the port takes POSTs: curl followed the 301 with a GET. - Three ways that line does nothing, all tested on nginx 1.27.5, 1.28.3 and 1.30.6: putting it inside a
location, putting it athttplevel with any othererror_pagein the server, and expectingerror_page 400or anif ($https = "")redirect to catch the request. All three still give the 400. - On a non-standard port behind Docker or a load balancer,
$server_portis the port nginx listens on, not the one the client used, sohttps://$host:$server_portredirects to the wrong port.$http_hostkeeps the client's port; a hard-coded published port is safer still. - The default error log never shows it: nginx logs
client sent plain HTTP request to HTTPS portatinfo. Apache and Caddy answer with their own 400, HAProxy closes the connection, and Traefik serves plain HTTP on a TLS entry point if a non-TLS router matches.
nginx returns 400 Bad Request with the line The plain HTTP request was sent to HTTPS port when a client opens a connection to a port configured as listen ... ssl and sends an ordinary, unencrypted HTTP request instead of starting a TLS handshake. Something is sending http:// to a TLS port: a person typing http://example.com:8443, a load balancer or Kubernetes Service forwarding plain HTTP to port 443, a Docker mapping such as -p 80:443, or an old configuration with ssl on;. Fix the sender if you can. If real visitors will keep arriving that way, add one line at server level so nginx redirects them instead of failing:
server {
listen 443 ssl;
server_name example.com;
error_page 497 =301 https://$host$request_uri;
# ...
}The line has three quiet ways of not working, and the variables in it go wrong on non-standard ports, so the details below matter more than the line itself.
| Where the plain HTTP comes from | How to tell | Fix |
|---|---|---|
A visitor or script using http://host:443 or http://host:8443 | Requests from many client IPs; browsers with an HSTS entry never send it | error_page 497 redirect at server level |
| A load balancer or Ingress forwarding HTTP to the TLS port | Every request from the balancer's addresses gets 400 | Point it at the plain port, or set its backend protocol to HTTPS |
A container port mapping such as -p 80:443 or 8080:443 | http://host/ fails while https://host:80/ works | Map the plain port to the plain listener |
ssl on; in a server that also listens on 80 (nginx before 1.25.1) | Port 80 itself returns this 400 | Remove ssl on;, add ssl to the 443 listen only |
Another proxy using proxy_pass http:// to this port | The upstream's 400 page passes through the front proxy | proxy_pass https:// on the front proxy |
How the cases were reproduced#
Everything below was observed on 19 October 2026 unless it is attributed to documentation or source code. nginx 1.30.6, 1.28.3 and 1.27.5 ran from the official Alpine images with identical results on all three; nginx 1.24 was used only for the ssl on; test. Apache httpd 2.4.69 with mod_ssl, HAProxy 3.2.26, Caddy 2.11.7 and Traefik 3.7.15 were then given the same treatment. Each server listened with TLS on one Docker network, and the client was curl 8.22.0, with nc for hand-written requests.
How nginx knows the request was plain HTTP#
nginx does not wait for a failed handshake. In ngx_http_ssl_handshake(), the first read on a new connection to an ssl listener is a one-byte recv() with MSG_PEEK. If that byte is 0x16, the record type of a TLS handshake, or has the high bit set (an SSLv2-style hello), nginx starts TLS. Anything else, including the G of GET or the P of POST, sends the connection down the plain HTTP path with a marker that it arrived on an ssl port.
nginx then reads and parses the request completely, headers and all, picks the server block from the Host header, and only then, in ngx_http_process_request(), notices that the connection has no TLS session. It logs the event and finalizes the request with NGX_HTTP_TO_HTTPS, which is 497:
[info] 30#30: *2 client sent plain HTTP request to HTTPS port while reading client request headers, client: 10.201.12.5, server: , request: "GET /path?q=1 HTTP/1.1", host: "ngx130:4432"Two consequences follow from that order. Because the full request is parsed first, the redirect can use $request_uri and $host; the ssl module documentation says so in as many words: "The redirection happens after the request is fully parsed and the variables, such as $request_uri, $uri, $args and others, are available." And because the request is finalized before any location is chosen or any rewrite runs, nothing inside a location ever sees it.
Status 497 is one of nginx's private codes, alongside 495, 496 and the 499 covered in what nginx 499 means. It never reaches the wire. ngx_http_special_response.c rewrites the response status to 400 for 497 (as it does for 494, 495 and 496), and turns off keep-alive. The page body is fixed text: a title of 400 The plain HTTP request was sent to HTTPS port and a heading of 400 Bad Request. The access log records 400 too:
443 host=ngx130 status=400 https="" uri=/path?q=1 bytes=407The error_page 497 redirect, and what each form returns#
The ssl module documents 497 as "a regular request has been sent to the HTTPS port", usable "for redirects using the error_page directive". The core module adds the rule that matters for the status code: with a URL as the target, "by default, the response code 302 is returned to the client", and it "can only be changed to one of the redirect status codes (301, 302, 303, 307, and 308)".
What each form returned on all three nginx versions, for GET /path?q=1 sent in plain text:
| Configuration in the TLS server | Response | Location |
|---|---|---|
No error_page 497 | 400 | none |
error_page 497 https://$host$request_uri; | 302 | https://ngx130/path?q=1 |
error_page 497 =301 https://$host$request_uri; | 301 | https://ngx130/path?q=1 |
error_page 497 =308 https://$http_host$request_uri; | 308 | https://ngx130:4433/path?q=1 |
error_page 497 =301 https://$host:$server_port$request_uri; | 301 | https://ngx130:4434/path?q=1 |
location / { if ($https = "") { return 301 ...; } } | 400 | none |
error_page 400 /e400.html; | 400 (nginx's own page) | none |
301 or 308#
The choice matters only for requests with a body. RFC 9110 says that for both 301 and 302 "the user agent MAY change the request method from POST to GET for the subsequent request", and that with 308 "the request method will not change when automatically following the redirect". curl showed exactly that: curl -L -d a=1 against the =301 server sent POST, received the 301, and followed with GET; against the =308 server it followed with POST. A plain-HTTP API client hitting a 301 loses its body without an error. Use =308 for anything that takes POST, PUT or PATCH, and =301 when the only callers are browsers loading pages.
$host, $http_host or $server_port#
The variable choice decides where the client is sent, and the right one depends on whether the TLS port is the standard 443.
nginx defines $host as the host from the request line, else the Host header, else the matching server name; in every test it came without the port. $http_host is the Host header exactly as sent, port included. $server_port is the "port of the server which accepted a request", that is, the port nginx itself listens on. The test sent Host: app.test:8443 to a TLS server listening on 4432 to 4434 inside the container, which is what a Docker mapping of 8443:4432 or a load balancer translating ports looks like from nginx's side:
| Redirect target | Location returned | Correct for the client? |
|---|---|---|
https://$host$request_uri | https://app.test/x | No: drops 8443, sends the client to 443 |
https://$host:$server_port$request_uri | https://app.test:4434/x | No: the container's port, not the published one |
https://$http_host$request_uri | https://app.test:8443/x | Yes |
HTTP/1.0 request with no Host, either form, no server_name | https:///x | No: an empty host |
The rule that follows: on 443, use https://$host$request_uri. On any other port, prefer the published port written out, such as https://$host:8443$request_uri, because that is independent of the client and of the container. $http_host gets the port right for normal browsers, but it is whatever the client sent, and with no Host header it is empty. Set server_name in the server either way, because $host falls back to it and that turns the https:///x case into a working URL.
Three ways error_page 497 silently does nothing#
Each of these was configured, passed nginx -t, and still returned the 400 on 1.27.5, 1.28.3 and 1.30.6.
It is inside a location. A server with location / { error_page 497 =301 https://$host$request_uri; ... } returned 400. The request is finalized before location matching, so the server-level configuration is the one in force. The core module lists location as a valid context, which is why nginx -t accepts it.
It is at http level and the server has any other error_page. With error_page 497 =301 ... in the http block, a server with no error_page of its own redirected correctly, but a server that only added error_page 404 /404.html; returned 400. error_page follows the replace-not-merge rule the core module states: it is inherited "if and only if there are no error_page directives defined on the current level". The same rule governs proxy_set_header and add_header, described in nginx as a reverse proxy. Put the 497 line in every TLS server, or in an included snippet those servers share with their other error_page lines.
It catches 400, or uses if ($https = ""). The internal code is 497 until the response is written, so error_page 400 does not match it, and a custom 400 page will not replace the 497 page. The if redirect, a common forum suggestion, sits in a location that is never reached; $https is only a useful test in a server with separate plain and ssl listen lines.
Where the plain HTTP actually comes from#
A load balancer or Ingress pointed at the TLS port#
A balancer that terminates TLS and then opens a plain connection to nginx's 443 turns every request into this 400, and from the client's side the page comes back over a perfectly good HTTPS connection to the balancer. The fix is on the balancer, not in nginx: either send plain HTTP to a plain port, or keep re-encryption and tell the balancer its backend speaks HTTPS. The three modes, and what each costs, are compared in TLS termination, passthrough and re-encryption. Do not "fix" it with an error_page 497 redirect: the balancer hands the redirect back to a client that is already on HTTPS, the client asks for the same URL again, and gets the same redirect, a loop. Once the balancer sends to a plain port, the application needs X-Forwarded-Proto to know the original request was HTTPS, or it produces the other well-known loop described in X-Real-IP, X-Forwarded-Proto, Host and Port.
The same thing happens one hop further in when another nginx uses proxy_pass http:// to a TLS backend. The front nginx logs no error and passes the backend's 400 page through, which is listed with the other wrong-port cases in why nginx logs upstream sent no valid HTTP/1.0 header. The reverse mistake, proxy_pass https:// to a plain port, fails in nginx's TLS client instead, with wrong version number, covered in the upstream TLS handshake guide.
ssl on, from older configurations#
Before listen ... ssl existed, TLS was turned on per server with ssl on;, and that applies to every listen in the server. A block with listen 80; listen 443; ssl on; therefore makes port 80 a TLS port too. On nginx 1.24 the configuration loaded with only the "ssl" directive is deprecated, use the "listen ... ssl" directive instead as a warning, and then http://host/ on port 80 returned 400 The plain HTTP request was sent to HTTPS port while https://host:80/ returned the page. The ssl module documentation records that the directive "was made obsolete in version 1.15.0 and was removed in version 1.25.1"; on 1.27.5 the same file failed with unknown directive "ssl". So this cause exists only on servers still running 1.24 or older, which is where most of the decade-old answers to this error were written.
The reverse case: TLS sent to a plain port#
A browser or client that speaks TLS to a port nginx serves without ssl gets no page at all. curl failed with curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number, the same message and the same reason as in whether HTTPS_PROXY should start with http:// or https://: the client read nginx's plain 400 response as a TLS record. nginx logged client sent invalid method while reading client request line at info, with the ClientHello's binary bytes in the request: field, and a 400 in the access log. If the access log shows 400s whose request is a line of binary starting \x16\x03\x01, something is sending HTTPS to a plain port.
Apache, HAProxy, Caddy and Traefik do it differently#
The same plain GET /x?q=1 with Host: app.test, sent to each server's TLS port:
| Server | Response to plain HTTP | Logged as | Same-port redirect |
|---|---|---|---|
| nginx 1.27.5 to 1.30.6 | 400, The plain HTTP request was sent to HTTPS port | info line; access log 400 | error_page 497, keeps path and query |
| Apache 2.4.69 | 400, Reason: You're speaking plain HTTP to an SSL-enabled server port. | AH01996 at info; access log "GET / HTTP/1.0" 400 | ErrorDocument 400 https://... gives a 302 to a fixed URL, path lost |
| HAProxy 3.2.26 | Connection closed, no bytes (curl error 52) | SSL handshake failure (error:0A00009C:SSL routines::http request) | Not on an ssl bind |
| Caddy 2.11.7 | HTTP/1.0 400 Bad Request, Client sent an HTTP request to an HTTPS server. for GET, HEAD, POST, PUT, OPTIONS; closed for DELETE and PATCH | debug only | No |
| Traefik 3.7.15 | 404 if only TLS routers exist; served normally if a non-TLS router matches | Access log entry | redirectScheme on a non-TLS router: 301 for GET, 308 for POST |
Apache logs every such request as GET / HTTP/1.0. mod_ssl detects the plain request inside the TLS filter and substitutes a fixed request line, HTTP_ON_HTTPS_PORT, defined in ssl_engine_io.c as "GET / HTTP/1.0", so the error page can be generated. The real method and path are gone by the time anything runs. So an ErrorDocument 400 redirect can only send the client to a fixed URL (a 302 to https://app.test/ for /x?q=1 in the test), and it redirects every other 400 the virtual host produces as well.
Caddy's answer comes from Go's net/http, and depends on the method. When the handshake fails, Go checks whether the first five bytes are one of GET /, HEAD , POST , PUT / or OPTIO, and only then writes its 400 before closing. DELETE and PATCH do not match, so those clients got an empty reply, logged as tls: first record does not look like a TLS handshake. Any Go server using ListenAndServeTLS behaves the same.
Traefik entry points are not TLS-only. The Traefik documentation says a tls section "tells Traefik that the router should process only TLS requests and ignore non-TLS traffic". The entry point itself accepts both. With only a TLS router on websecure, the plain request reached no router and got Traefik's 404 page not found, which looks nothing like a protocol error. Adding a second router on the same entry point without tls, carrying a redirectScheme middleware with permanent: true, turned it into a same-port redirect: 301 for GET and 308 for POST, both to https://app.test/x. That router dropped the client's port from Host: app.test:8443, so on a non-standard port set the middleware's port option. The same mechanism means a non-TLS router attached to a TLS entry point by mistake serves the application in plain text on port 443. Traefik routers, services and middlewares covers how routers attach to entry points.
Failure modes#
Monitoring reports 400s on the HTTPS virtual host, the error log is empty. A health checker or script is using http:// against the TLS port. Point it at https://.
Every request through the load balancer returns 400, direct HTTPS to nginx works. The balancer sends plain HTTP to 443. Change its backend protocol or target port. A 497 redirect will not help.
The redirect works on most servers but not on one. That server has its own error_page line and no longer inherits the http-level 497 redirect. Add the 497 line to it.
The redirect sends users to the wrong port. $host dropped a non-standard port, or $server_port gave the container's internal port. Write the published port in the target.
A form or API POST over http:// arrives as a GET with no body. The redirect is =301 or the default 302. Use =308.
After upgrading nginx, it will not start: unknown directive "ssl". The configuration still has ssl on;, removed in 1.25.1. Delete it and add ssl to the TLS listen lines only; that also stops port 80 returning this 400.
Where this answer stops applying#
The nginx behaviour was identical on 1.27.5, 1.28.3 and 1.30.6 and matches the current source on GitHub; the ssl on; result is from 1.24 only. Results were for HTTP/1.x requests. An HTTP/2 prior-knowledge client to a TLS port does not produce 497: nginx rejected PRI * HTTP/2.0 as client sent invalid request with a generic 400, and curl reported (16) Remote peer returned unexpected data while we expected SETTINGS frame. HTTP/3 runs over QUIC on UDP and cannot reach this code. The HSTS behaviour is from RFC 6797, not from browser testing. The Apache, HAProxy, Caddy and Traefik rows are for the versions listed with near-default configuration. Envoy was not tested.
Frequently asked questions#
What does The plain HTTP request was sent to HTTPS port mean?#
It means a client sent an unencrypted HTTP request to a port nginx serves with TLS (listen ... ssl). nginx detects it from the first byte, which is not a TLS handshake, and answers with a 400 page. Either the client used http:// instead of https://, or a load balancer, port mapping or proxy is sending plain HTTP to the TLS port.
How do I redirect HTTP to HTTPS on the same port in nginx?#
Add error_page 497 =301 https://$host$request_uri; to the TLS server block, at server level. On a non-standard port, write the port into the target, for example https://$host:8443$request_uri, and use =308 instead of =301 if the port receives POST requests.
Why is my error_page 497 not working?#
Usually because it is in a location, which nginx never reaches for this request, or because it is in the http block and the server defines another error_page, which cancels the inherited one. error_page 400 and an if ($https = "") redirect do not catch it either. Put the 497 line directly in each TLS server block.
Why does nginx not log anything for this error?#
It does, but at the info level, and the default error_log level is error. The line is client sent plain HTTP request to HTTPS port while reading client request headers. The access log shows the request as an ordinary 400.
Why do I get this error on port 80?#
Port 80 has been made a TLS port, almost always by ssl on; in a server block that also has listen 80, on nginx 1.24 or older. ssl on; enables TLS for every listen in the server. Remove it and add ssl only to the 443 listen; nginx 1.25.1 and later reject ssl on; outright.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx ngx_http_ssl_module (error processing, 497; ssl directive removed in 1.25.1)
- nginx ngx_http_core_module (error_page, $host, $server_port, $https)
- nginx source, ngx_http_request.c (ngx_http_ssl_handshake, ngx_http_process_request)
- nginx source, ngx_http_special_response.c (497 page, status rewritten to 400)
- Apache httpd source, mod_ssl ssl_engine_io.c (HTTP_ON_HTTPS_PORT)
- Go source, net/http server.go (tlsRecordHeaderLooksLikeHTTP), release-branch.go1.25
- Traefik TLS router configuration
- Traefik RedirectScheme middleware
- RFC 9110, HTTP Semantics, sections 15.4.2 to 15.4.9
- RFC 6797, HTTP Strict Transport Security, section 8.3
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in troubleshooting proxies#
- 502 vs 503 vs 504: reading proxy errors correctly
- Cntlm vs Px: which one for an NTLM or Kerberos proxy?
- Corporate proxies and developer tooling
- Access-Control-Allow-Origin multiple values: the fix
- curl through a proxy
- Header and body size limits at the proxy
- HTTPS_PROXY: http:// or https://? Tested in 12 clients
- netsh winhttp vs Windows proxy settings: who reads which?
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx 502 after Docker restart: fix the stale IP
- nginx connect() failed to [::1] with localhost: the fix
- nginx cuts off large downloads: find which of 5 causes
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- A proxy debugging playbook