Why does nginx log SSL_do_handshake() failed while SSL handshaking to upstream?
Each upstream TLS error nginx logs, from alert 112 to verify error 20, mapped to its cause and fix. Includes the Let's Encrypt chain that breaks proxy_ssl_verify_depth 1.
Key points
SSL_do_handshake() failed ... while SSL handshaking to upstreammeans nginx, acting as a TLS client, could not finish the handshake with an HTTPS backend. The alert number in the line names the cause: 112 is missing SNI, 70 is a protocol version mismatch, 40 is a generic refusal (often SNI or a client certificate), andwrong version numbermeans the port speaks plain HTTP.- The most common fix is
proxy_ssl_server_name on;. It defaults tooff, so nginx sends no SNI, and SNI-routed backends such as CDNs and multi-site servers refuse the handshake or serve a default certificate. With anupstreamblock, also setproxy_ssl_nameandproxy_set_header Hostto the real hostname, because both default to the block's name. - With
proxy_ssl_verify on, the defaultproxy_ssl_verify_depth 1allows one intermediate CA. Since 13 May 2026, default Let's Encrypt certificates chain through a cross-signed Gen Y root, which is two intermediates, so nginx.org, letsencrypt.org and www.microsoft.com all failed with(20:unable to get local issuer certificate)until the depth was 2. - Locations that
proxy_passto the same host and port share one cached TLS session. In testing, a location whose CA list could not verify the backend returned 200 after another location verified it, and a location with a client certificate was rejected after one without.proxy_ssl_session_reuse off, or a separateupstreamblock, keeps them apart.
nginx logs SSL_do_handshake() failed ... while SSL handshaking to upstream when it connects to an HTTPS backend as a TLS client and the backend refuses the handshake. The text inside the parentheses says why, and the alert number is the part to read: 112 (unrecognized name) means nginx sent no SNI or the wrong name, 70 (protocol version) means the two sides share no TLS version, 40 (handshake failure) is a generic refusal that is usually missing SNI or a missing client certificate, and wrong version number means the port answered in plain HTTP. The client sees a 502. The fix for the most common case is one line, because proxy_ssl_server_name defaults to off:
location / {
proxy_ssl_server_name on;
proxy_pass https://api.example.com;
}Verification errors are a separate family. They read upstream SSL certificate verify error or upstream SSL certificate does not match, appear only after proxy_ssl_verify on, and are covered after the handshake errors.
| Error log line (nginx 1.30, OpenSSL 3.5) | Cause | Fix |
|---|---|---|
tlsv1 unrecognized name:SSL alert number 112 | No SNI sent, or SNI is an upstream block name the backend does not know | proxy_ssl_server_name on; plus proxy_ssl_name with the real hostname |
ssl/tls alert handshake failure:SSL alert number 40 (older reports: sslv3 alert handshake failure) | Backend refused without saying why: missing SNI on some servers, required client certificate over TLS 1.2, no shared cipher | Try SNI first, then proxy_ssl_certificate, then compare ciphers |
tlsv1 alert protocol version:SSL alert number 70, or unsupported protocol after widening proxy_ssl_protocols | Backend speaks only TLS 1.1 or older; nginx sends TLS 1.2 and 1.3 by default | Upgrade the backend, or widen proxy_ssl_protocols and lower the cipher security level |
wrong version number | proxy_pass https:// to a port that speaks plain HTTP | proxy_pass http://, or the backend's real TLS port |
SSL_read() failed ... tlsv13 alert certificate required:SSL alert number 116 while reading response header | Backend requires a client certificate, TLS 1.3 | proxy_ssl_certificate and proxy_ssl_certificate_key |
Upstream returns 400 The plain HTTP request was sent to HTTPS port | proxy_pass http:// to a TLS port | proxy_pass https:// |
upstream SSL certificate verify error: (20:unable to get local issuer certificate) | CA not in proxy_ssl_trusted_certificate, or the chain has more intermediates than proxy_ssl_verify_depth allows | Check the chain depth before adding CAs; proxy_ssl_verify_depth 2; |
upstream SSL certificate verify error: (21:unable to verify the first certificate) | Backend sends its leaf certificate without the intermediate | Fix the backend's certificate file to include the chain |
upstream SSL certificate does not match "NAME" | The name nginx checks is not in the certificate; often NAME is an upstream block name | proxy_ssl_name real.host.name; |
[emerg] no proxy_ssl_trusted_certificate for proxy_ssl_verify at startup | proxy_ssl_verify on with no CA file | proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; or your internal CA |
How the cases were reproduced#
Everything on this page was observed on 16 October 2026 unless it is attributed to documentation or source code. The proxy was nginx 1.30.6 from the nginx:1.30-alpine image (built with OpenSSL 3.5.8, running with 3.5.9), with nginx 1.27.5 and 1.28.3 from their Alpine images for the session tests. The backend was a second nginx 1.30.6 with one TLS listener per case: one with ssl_reject_handshake on in its default server so that only SNI app.test was served, one TLS 1.1 only, one requiring a client certificate, one serving its leaf without its intermediate, and one plain HTTP port. Certificates came from a lab root CA, with chains of one and two intermediates. HAProxy 3.2.26 (both as a client and as a backend with strict-sni and verify required) and Caddy 2.11.7 ran against the same backends. Eight public HTTPS sites were then used to test SNI and chain depth against real certificates.
Missing SNI: alert 112 and alert 40#
proxy_ssl_server_name is documented with the default off. With it off, nginx's ClientHello carries no server name, and a backend that chooses its certificate by SNI has two options: refuse, or answer with whatever its default certificate is. The nginx backend with ssl_reject_handshake on refused with alert 112; so did HAProxy with strict-sni; so did nginx.org itself:
SSL_do_handshake() failed (SSL: error:0A000458:SSL routines::tlsv1 unrecognized name:SSL alert number 112)
while SSL handshaking to upstream, ... upstream: "https://3.136.61.53:443/"The other six public sites tested (Cloudflare, Google, GitHub's API, S3, Fastly, Microsoft) completed the handshake without SNI and served something. That is the trap: a backend that answers without SNI sends its default certificate, which works while proxy_ssl_verify is off and fails the name check the day verification is turned on. TLS termination, passthrough and re-encryption covers why unverified re-encryption is the wrong place to stop. Which certificate a server picks for which name is in SNI-based routing.
Two cases still send no SNI after proxy_ssl_server_name on:
- An IP address in
proxy_pass. RFC 6066 says "Literal IPv4 and IPv6 addresses are not permitted in HostName", and nginx'sngx_http_upstream_ssl_name()carries the comment "as per RFC 6066, literal IPv4 and IPv6 addresses are not permitted" and skips SNI when the name parses as an address.proxy_pass https://10.201.10.2:443/with SNI on still got alert 112. Write the hostname, or setproxy_ssl_name backend.example.com;, which supplies the name while the address stays literal. - The name is not the one the backend serves. That is the
upstreamblock case below.
The name nginx sends is the proxy_pass host, including upstream block names#
proxy_ssl_name defaults to $proxy_host; the documentation says "By default, the host part of the proxy_pass URL is used." That name is used twice: sent as SNI when proxy_ssl_server_name is on, and checked against the certificate when proxy_ssl_verify is on. With proxy_pass https://backend/; pointing at upstream backend { server app.test:443; }, the host part is backend, so:
- with SNI on, the backend received SNI
backendand refused with alert 112, the same line as no SNI at all; - with verification on, nginx logged
upstream SSL certificate does not match "backend4443", naming the upstream block.
proxy_ssl_name app.test; fixed the handshake, and then the request returned a 404 from the backend. The Host header was still backend, because proxy_set_header Host defaults to $proxy_host too, and the backend routed the request by Host to its default server. An HTTPS upstream behind an upstream block needs all three:
upstream api_pool {
server 10.0.0.11:443;
server 10.0.0.12:443;
keepalive 16;
}
location / {
proxy_pass https://api_pool;
proxy_ssl_server_name on;
proxy_ssl_name api.example.com; # SNI and certificate check
proxy_set_header Host api.example.com; # what the backend routes on
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_ssl_verify_depth 2;
}How proxy_pass builds the rest of the upstream request is in nginx proxy_pass and the trailing slash.
Protocol and port mismatches: alert 70 and wrong version number#
proxy_ssl_protocols defaults to TLSv1.2 TLSv1.3, and the documentation notes that "The TLSv1.3 parameter is used by default since 1.23.4." A backend limited to TLS 1.0 and 1.1 answered with:
SSL_do_handshake() failed (SSL: error:0A00042E:SSL routines::tlsv1 alert protocol version:SSL alert number 70)Allowing the old versions is two changes, not one. proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2; alone only changes the error, on 1.27.5, 1.28.3 and 1.30.6 alike, to SSL_do_handshake() failed (SSL: error:0A000102:SSL routines::unsupported protocol): OpenSSL 3 refuses those versions at its default security level. The test needed proxy_ssl_ciphers DEFAULT:@SECLEVEL=0; as well before the backend answered 200. Treat that as a bridge to a backend upgrade, scoped to the one location that needs it.
wrong version number is a different thing: nginx sent a ClientHello and got back bytes that are not TLS at all, which is what an HTTP server's error response looks like to a TLS parser. The configuration says https:// and the port is plain HTTP. The reverse mistake, proxy_pass http:// to a TLS port, produces no nginx error at all: the backend answers its own 400 The plain HTTP request was sent to HTTPS port, and nginx passes it through.
Client certificates: alert 116 is not logged as a handshake error#
A backend that requires mutual TLS fails in a way that depends on the TLS version, and only one of the two looks like a handshake error. Against HAProxy with verify required on its bind line:
| Backend offers | What nginx logged with no proxy_ssl_certificate | Client sees |
|---|---|---|
| TLS 1.3 | SSL_read() failed (SSL: error:0A00045C:SSL routines::tlsv13 alert certificate required:SSL alert number 116) while reading response header from upstream | 502 |
TLS 1.2 only (ssl-max-ver TLSv1.2) | SSL_do_handshake() failed (... ssl/tls alert handshake failure:SSL alert number 40) while SSL handshaking to upstream | 502 |
nginx backend, ssl_verify_client on | Nothing: the handshake completed | 400 No required SSL certificate was sent, from the backend |
In TLS 1.3 the client finishes its side of the handshake before the server has checked the certificate, so the server's rejection arrives as the first thing nginx reads, and the log says while reading response header. Searching the error log for SSL handshaking misses it. nginx as a backend does not reject in the handshake at all; it completes it and answers 400. The fix in all three cases is proxy_ssl_certificate and proxy_ssl_certificate_key, and mutual TLS through a proxy covers the rest of that setup.
Turning on verification: errors 20, 21 and the depth limit#
proxy_ssl_verify defaults to off, so none of the verification errors appear until someone turns it on. Turning it on without a CA file does not fail at request time: nginx refuses to start with [emerg] no proxy_ssl_trusted_certificate for proxy_ssl_verify. With a CA file, three errors come up:
(21:unable to verify the first certificate): the backend sent its leaf certificate without the intermediate that signed it. The fix is on the backend, in its certificate file, not in nginx's trust store.upstream SSL certificate does not match "NAME": covered above. IfNAMEis an upstream block or an IP address, setproxy_ssl_name.(20:unable to get local issuer certificate): nginx could not build a path to a trusted root. The obvious reading is a missing CA. On nginx it is just as often the depth limit.
proxy_ssl_verify_depth defaults to 1. The nginx documentation calls it only "the verification depth", but nginx passes the number to OpenSSL, and OpenSSL's documentation is precise: it limits "the certificate chain to num intermediate CA certificates. A maximal depth chain can have up to num+2 certificates, since neither the end-entity certificate nor the trust-anchor certificate count against the -verify_depth limit." So the default allows exactly one intermediate. The lab chains confirmed it: leaf, one intermediate and root verified at depth 1; leaf, two intermediates and root failed at depth 1 with error 20, not with certificate chain too long, and verified at depth 2.
That used to be a corner case. It is not any more. Let's Encrypt documents its default chains for the Gen Y hierarchy as EE ← YE1/YE2 ← Root YE ← ISRG Root X2 ← ISRG Root X1 for ECDSA certificates and EE ← YR1/YR2 ← Root YR ← ISRG Root X1 for RSA, where Root YE and Root YR are cross-signed intermediates because, in Let's Encrypt's words, they "are not yet included in Root Program Trust Stores". Its announcement says the classic profile, which most subscribers use, "will switch to the new Generation Y hierarchy on May 13 2026." Against the trust bundle in the nginx:1.30-alpine image, the public sites tested gave:
| Site | Issuer | Intermediates in the verified path | proxy_ssl_verify_depth 1 (default) | Depth 2 |
|---|---|---|---|---|
| nginx.org | Let's Encrypt YE1 | 2 (YE1, Root YE cross-signed by ISRG Root X2) | 502, error 20 | 200 |
| letsencrypt.org | Let's Encrypt YE2 | 2 (YE2, Root YE) | 502, error 20 | 200 |
| www.microsoft.com | Microsoft TLS G2 RSA CA OCSP 04 | 2 (issuing CA, Microsoft TLS RSA Root G2 cross-signed by DigiCert Global Root G2) | 502, error 20 | 200 |
| www.google.com | Google Trust Services WR2 | 1 | 200 | 200 |
| www.cloudflare.com | Google Trust Services WE1 | 1 | 200 | 200 |
| api.github.com | Sectigo DV E36 | 1 | 200 | 200 |
| www.fastly.com | Certainly Intermediate R1 | 1 | 200 | 200 |
| s3.amazonaws.com | Amazon RSA 2048 M04 | 1 | 307 (backend redirect) | 307 |
The intermediate counts are from openssl s_client -verify_return_error -CAfile /etc/ssl/certs/ca-certificates.crt in the same Alpine environment. An RSA certificate on the Gen Y chain was not among the sites tested, but by the same OpenSSL rule its documented path (YR1, Root YR) also has two intermediates.
To check a backend before changing nginx, count the depth= lines that are neither the leaf (depth=0) nor the root (the highest number):
echo | openssl s_client -connect api.example.com:443 -servername api.example.com \
-CAfile /etc/ssl/certs/ca-certificates.crt -verify_return_error 2>&1 | grep -E '^depth=|Verify return code'Run it inside the nginx container, so the trust bundle is the one nginx reads.
One location's TLS session leaks into another#
nginx caches the TLS session for an upstream server and resumes it on the next connection; proxy_ssl_session_reuse defaults to on. The source shows where the session is kept: on the upstream peer (peer->ssl_session in ngx_http_upstream_round_robin.c), not on the location. And nginx turns every proxy_pass host into an upstream, reusing an existing one when the host and port match, so two locations that both say proxy_pass https://app.test:4443/ share one peer and one cached session, even when their proxy_ssl_* settings differ.
The test used two locations to the same backend. Both leaking rows reproduced identically on nginx 1.27.5, 1.28.3 and 1.30.6:
| First request | Second request | Second location's settings | Result of second request |
|---|---|---|---|
| none | location B | proxy_ssl_verify on, CA file that does not contain the backend's root | 502, error 20 (correct) |
| location A, verifies with the lab root | location B | Same as above | 200: the backend log showed the session resumed ($ssl_session_reused = r) |
| none | location M | proxy_ssl_certificate set, backend requires client certificates | 200 |
| location L, no client certificate | location M | Same as above | 400 No required SSL certificate was sent: session resumed without a certificate |
A resumed session skips certificate exchange, so it carries the outcome of the handshake that created it. Location B accepted a backend it could not verify, and location M lost its client certificate, depending only on which location had connected first. A session created with verification off was not accepted by a verifying location: that request still failed with error 20.
Two fixes worked. proxy_ssl_session_reuse off; in the location with the stricter settings made it do a full handshake every time (B returned 502, M returned 200, regardless of order, on 1.27.5 and 1.30.6). A separate upstream block for that location, with proxy_ssl_name set to the real hostname, gave it its own peer and its own session on 1.30.6, and kept session resumption.
HAProxy and Caddy do the same job with different defaults#
Most of the nginx errors above come from two defaults: no SNI and no verification. The other two common proxies chose differently:
| Behaviour | nginx 1.30 | HAProxy 3.2.26 | Caddy 2.11.7 |
|---|---|---|---|
| SNI sent by default | No (proxy_ssl_server_name off) | No; sni expression required. Without it the SNI-only backend gave 503, state SC, 3 retries | Yes, the upstream address' host |
| Certificate verified by default | No (proxy_ssl_verify off) | Yes: verify defaults to required, and a server ... ssl line with no ca-file is a fatal startup error | Yes, against system roots: lab backend gave x509: certificate signed by unknown authority |
| Name checked against the certificate | proxy_ssl_name ($proxy_host) | The sni value, else verifyhost; with neither, names are ignored (an IP server line with ca-file returned 200 for a certificate naming app.test) | tls_server_name, default the upstream host |
| Chain depth limit | 1 intermediate | None hit: the two-intermediate lab chain verified | None hit: the two-intermediate lab chain verified |
Host sent to an HTTPS upstream | $proxy_host | The client's Host, unchanged | The upstream host, automatically since 2.11.0 |
The HAProxy error is worth knowing word for word, because it is the first thing anyone sees after upgrading an old configuration: verify is enabled by default but no CA file specified. If you're running on a LAN where you're certain to trust the server's certificate, please set an explicit 'verify none' statement on the 'server' line. The manual adds a warning nginx users should take too: "When no name is found, the certificate's names are ignored. For this reason, without SNI it's important to use "verifyhost"." HAProxy configuration for HTTP reverse proxying covers server lines in general. Caddy's documentation says the Host rewrite is automatic since v2.11.0, "so it is no longer necessary to explicitly override the Host header when proxying to HTTPS"; the backend's log confirmed SNI and Host were both app.test. The rest of the Caddy side is in Caddy as a reverse proxy.
Failure modes#
502 after moving a backend behind a CDN or a shared load balancer. It used to be the only site on its address; now the address serves many names and needs SNI. Alert 112 or 40. proxy_ssl_server_name on;.
502 after turning on proxy_ssl_verify, with does not match. Without SNI the backend served its default certificate, or the name checked is an upstream block name. Turn SNI on and set proxy_ssl_name.
502 with error 20, and curl from the same container works. curl has no depth limit of 1. Count the intermediates as above and set proxy_ssl_verify_depth 2;.
Handshake fixed, now 404 or the wrong site. SNI is right but Host is still the upstream block name. proxy_set_header Host with the real hostname.
Intermittent 400 No required SSL certificate was sent, or a verification failure that comes and goes. Two locations share a backend address with different TLS settings and are trading sessions. proxy_ssl_session_reuse off; in the stricter one, or give it its own upstream block.
The TLS error appears once, then only no live upstreams. A failed handshake is a failed attempt, so when the backend hostname resolves to several addresses, each one is marked down for fail_timeout after its first failure. The public site tests showed exactly this: one SSL_do_handshake() or verify error line, then no live upstreams for the following requests. Search the error log for the first line, not the most frequent one. Why nginx tries ::1 when proxy_pass says localhost explains that lockout, and 502 vs 503 vs 504 lists the other 502 signatures.
Where this answer stops applying#
The error strings are from OpenSSL 3.5 and 3.3 builds of nginx on Alpine. Other OpenSSL versions word some reasons differently (the alert 40 text in particular), and BoringSSL or LibreSSL builds differ again; match on the alert number. The public site results are from one network on one day, against the CA bundle in nginx:1.30-alpine. A trust store that contains Root YE or Root YR directly shortens the Let's Encrypt path to one intermediate, and certificate authorities change their chains, so rerun the openssl s_client check against your own backends. The session sharing was tested with implicit upstreams created by proxy_pass and with HTTP/1.x upstream connections; upstream blocks with a zone keep the session in shared memory, which was not tested here. Envoy and Traefik were not tested.
Frequently asked questions#
What does SSL_do_handshake() failed while SSL handshaking to upstream mean?#
It means nginx connected to an HTTPS backend as a TLS client and the handshake did not complete, so the client got a 502. The alert number in the line gives the cause: 112 is a missing or unknown SNI name, 70 is no shared TLS version, 40 is a generic refusal, and wrong version number means the port is plain HTTP.
How do I fix SSL alert number 112 in nginx?#
Add proxy_ssl_server_name on; to the location. nginx sends no SNI by default, and a backend that serves several names refuses a handshake without one. If proxy_pass names an upstream block or an IP address, also set proxy_ssl_name to the hostname the backend serves.
Why does nginx say unable to get local issuer certificate when the CA is in the bundle?#
Usually because the chain has two intermediate certificates and proxy_ssl_verify_depth defaults to 1, which allows one. Default Let's Encrypt certificates have chained through a cross-signed Gen Y root since 13 May 2026, which is two intermediates. Set proxy_ssl_verify_depth 2;.
Does nginx verify the upstream certificate by default?#
No. proxy_ssl_verify defaults to off, so nginx encrypts to any backend certificate without checking it. Turn it on with proxy_ssl_trusted_certificate pointing at a CA file, or nginx refuses to start with no proxy_ssl_trusted_certificate for proxy_ssl_verify.
What does wrong version number mean in an nginx upstream error?#
It means nginx spoke TLS to a port that answered in plain HTTP. The proxy_pass URL says https:// but the backend port is not a TLS listener. Use http://, or the backend's TLS port.
Should I turn off proxy_ssl_session_reuse?#
Only in a location that shares a backend address with another location using different TLS settings, such as a different CA file or a client certificate. Those locations share one cached session in nginx, and a resumed session keeps the result of the handshake that created it. Elsewhere, leave it on: it saves a full handshake per new upstream connection.
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_proxy_module (proxy_ssl_server_name, proxy_ssl_name, proxy_ssl_verify, proxy_ssl_verify_depth, proxy_ssl_protocols, proxy_ssl_session_reuse)
- nginx source, ngx_http_upstream.c (ngx_http_upstream_ssl_name, implicit upstream lookup)
- nginx source, ngx_http_upstream_round_robin.c (TLS session stored per peer)
- RFC 6066, TLS Extensions, section 3 (Server Name Indication)
- OpenSSL 3.5 verification options (-verify_depth)
- Let's Encrypt, Chains of Trust (Gen Y roots and default chains)
- Let's Encrypt, Upcoming changes to Let's Encrypt certificates (classic profile switch date)
- HAProxy 3.2 configuration manual (verify, verifyhost, sni, ssl-server-verify)
- Caddy reverse_proxy directive (tls_server_name, Host for HTTPS upstreams)
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
- 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