Why does Caddy log "http: TLS handshake error ... no certificate available for"?
The client asked for a name Caddy holds no certificate for. Tested on Caddy 2.11.7: no SNI, unknown hosts, scanners, CDNs, http:// sites, and the fix for each.
Key points
- The line means the client's TLS hello asked for a name Caddy has no certificate for. The name is in the quotes. If it is a hostname, the client sent it as SNI. If it is an IP address, the client sent no SNI, and Caddy 2.11.7 put in the IP it was reached on.
- Caddy tries the exact name, then wildcards, then
default_sni(only when there is no SNI), thenfallback_sni. Only when all of them miss does it end the handshake with this error and a TLSinternal erroralert. Chrome shows that as ERR_SSL_PROTOCOL_ERROR. - The fix depends on the name. A typo or a missing
wwwneeds the name added to a site address. A client that sends no SNI needsdefault_sni. A CDN that sends a name you have no certificate for needsfallback_sni, which is experimental. - On Caddy 2.11.7 the line is logged at DEBUG, so you only see it with
debugon. Most of the lines on a public server are scanners hitting the bare IP. They are harmless. Turn offdebug, or addexclude http.stdlibto the default log. on_demandwithoutaskdoes not cause this error on 2.11.7. Caddy warnsYOUR SERVER MAY BE VULNERABLE TO ABUSEand issues a certificate for any name. Anaskendpoint that says no gives a different line:certificate is not allowed for server name.
Caddy logs http: TLS handshake error from <client>: no certificate available for '<name>' when a client asks for a name that Caddy has no certificate for. The name in the quotes is what the client asked for, and Caddy checks its certificates for that name, then for a wildcard, then for the default_sni and fallback_sni fallbacks. When all of them miss, it gives up and ends the handshake, so the client never gets as far as sending an HTTP request. The name tells you which case you have:
| Name in the quotes | What the client did | Fix | Browser shows |
|---|---|---|---|
Caddy's own IP, such as '10.201.14.2' | Connected by IP, so it sent no SNI | Use the hostname; for clients that cannot, set default_sni | ERR_SSL_PROTOCOL_ERROR |
A hostname you serve, with a typo or without www | Sent a name that no site address lists | Add the name to the site address | ERR_SSL_PROTOCOL_ERROR |
| Your IP, from addresses you do not know | Internet scanners trying the bare IP | Nothing to fix; turn off debug, or exclude http.stdlib | Not a browser |
| A name your CDN or load balancer sends | A front proxy passed on a name Caddy has no certificate for | Use the real name, or set fallback_sni (experimental) | Depends on the CDN |
A name whose site block is http:// only | Asked for HTTPS on a name served as plain HTTP | Serve the name over HTTPS, or use http:// in the URL | ERR_SSL_PROTOCOL_ERROR |
Each row was reproduced on Caddy 2.11.7 on 10 November 2026, and each is explained below. Two causes that people often suspect, on_demand without ask and a site on another port, did not produce this line. They are covered near the end.
What the line means: the ClientHello, SNI and Caddy's lookup#
A TLS handshake is the exchange at the start of an HTTPS connection. The client and server agree on keys, and the server proves who it is with a certificate. The client speaks first, with a message called the ClientHello, which lists what the client supports and what it wants.
SNI (Server Name Indication) is a field in the ClientHello that holds the hostname the client wants. It lets one server on one IP address hold many certificates and pick the right one before any HTTP is sent. A browser sends SNI whenever the URL has a hostname, and none when the URL is an IP address. The SNI routing guide shows how proxies read the same field to route connections.
Caddy gets its certificates through a library called CertMagic. Its handshake.go file is where this error comes from. On each handshake, CertMagic looks in its in-memory cache of certificates in this order:
- If the client sent SNI, try the exact name. Then try wildcards, swapping one label at a time for
*. Forother.testthe debug log showed tries forother.test,*.testand*.*. - If the client sent no SNI, try a certificate for the local IP address the connection arrived on. Then try the
default_sniname. - In both cases, try the
fallback_sniname. - If on-demand TLS is on, load a certificate from storage or get a new one.
- If nothing worked, return
fmt.Errorf("no certificate available for '%s'", name). That is line 423 ofhandshake.go, at v0.25.6 and on the master branch.
Go's TLS server then sends the client an internal error alert and closes the connection. Go's HTTP server writes the error to its own log, with the prefix http: TLS handshake error from and the client's address and port. Caddy sends that log to a logger named http.stdlib. This is the whole line as Caddy 2.11.7 wrote it:
{"level":"debug","ts":1794294505.441299,"logger":"http.stdlib","msg":"http: TLS handshake error from 10.201.14.3:38228: no certificate available for '10.201.14.2'"}10.201.14.3:38228 is the client and '10.201.14.2' is the name Caddy looked for. Here the client sent no SNI, so that is Caddy's own address.
Why you only see it with debug on#
Look at "level":"debug": Caddy 2.11.7 logs this line at DEBUG. The source says so in modules/caddyhttp/app.go: messages from Go's HTTP server "are low-signal and logged at DEBUG". Only a recovered panic is raised to ERROR, and the default log level is INFO. So on a stock setup the line is never written, and in a test with no debug option, three failed handshakes left no trace in the log.
If you see these lines, the debug global option is on. The global options page says it "sets the log level to DEBUG for the default logger". With it on, each failed handshake also gives two tls.handshake lines. One is no certificate matching TLS ClientHello, with server_name (the SNI as sent, which is "" when there was none) and identifier (the name Caddy looked up). That pair is the quickest way to tell a no-SNI client from a wrong name.
The empty name: '' or an IP?#
Many forum posts quote the error with empty quotes: no certificate available for ''. On Caddy 2.11.7 that never happened in testing. When the ClientHello had no SNI, the quotes held the IP address the connection came in on. The code agrees. In getNameFromClientHello, an empty server name becomes the default_sni name if one is set, and the local IP if not. So on a current Caddy, an IP in the quotes means "no SNI". Empty quotes would only appear if Caddy could not read the local address.
How the cases were reproduced#
Everything here was observed on 10 November 2026 unless it is quoted from the docs or source. Caddy 2.11.7 ran from the official caddy:2 image on Docker Engine 28.1.1, on a user-defined network, Linux host. Every site used tls internal, so Caddy's own local CA issued each certificate and no public CA was involved. Clients were curl 8.14.1 with OpenSSL 3.5.9 and headless Chromium 142.0.7444.59, in their own containers. Hostnames such as example.test were pointed at Caddy with curl's --resolve and Chromium's --host-resolver-rules. The on-demand tests used a small Python ask server.
Case 1: a client connected by IP and sent no SNI#
With one site, example.test { tls internal }, curl to https://10.201.14.2/ failed:
* TLSv1.3 (IN), TLS alert, internal error (592):
* TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal errorCaddy logged no certificate available for '10.201.14.2', and Chromium showed ERR_SSL_PROTOCOL_ERROR for the same URL. One Chromium visit wrote eight handshake error lines, because the browser retried.
In Docker, the IP in the quotes is the container's address, not your public one. With a port published as 127.0.0.1:18443:443 and curl run on the host, the log read TLS handshake error from 10.201.0.1:46620: no certificate available for '10.201.0.2'. 10.201.0.2 was the container, and 10.201.0.1 was the Docker network's gateway that the host's traffic came through.
The fix with default_sni. The global options page says default_sni "sets a default TLS ServerName for when clients do not use SNI in their ClientHello". With default_sni example.test set, the same curl to the IP completed the handshake. The debug log said fell back to default certificate, and Caddy served the example.test certificate to a client that had asked for no name at all.
But that only fixed the TLS layer, and two problems stayed:
- The certificate names the host, not the IP. curl with Caddy's root CA trusted said
SSL: no alternative certificate subject name matches target ipv4 address '10.201.14.2'. Chromium, which did not trust the local CA, showedERR_CERT_AUTHORITY_INVALID. A browser at an IP URL gets a certificate warning, not a page. - The Host header is still the IP. The request reached Caddy with
Host: 10.201.14.2. No site matched it, so Caddy answered 200 with an empty body. Adding-H "Host: example.test"returned the site.
So default_sni suits only a client that cannot send SNI, does send the right Host, and accepts a certificate for that host. For a person in a browser, the fix is to use the hostname.
The other fix: put the IP in a site address. The Automatic HTTPS page says Caddy serves IP addresses over HTTPS too. Public CAs "generally" do not issue for IPs, so Caddy uses its local CA. That suits a LAN or a test box. It is not a way to serve the public by IP. On-demand TLS without ask also issued a certificate for the IP, as shown further down.
Case 2: a hostname that no site address lists#
curl with SNI other.test, against the same single site, got the same internal error alert. Caddy logged no certificate available for 'other.test'. Chromium showed ERR_SSL_PROTOCOL_ERROR.
Caddy gets certificates only for the names in its site addresses, which the Caddy reverse proxy guide shows how to write. If the client asks for any other name, there is no certificate for it. The usual causes:
- The other half of www.
example.comis in the Caddyfile and someone visitswww.example.com, or the reverse. Write both:example.com, www.example.com { ... }, or add a redirect site for one of them. - A typo or an old name. A DNS record still points at this server for a name that left the Caddyfile.
- A name added without a reload. Caddy reads the Caddyfile only at start or on
caddy reload.
Case does not matter: SNI Example.TEST matched the example.test certificate. Case 2 is the one in the Caddy Community thread named after this error. The poster served www.theg.vg and got no certificate available for 'www.theg.vg' from an address on their own LAN. It went away without any change to the certificate. If the name in the quotes is a name you do serve, look at whether the certificate was ever obtained. Search the log for certificate obtained successfully with that identifier.
Case 3: internet scanners hitting your IP#
On a server with a public IP, most of these lines come from scanners. These are programs that connect to every address on port 443 to see what answers. They connect by IP, so they send no SNI, and Caddy logs its own IP in the quotes.
They are harmless, because the handshake fails before any HTTP is read, so no request reaches a site or a backend. There is nothing to fix, and they are not the cause of a 502 or a slow page. A 502 is a failure on the far side of Caddy, covered in why Caddy reverse_proxy returns 502. But they fill a debug log. Two ways to quiet them:
- Turn off debug. At the default INFO level the line is not written at all. Most people who see it turned
debugon to chase some other problem. - Keep debug, drop the logger. The
logglobal option takesincludeandexcludelists of logger names. This removed everyhttp.stdlibline in testing, while thetls.handshakedebug lines stayed:
{
debug
log default {
exclude http.stdlib
}
}Excluding http.stdlib hides all of Go's HTTP server messages, not just these. That includes lines such as http2: server: error reading preface, which turned up in the same tests. Undo it once the debug session is over.
Do not set default_sni just to silence scanners. It makes the handshake work, so every scanner that connects by IP gets your site's certificate, and with it the hostname you serve. In testing, a no-SNI client got the example.test certificate as soon as default_sni example.test was set.
Case 4: a CDN or load balancer sends its own SNI#
A front proxy that ends TLS and opens a new TLS connection to Caddy chooses its own SNI for that second connection. If it sends a name Caddy has no certificate for, the visitor's request fails at the CDN, and Caddy logs that name in the quotes. How the two TLS legs work is set out in TLS termination vs passthrough.
The first fix is in the CDN's origin settings: make it send a name that is in a Caddy site address.
When you cannot change what it sends, Caddy has fallback_sni. The global options page marks it "Experimental". It says the fallback "becomes the TLS ServerName in the ClientHello if the original ServerName doesn't match any certificates in the cache". The use is "very niche". It is for a CDN that passes on the visitor's ServerName "but can accept a certificate with the origin's hostname instead". It also says "Caddy must be managing a certificate for this name". CertMagic's own comment gives Cloudflare as an example of this kind of client.
With fallback_sni example.test, curl with SNI other.test completed the handshake with the example.test certificate. The same thing that happened to default_sni then happened again. The request carried Host: other.test, no site matched it, and Caddy answered 200 with an empty body. With Host: example.test the site answered. A no-SNI client got the fallback certificate as well, since CertMagic tries the fallback whether SNI is present or not.
So fallback_sni only fixes the handshake. The CDN must still send a Host header that a site block matches, and it must accept a certificate for a name other than the one it asked for. That second part depends on the CDN and was not tested here. The Apache 421 guide shows what goes wrong when SNI and Host disagree on the other side of a proxy.
Case 5: the name is served over plain HTTP only#
A site address with http:// in front, such as http://plain.test, tells Caddy to serve that name without TLS and not to get a certificate for it. If another site on the server uses HTTPS, Caddy still listens on port 443. A client that asks for https://plain.test/ then reaches port 443 with SNI plain.test. In testing it got no certificate available for 'plain.test', and Chromium showed ERR_SSL_PROTOCOL_ERROR.
The same happens with a site address ending in :80. Either add HTTPS for the name, or send people to the http:// URL. If no site uses HTTPS at all, nothing listens on 443, and the client gets connection refused instead.
What does not cause this line#
A site block on another port#
With only example.test:8443 in the Caddyfile, nothing listened on 443, so curl to port 443 failed with exit code 7, connection refused, and Caddy logged nothing. Port 8443 worked.
With other.test on 443 and example.test:8443, curl to example.test on port 443 did not fail the handshake. Caddy's certificate cache is shared across all its servers, so the example.test certificate was found and served. No route on port 443 matched the name, so the answer was 200 with an empty body. A wrong port gives you connection refused or a blank page, not this error. The exception is Case 5, a name served only on :80.
on_demand without ask#
On-demand TLS means Caddy gets a certificate during the first handshake for a name, not at start-up. The Automatic HTTPS page says it "must be both enabled and restricted to prevent abuse". The main restriction is an ask URL that Caddy calls with ?domain= to check that a name is allowed.
On Caddy 2.11.7, a Caddyfile with on_demand and no ask passed caddy validate. At start it logged a warning:
{"level":"warn","logger":"tls","msg":"YOUR SERVER MAY BE VULNERABLE TO ABUSE: on-demand TLS is enabled, but no protections are in place"}It then issued a certificate for every name it was asked for: random.test, and the bare IP 10.201.14.2 from a no-SNI client. Both requests got 200. So on-demand without ask cures this error by issuing for anyone. With a public CA, that means anyone who points a name at your IP can make Caddy request certificates for it, which is the abuse the docs warn about. Set ask.
With an ask endpoint that answered 404 for denied.test, the handshake failed, but with a different line:
http: TLS handshake error from 10.201.14.4:45520: certificate is not allowed for server name denied.test: decision func: denied.test: certificate not allowed by permission module http://zask:9000/check - non-2xx status code 404That message comes from the check before CertMagic loads or obtains a certificate. If you see it, the ask endpoint said no, so look at what it returned for that name. The global options page says any status other than 2xx cancels issuance and errors the handshake.
One more on-demand catch. on_demand inside the tls block of example.test applies only to that site's names. With that setup, SNI other.test still got no certificate available for 'other.test'. To issue for names you do not list, the on-demand site needs an address that matches them, such as https://.
Worked case: ERR_SSL_PROTOCOL_ERROR on a new server#
Take a VPS with the Caddyfile example.com { reverse_proxy app:3000 }. DNS for example.com points at the VPS, and the site loads. Now suppose a colleague opens https://203.0.113.10/, the server's IP, to check it, and Chrome shows ERR_SSL_PROTOCOL_ERROR. Walk it through.
- Turn on
debugin the global options and reload. Repeat the visit. Several lines appear, one per retry, each endingno certificate available for '203.0.113.10'. If Caddy runs in Docker, the quotes hold the container's address instead. - The quotes hold an IP, so the browser sent no SNI. That fits: the URL was an IP. Case 1.
- The matching
tls.handshakeline would read"server_name":"", which confirms it. - Caddy is working as designed. It holds a certificate for
example.comand nothing for the IP. Addingdefault_sni example.comwould only change the error to a certificate name warning. Then, once that was clicked through, the colleague would get an empty page, because theHostwould be the IP. - The answer is to use
https://example.com/. Then removedebug.
Now change one detail. Suppose the quotes read 'www.example.com'. The browser sent SNI, so this is Case 2. Change the site address to example.com, www.example.com and reload. Caddy then obtains a certificate for www, as long as DNS for it points at the server.
Where this answer stops applying#
Everything here is Caddy 2.11.7, with CertMagic v0.25.6, in the caddy:2 image on Linux. Another release may log at another level or change the lookup order, so read your own debug lines. All certificates came from tls internal. A public CA was not used, so failures to obtain a certificate from Let's Encrypt or ZeroSSL were not tested. Encrypted Client Hello (ECH), which hides the real SNI behind an outer name, was not tested either. The CDN case was simulated with curl, not run through a real CDN. Chromium was the only browser tested, so the Firefox and Safari wording is not given here. If the handshake works and the browser still errors, the fault is past TLS; the proxy debugging playbook shows how to find the hop. Finally, a server with tls connection policies, client certificates or a custom certificate selection module can fail a handshake for reasons this page does not cover.
Frequently asked questions#
What does "no certificate available for" mean in Caddy?#
The client's TLS hello asked for a name that Caddy has no certificate for, and the default_sni and fallback_sni fallbacks did not help either. The name is in the quotes. Caddy ended the handshake, so no HTTP request reached any site.
Why does the error show my server's IP address?#
The client connected by IP and sent no SNI. Caddy 2.11.7 then looks up the local IP the connection arrived on, and puts that IP in the message. In Docker it is the container's address. Use the hostname in the URL, or set default_sni for a client that cannot send SNI.
Why does Caddy show ERR_SSL_PROTOCOL_ERROR in Chrome?#
When Caddy has no certificate for the name, it ends the handshake with a TLS internal error alert. Chromium 142 showed that as ERR_SSL_PROTOCOL_ERROR in every case on this page: no SNI, an unknown name, and a name served over plain HTTP only. curl shows it as tlsv1 alert internal error.
How do I stop Caddy logging TLS handshake errors from scanners?#
On Caddy 2.11.7 these lines are DEBUG level, so turning off the debug global option removes them. To keep debug on, add log default { exclude http.stdlib } to the global options. Do not use default_sni for this, because it hands your certificate to every scanner.
What is the difference between default_sni and fallback_sni?#
default_sni is used only when the client sends no SNI. fallback_sni is tried when the name the client sent, or the lack of one, matches no certificate. It is marked experimental and meant for CDNs. Both fix only the handshake. A site still has to match the request's Host header, or Caddy returns an empty 200.
Does on_demand TLS without ask cause this error?#
No, not on Caddy 2.11.7. It warns YOUR SERVER MAY BE VULNERABLE TO ABUSE at start, then issues a certificate for any name, even a bare IP. An ask endpoint that refuses a name gives a different error, certificate is not allowed for server name.
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.
- CertMagic source, handshake.go (getCertificateFromCache, getCertDuringHandshake, getNameFromClientHello) at v0.25.6, the version Caddy 2.11.7 uses
- CertMagic source, handshake.go, master branch
- Caddy v2.11.7 source, modules/caddyhttp/app.go (http.stdlib logger, serverErrorLogger)
- Caddy documentation, Caddyfile global options (default_sni, fallback_sni, debug, log, on_demand_tls)
- Caddy documentation, Automatic HTTPS (IP addresses, on-demand TLS)
- Caddy Community, "TLS handshake error / no certificate available for"
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
- Apache AH01102 error reading status line: fix the 502
- ERR_TUNNEL_CONNECTION_FAILED: what the proxy answered
- 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
- Envoy reset reason: connection termination, every cause
- HAProxy 503 No server is available: read the log line
- HAProxy health check failed: every reason decoded
- 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 413 Request Entity Too Large: find the limit
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx 502 after Docker restart: fix the stale IP
- nginx in Docker: 502 on proxy_pass localhost, fixed
- nginx drops headers with underscores: why, and the fix
- nginx 400 plain HTTP request sent to HTTPS port: fix
- nginx proxy_cache always MISS: every cause, tested
- Nginx Proxy Manager 502 Bad Gateway: find your cause
- nginx connect() failed to [::1] with localhost: the fix
- nginx (13: Permission denied) to upstream: SELinux fix
- nginx SSL handshaking to upstream errors: fix each one
- nginx cuts off large downloads: find which of 5 causes
- nginx upstream sent no valid HTTP/1.0 header: the fix
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- POST becomes GET after a redirect: proxies and clients
- A proxy debugging playbook
- Squid TCP_DENIED/403 on every request: the http_access fix
- Traefik upload fails after 60 seconds: the readTimeout fix