Why does nginx log upstream sent no valid HTTP/1.0 header?
The backend's first bytes were not an HTTP status line, so nginx passed them on as HTTP/0.9 and logged 009, not 502. What causes it, what does not, and how to find it.
Key points
upstream sent no valid HTTP/1.0 headermeans the first bytes the backend sent did not start withHTTP/, amajor.minorversion and a status code. nginx does not answer 502: it treats the reply as HTTP/0.9, passes the raw bytes to the client with no headers of its own, and logs status009.- To the client it looks like a broken server, not a gateway error: curl 7.66.0 and later fails with
Received HTTP/0.9 when not allowed, an HTTP/2 client gets:status 000(curl error 92), and with HTTP/0.9 allowed the backend's own headers show up as the body. - The usual causes are an application that writes headers without a status line, a stray byte before
HTTP/(a blank line, a space, a UTF-8 BOM), a version written asHTTP/2or in lower case, orproxy_passaimed at a service that speaks first, such as SSH. curl talking to the backend directly acceptedHTTP/2 200andhttp/1.1 200, so a direct test can pass while nginx fails. - Plain HTTP to a TLS port, h2c-only backends, Redis, PostgreSQL, PHP-FPM and PROXY protocol listeners do not produce this line in testing: they give a 400 from the backend,
prematurely closedorConnection reset by peer, all 502. proxy_next_upstream invalid_headerdoes not retry it anderror_page 502does not catch it. Withproxy_cacheenabled in the location nothing is written to the error log, and withproxy_cache_valid anythe garbage is cached and served as a HIT.
nginx logs upstream sent no valid HTTP/1.0 header while reading response header from upstream when the first bytes a backend sends back are not an HTTP status line: not HTTP/, a version with a dot, a space and three digits. It is almost always a configuration mistake on the backend side, either the wrong port or an application writing its response wrongly. The part most pages get wrong is what happens next: nginx does not return a 502. It falls back to HTTP/0.9, sends the backend's bytes to the client exactly as received, with no status line or headers of its own, and writes 009 in the access log's status field.
So the fix starts with seeing what the backend actually sends. From the nginx host or container:
printf 'GET / HTTP/1.1\r\nHost: app\r\nConnection: close\r\n\r\n' \
| nc -w 3 backend 8080 | head -c 64 | od -c | head -4The first characters must be H T T P / 1 . 1 followed by a space. Whatever is there instead names the cause:
| First bytes from the backend | What it is | Fix |
|---|---|---|
Content-Type: ... | An application writing CGI-style headers with no status line | Make it write HTTP/1.1 200 OK first, or use the gateway protocol it was written for (FastCGI, uwsgi) |
\r\n then HTTP/1.1 | A blank line printed before the response, often debug output | Remove the output; nginx and Caddy reject it, HAProxy tolerates it |
357 273 277 then HTTP/1.1 | A UTF-8 byte order mark, usually from a source file saved with one | Save the file without a BOM |
A space, then HTTP/1.1 | A hand-built status line with a leading space | Fix the format string |
HTTP/2 200 | A hand-built status line that uses the HTTP/2 version label | Write HTTP/1.1; a version must have a minor digit |
http/1.1 200 OK | Lower-case version | Upper case; the name is case-sensitive |
SSH-2.0-..., 220 ..., ICY 200 OK | A service that is not HTTP: SSH, an SMTP or FTP banner, a SHOUTcast-style stream | Point proxy_pass at the HTTP port |
| Nothing at all, then a close or reset | A different error: see the table further down |
How the cases were reproduced#
Everything below was observed on 18 October 2026 unless it is attributed to documentation or source code. The proxy was nginx 1.30.6, 1.28.3 and 1.27.5 from the official Alpine images, with identical results on all three. Backends ran in containers on one Docker network: a Python 3.13 socket server that answered each request with a fixed byte string (one port per malformed response), real OpenSSH 10.0, Redis 8, memcached 1.6, PostgreSQL 18.6, PHP-FPM 8.4, a Node 24.22.0 HTTPS server, a Go 1.25.14 HTTPS server and a Go h2c-only server, a Python HTTPS server on OpenSSL 3.5, and nginx 1.30.6 listening with TLS, with http2 on in cleartext, and with proxy_protocol. The client was curl 8.22.0. HAProxy 3.2.26 and Caddy 2.11.7 then fronted the same backends.
What nginx actually does: HTTP/0.9, not 502#
The behaviour is in ngx_http_proxy_process_status_line(). When the status line parser fails, the function logs the error and then, instead of failing the request, does this:
r->http_version = NGX_HTTP_VERSION_9;
u->state->status = NGX_HTTP_OK;
u->headers_in.connection_close = 1;
return NGX_OK;The line that would have turned it into a 502, return NGX_HTTP_UPSTREAM_INVALID_HEADER;, sits inside an #if 0 block, so it is compiled out. The request carries on as a successful HTTP/0.9 response, and HTTP/0.9 has no status line and no headers. nginx's header filter skips writing headers for requests below HTTP/1.0, so the client receives the backend's bytes and nothing else.
The access log then shows a status that looks like a typo. $status reports 9 for an HTTP/0.9 response with no status set, printed with three digits:
/nostatus status=009 up=200 bytes=53 comp="OK" proto=HTTP/1.1$upstream_status says 200, because the code sets it to NGX_HTTP_OK. $request_completion is OK. A dashboard that counts 5xx responses counts none of these.
What each client got from nginx 1.30.6 for a backend that sent Content-Type: text/plain, Content-Length: 6, a blank line and hello:
| Client request | What the client received | What it reported |
|---|---|---|
| curl, HTTP/1.1 (default) | The 53 raw bytes, no status line | curl: (1) Received HTTP/0.9 when not allowed |
curl -0 (HTTP/1.0) | The same | The same; access log proto=HTTP/1.0, status 009 |
curl --http2 over TLS | A HEADERS frame with :status 000 | curl: (92) Invalid HTTP header field was received: frame type: 1, stream: 1, name: [:status], value: [000] |
curl --http0.9 | The backend's headers as body text, Content-Type: text/plain and all | Waited for the connection to close: exit 28 at the -m limit with "53 bytes received" |
curl --http0.9 -H 'Connection: close' | The same text | Exit 0 |
The curl documentation says HTTP/0.9 "is disabled by default (added in 7.66.0)", which is where the first message comes from. The --http0.9 row is the symptom in nginx trac ticket 899, where response headers appeared in the body: Maxim Dounin's reading of the capture was that the upstream response "is not a valid HTTP/1.x response, it has a missing status line". The hang in that row happened because nginx kept the client connection open as keep-alive after the 0.9 response; the connection closed only at the client's request or at keepalive_timeout (client-side keep-alive, not the upstream pool described in keep-alive and upstream connection pooling).
What nginx accepts as a status line#
ngx_http_parse_status_line() is a byte-by-byte state machine. Its rules, read from the source and confirmed by the tests:
- The first byte must be
H, thenTTP/, in upper case. Any other first byte fails at once, including\r,\n, a space and the first byte of a BOM. - Then a major version digit
1to9, then a dot, then at least one minor digit, then a space.HTTP/2 200fails at the space after2.HTTP/2.0 200would pass. - Then exactly three status digits. The reason phrase is optional:
HTTP/1.1 200with nothing after the code returned 200. - Line endings may be bare LF.
HTTP/1.1 200 OK\nwith LF-only headers returned 200.
That matches RFC 9112 closely. Its grammar is HTTP-version = HTTP-name "/" DIGIT "." DIGIT with HTTP-name = %s"HTTP", where %s marks a case-sensitive string, so lower case and a missing minor digit are both invalid HTTP/1.1. It allows bare LF ("a recipient MAY recognize a single LF as a line terminator"). Its tolerance for an empty line before the start line is written for servers reading a request-line, not for clients reading a status line, which is why rejecting a leading CRLF in a response is within the specification.
A problem that only nginx reveals: curl talking to the backend directly accepted both HTTP/2 200 and http/1.1 200 OK and returned 200, while the same responses through nginx gave no valid HTTP/1.0 header. When a direct curl works and nginx does not, look at the raw bytes with nc and od -c rather than trusting curl's parse. The same holds for HAProxy and Caddy, which rejected both.
If the status line is fine and a later header line is malformed, nginx logs a different error and does return a 502:
upstream sent invalid header: "this\x20..." while reading response header from upstreamThat one is a real 502 on every version tested, with $upstream_status 502.
Which mistakes produce this line, and which only look like they should#
The 502 vs 503 vs 504 page lists this error as a 502 and names plain HTTP to a TLS port and h2c-only servers as the classic causes. Testing showed neither part held, and that page now says so. Most wrong ports do not produce this line at all, because most non-HTTP servers do not reply with bytes: they close the connection or reset it. The line needs a backend that sends something that is not a status line.
proxy_pass http:// points at | nginx error log | Client gets |
|---|---|---|
| Application writing headers with no status line | upstream sent no valid HTTP/1.0 header | Raw bytes, status 009 |
| Response with a leading CRLF, space or BOM | upstream sent no valid HTTP/1.0 header | Raw bytes, status 009 |
HTTP/2 200, http/1.1 200, ICY 200 OK status line | upstream sent no valid HTTP/1.0 header | Raw bytes, status 009 |
| OpenSSH 10.0 on port 22 | upstream sent no valid HTTP/1.0 header, then readv() failed (104: Connection reset by peer) while reading upstream | Empty reply (curl error 52), status 009, 0 bytes |
| nginx TLS listener | none | Backend's own 400 The plain HTTP request was sent to HTTPS port |
| Go 1.25 HTTPS server | none | Backend's own 400, Client sent an HTTP request to an HTTPS server. |
| Node 24 HTTPS server | upstream prematurely closed connection | 502 |
| Python 3.13 HTTPS server (OpenSSL 3.5) | recv() failed (104: Connection reset by peer) | 502 |
Go h2c-only server (SetUnencryptedHTTP2) | recv() failed (104: Connection reset by peer) | 502 |
nginx with http2 on on a cleartext listener | none | 200: it still serves HTTP/1.1 |
nginx with listen ... proxy_protocol | upstream prematurely closed connection | 502 |
| PostgreSQL 18, Redis 8, memcached 1.6 | upstream prematurely closed connection | 502 |
| PHP-FPM 8.4 on port 9000 | recv() failed (104: Connection reset by peer) | 502 |
Two rows deserve a note. Redis did not answer the GET line at all; its log said Possible SECURITY ATTACK detected. It looks like somebody is sending POST or Host: commands to Redis and it dropped the connection, which is a deliberate defence against cross-protocol requests. SSH is the clearest real example of a service that speaks first: sshd sent SSH-2.0-OpenSSH_10.0 before reading anything, nginx logged the error on those bytes, then sshd read the HTTP request, replied Invalid SSH identification string and reset the connection, so the client received nothing. The same shape is on record from production: in the 2014 thread on the nginx mailing list, the explanation was "you've configured nginx to pass http to varnish CLI port", Varnish's management port.
The TLS rows are the ones the upstream TLS handshake guide covers from the other direction, proxy_pass https:// to a plain port. For an h2c backend such as a gRPC server, the problem is the upstream protocol, and the fix is in gRPC through a reverse proxy.
Retries, error pages and the cache do not see it#
Because the function returns NGX_OK, nginx never registers a failure, and three mechanisms that are designed to catch bad upstreams do nothing:
proxy_next_upstream invalid_header. The documentation describesinvalid_headeras "a server returned an empty or invalid response", which reads like this case. It is not. Withupstream pair { server bad; server good; }andproxy_next_upstream error timeout invalid_header;, eight requests in a row alternated between the two servers, and every one that reached the bad server returned the raw bytes with status009. No request was retried. The bad server was not marked down either, althoughmax_failsdefaults to 1, so passive failover from health checks and upstream failover never takes the server out.proxy_intercept_errors onwitherror_page 502. There is no 502 to intercept. The client still got the raw bytes.proxy_cache. In a location withproxy_cacheset, the code takes an earlier branch that sets HTTP/0.9 and returns without logging anything. The access log showed009with$upstream_status-and the error log stayed empty.
The cache case has one more step. proxy_cache_valid 10m; caches only 200, 301 and 302 responses, according to the documentation, and the 0.9 response was not stored: every request was a MISS. With proxy_cache_valid any 1m;, the second and third requests were HITs, served from the cache with the garbage body. So a location with any in its cache validity keeps serving a broken response after the backend is fixed, until the entry expires or is purged. Caching in reverse proxies has the rest of how validity and keys interact.
Finding it in logs, given all that, means searching the access log as well as the error log:
awk '$9 == "009"' /var/log/nginx/access.log | head # combined format: status is field 9
grep 'no valid HTTP/1.0 header' /var/log/nginx/error.log | headIf the first command finds lines and the second does not, the location has proxy_cache on.
HAProxy and Caddy return a 502 for the same bytes#
The other two proxies treat an unparseable status line as a gateway failure, which is what most people expect nginx to do.
| Backend sends | nginx 1.30.6 | HAProxy 3.2.26 | Caddy 2.11.7 |
|---|---|---|---|
| Headers, no status line | 009, raw bytes | 502, PH-- | 502, malformed HTTP status code "text/plain" |
| Leading CRLF | 009, raw bytes | 200 | 502, malformed HTTP response "" |
UTF-8 BOM before HTTP/1.1 | 009, raw bytes | 502, PH-- | 502, malformed HTTP version "HTTP/1.1" |
HTTP/2 200 | 009, raw bytes | 502, PH-- | 502, malformed HTTP version |
http/1.1 200 OK | 009, raw bytes | 502, PH-- | 502, malformed HTTP version |
| Leading space | 009, raw bytes | 502, PH-- | 502, malformed HTTP status code |
| SSH banner | 009, empty | 502, PH-- | 502, malformed HTTP response "SSH-2.0-..." |
| LF-only line endings | 200 | 200 | 200 |
HTTP/1.1 200 with no reason phrase | 200 | 200 | 200 |
HAProxy's manual defines PH as "The proxy blocked the server's response, because it was invalid, incomplete, dangerous (cache control), or matched a security filter. In any case, an HTTP 502 error is sent to the client." The useful part is what it keeps. show errors on the runtime socket, which the management guide describes as a dump of the "last known HTTP/1.x request and response errors", printed the backend's bytes with the offset where parsing failed:
[18/Oct/2026:08:44:31.919] backend b21 (#4): invalid response
...
len 36, wraps at 16336, error at position 0
...
00000 HTTP/2 200\r\n
00012 Content-Length: 3\r\n
00031 \r\n
00033 ok\nThat is the nc check above, done for you on the request that failed. It needs a stats socket at level operator or admin; HAProxy configuration for HTTP reverse proxying covers the global section where it goes. Caddy's error log quotes the first bytes in Go's message, which is usually enough to recognise the cause.
Failure modes#
Users report a download of "a text file full of headers", or a page that shows Content-Type: at the top. The backend writes headers without a status line and nginx forwards them as an HTTP/0.9 body. The access log shows 009. Fix the application's output; nothing in nginx turns it into a valid response.
Monitoring shows no 5xx, but a health checker or an API client fails with Received HTTP/0.9 when not allowed. Same cause. Add 009 to whatever alerts on upstream failures, or alert on the error log line.
HTTP/2 clients fail with a protocol error and HTTP/1.1 clients get garbage. One backend, one cause: nginx sends :status 000 on HTTP/2. Do not chase it as an HTTP/2 problem.
It started after a deploy, and only for some requests. One server in the upstream block is on the wrong port or runs a build that prints debug output first. proxy_next_upstream will not route around it; take it out of the block.
The backend was fixed and nginx still serves the broken response. The location caches with proxy_cache_valid any. Purge the entry or wait for its validity to expire.
After moving a service, proxy_pass points at an SSH or other admin port. The client gets an empty reply, nginx logs this error followed by a reset. Check the port before the application.
Where this answer stops applying#
The behaviour was tested with proxy_pass over HTTP/1.x to the upstream. fastcgi_pass, uwsgi_pass and grpc_pass have their own response parsers and their own errors; this page does not cover them. proxy_http_version 2, available from nginx 1.29.4, uses a different upstream protocol and was not tested. The HTTP/0.9 fallback was identical on 1.27.5, 1.28.3 and 1.30.6, and the current source on GitHub still has it, but the client results depend on the client: curl 7.66.0 and later refuse HTTP/0.9 by default, older curl accepts it, and browsers were not tested here. The OpenSSH, Redis and TLS server results are for the versions listed; another server on the same port may answer differently, which the nc check will show. Envoy and Traefik were not tested.
Frequently asked questions#
What does upstream sent no valid HTTP/1.0 header mean?#
It means the backend's response did not begin with a valid HTTP status line such as HTTP/1.1 200 OK. nginx then treats the response as HTTP/0.9 and passes the bytes through unchanged. The usual causes are proxy_pass aimed at a non-HTTP port or an application that writes headers without a status line.
Does nginx return 502 for upstream sent no valid HTTP/1.0 header?#
No. nginx falls back to HTTP/0.9, sends the backend's raw bytes with no headers, and logs status 009 with $upstream_status 200. The 502-returning branch is disabled in the source. HAProxy and Caddy do return 502 for the same response.
What does status 009 mean in the nginx access log?#
It is an HTTP/0.9 response with no status code. nginx's $status variable reports 9 in that case, printed as 009. On a proxied request it nearly always pairs with upstream sent no valid HTTP/1.0 header in the error log, or with nothing in the error log if the location uses proxy_cache.
Why does curl say Received HTTP/0.9 when not allowed through nginx?#
Because nginx forwarded a response with no status line, which curl reads as HTTP/0.9, and curl 7.66.0 and later refuse HTTP/0.9 unless --http0.9 is given. The fault is in the backend's response, not in curl. Run curl --http0.9 to see the raw bytes, which usually name the cause.
Will proxy_next_upstream invalid_header retry this error?#
No. Although the documentation describes invalid_header as an empty or invalid response, nginx does not treat a missing status line as a failure, so it neither retries the next server nor counts it against max_fails. Remove the faulty server from the upstream block until it is fixed.
Why does curl work against the backend but nginx logs no valid HTTP/1.0 header?#
curl is more lenient than nginx about the status line. In testing it accepted HTTP/2 200 and lower-case http/1.1 200 OK from an HTTP/1 connection, while nginx, HAProxy and Caddy all rejected them. Check the first bytes with nc and od -c; nginx needs exactly HTTP/, a major.minor version, a space and three digits.
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 source, ngx_http_proxy_module.c (ngx_http_proxy_process_status_line, HTTP/0.9 fallback)
- nginx source, ngx_http_parse.c (ngx_http_parse_status_line)
- nginx source, ngx_http_variables.c ($status reported as 9 for HTTP/0.9)
- nginx ngx_http_proxy_module (proxy_cache_valid, proxy_next_upstream)
- nginx trac ticket 899, response headers appearing in the body
- nginx mailing list, Maxim Dounin, 22 April 2014 (Varnish CLI port)
- RFC 9112, HTTP/1.1, sections 2.2, 2.3 and 4
- curl man page, --http0.9
- HAProxy 3.2 configuration manual, section 8.5 (termination state PH)
- HAProxy 3.2 management guide, show errors
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#
- 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