Troubleshooting

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.

· 15 min read · How we verify this

Key points

  • upstream sent no valid HTTP/1.0 header means the first bytes the backend sent did not start with HTTP/, a major.minor version 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 status 009.
  • 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 as HTTP/2 or in lower case, or proxy_pass aimed at a service that speaks first, such as SSH. curl talking to the backend directly accepted HTTP/2 200 and http/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 closed or Connection reset by peer, all 502.
  • proxy_next_upstream invalid_header does not retry it and error_page 502 does not catch it. With proxy_cache enabled in the location nothing is written to the error log, and with proxy_cache_valid any the 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:

bash
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 -4

The 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 backendWhat it isFix
Content-Type: ...An application writing CGI-style headers with no status lineMake it write HTTP/1.1 200 OK first, or use the gateway protocol it was written for (FastCGI, uwsgi)
\r\n then HTTP/1.1A blank line printed before the response, often debug outputRemove the output; nginx and Caddy reject it, HAProxy tolerates it
357 273 277 then HTTP/1.1A UTF-8 byte order mark, usually from a source file saved with oneSave the file without a BOM
A space, then HTTP/1.1A hand-built status line with a leading spaceFix the format string
HTTP/2 200A hand-built status line that uses the HTTP/2 version labelWrite HTTP/1.1; a version must have a minor digit
http/1.1 200 OKLower-case versionUpper case; the name is case-sensitive
SSH-2.0-..., 220 ..., ICY 200 OKA service that is not HTTP: SSH, an SMTP or FTP banner, a SHOUTcast-style streamPoint proxy_pass at the HTTP port
Nothing at all, then a close or resetA 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:

text
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:

text
/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 requestWhat the client receivedWhat it reported
curl, HTTP/1.1 (default)The 53 raw bytes, no status linecurl: (1) Received HTTP/0.9 when not allowed
curl -0 (HTTP/1.0)The sameThe same; access log proto=HTTP/1.0, status 009
curl --http2 over TLSA HEADERS frame with :status 000curl: (92) Invalid HTTP header field was received: frame type: 1, stream: 1, name: [:status], value: [000]
curl --http0.9The backend's headers as body text, Content-Type: text/plain and allWaited for the connection to close: exit 28 at the -m limit with "53 bytes received"
curl --http0.9 -H 'Connection: close'The same textExit 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, then TTP/, 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 1 to 9, then a dot, then at least one minor digit, then a space. HTTP/2 200 fails at the space after 2. HTTP/2.0 200 would pass.
  • Then exactly three status digits. The reason phrase is optional: HTTP/1.1 200 with nothing after the code returned 200.
  • Line endings may be bare LF. HTTP/1.1 200 OK\n with 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:

text
upstream sent invalid header: "this\x20..." while reading response header from upstream

That 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 atnginx error logClient gets
Application writing headers with no status lineupstream sent no valid HTTP/1.0 headerRaw bytes, status 009
Response with a leading CRLF, space or BOMupstream sent no valid HTTP/1.0 headerRaw bytes, status 009
HTTP/2 200, http/1.1 200, ICY 200 OK status lineupstream sent no valid HTTP/1.0 headerRaw bytes, status 009
OpenSSH 10.0 on port 22upstream sent no valid HTTP/1.0 header, then readv() failed (104: Connection reset by peer) while reading upstreamEmpty reply (curl error 52), status 009, 0 bytes
nginx TLS listenernoneBackend's own 400 The plain HTTP request was sent to HTTPS port
Go 1.25 HTTPS servernoneBackend's own 400, Client sent an HTTP request to an HTTPS server.
Node 24 HTTPS serverupstream prematurely closed connection502
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 listenernone200: it still serves HTTP/1.1
nginx with listen ... proxy_protocolupstream prematurely closed connection502
PostgreSQL 18, Redis 8, memcached 1.6upstream prematurely closed connection502
PHP-FPM 8.4 on port 9000recv() 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 describes invalid_header as "a server returned an empty or invalid response", which reads like this case. It is not. With upstream pair { server bad; server good; } and proxy_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 status 009. No request was retried. The bad server was not marked down either, although max_fails defaults to 1, so passive failover from health checks and upstream failover never takes the server out.
  • proxy_intercept_errors on with error_page 502. There is no 502 to intercept. The client still got the raw bytes.
  • proxy_cache. In a location with proxy_cache set, the code takes an earlier branch that sets HTTP/0.9 and returns without logging anything. The access log showed 009 with $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:

bash
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 | head

If 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 sendsnginx 1.30.6HAProxy 3.2.26Caddy 2.11.7
Headers, no status line009, raw bytes502, PH--502, malformed HTTP status code "text/plain"
Leading CRLF009, raw bytes200502, malformed HTTP response ""
UTF-8 BOM before HTTP/1.1009, raw bytes502, PH--502, malformed HTTP version "HTTP/1.1"
HTTP/2 200009, raw bytes502, PH--502, malformed HTTP version
http/1.1 200 OK009, raw bytes502, PH--502, malformed HTTP version
Leading space009, raw bytes502, PH--502, malformed HTTP status code
SSH banner009, empty502, PH--502, malformed HTTP response "SSH-2.0-..."
LF-only line endings200200200
HTTP/1.1 200 with no reason phrase200200200

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:

text
[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\n

That 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.

  1. nginx source, ngx_http_proxy_module.c (ngx_http_proxy_process_status_line, HTTP/0.9 fallback)
  2. nginx source, ngx_http_parse.c (ngx_http_parse_status_line)
  3. nginx source, ngx_http_variables.c ($status reported as 9 for HTTP/0.9)
  4. nginx ngx_http_proxy_module (proxy_cache_valid, proxy_next_upstream)
  5. nginx trac ticket 899, response headers appearing in the body
  6. nginx mailing list, Maxim Dounin, 22 April 2014 (Varnish CLI port)
  7. RFC 9112, HTTP/1.1, sections 2.2, 2.3 and 4
  8. curl man page, --http0.9
  9. HAProxy 3.2 configuration manual, section 8.5 (termination state PH)
  10. 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#