1. The problem I’m having:
When intentionally (or by annoying scraper bots) causing a 400 Bad request error, by:
- requesting a http page on the https port , causing a
Client sent an HTTP request to an HTTPS server.(curl -vL http://localhost:443) - requesting a page without a host header, causing a
400 Bad Request: missing required Host header(curl -vL -H "Host:" http://localhost)
while having a handle_errors directive, the handle_errors directive seems to never pick up/detect the error 400, and nothing inside it is executed (no respond, no abort, etc.)
2. Error messages and/or full log output:
When requesting a http page on the https port ( curl -vL http://localhost:443) :
caddy | {"level":"debug","ts":1775073325.9619644,"logger":"http.stdlib","msg":"http: TLS handshake error from [::1]:54930: client sent an HTTP request to an HTTPS server"}
When requesting a page without a host header (curl -vL -H "Host:" http://localhost) :
No logs were generated, even with debug in the global options
3. Caddy version:
v2.11.2 h1:iOlpsSiSKqEW+SIXrcZsZ/NO74SzB/ycqqvAIEfIm64=
4. How I installed and ran Caddy:
Caddy was installed and ran with docker compose, relevant docker-compose.yml and Caddyfile are below in their relevant sections
a. System environment:
Docker version 29.3.1, build c2be9ccfc3 running on EndeavorOS (Arch linux fork)
b. Command:
Not relevant, docker compose is used.
c. Service/unit/compose file:
docker-compose.yml:
services:
caddy:
image: caddy:2.11.2-alpine
container_name: caddy
restart: unless-stopped
network_mode: host
volumes:
- ./conf:/etc/caddy
- ./data:/data
- ./caddy_config:/config
d. My complete Caddy config:
Caddyfile:
{
auto_https disable_redirects
skip_install_trust
debug
}
http:// {
handle_errors 400 {
respond "Nope" 418
}
redir https://{host}{uri} 301
}
localhost {
handle_errors 400 {
respond "Nope" 418
}
respond "Yay!"
}
5. Links to relevant resources:
To not waste the human maintainers’ time, i ran this issue through an LLM which had access to caddy’s source code, to actually investigate the issue rather then just hallucinating a response, and it came up with the following answer (actually very useful but slightly long):
LLM response
No, handle_errors cannot intercept that 400 error.
Here’s why, broken down by where each thing lives in the code:
Where handle_errors hooks in
handle_errors (backed by the errorHandlerChain) is only triggered when Caddy’s own primary HTTP handler chain returns an error. In Server.ServeHTTP, the flow is:
-
Call
s.serveHTTP(w, r), which callss.primaryHandlerChain.ServeHTTP(w, r) -
Only if that returns a non-
nilerror does Caddy enrich the request with error context and invokes.errorHandlerChain.ServeHTTP(w, r)(yourhandle_errorsroutes).server.go:379-412
Where the “Client sent an HTTP request to an HTTPS server” error comes from
This error is not generated by Caddy’s handler chain at all. It is generated deep inside Go’s standard library (crypto/tls + net/http) when the TLS handshake fails because the incoming bytes look like plain HTTP, not a TLS record. This happens at the raw TCP connection layer, before Server.ServeHTTP is ever called. There is no trace of this message anywhere in Caddy’s own source code.
Because the response is written directly by the Go runtime’s TLS/HTTP server internals — never passing through s.serveHTTP() — the error never bubbles up into the handler chain, and handle_errors has no opportunity to intercept it.
The actual solution: HTTPRedirectListenerWrapper
Caddy provides HTTPRedirectListenerWrapper (module ID: caddy.listeners.http_redirect) specifically to address this problem. It operates at the listener level — before TLS even attempts its handshake — by peeking at the first 5 bytes of the connection to detect plain HTTP traffic. httpredirectlistener.go:33-50
If it detects HTTP bytes (GET /, HEAD , POST , PUT /, OPTIO), it reads the full request and directly writes a 308 Permanent Redirect to https:// on the raw connection: httpredirectlistener.go:106-158
Critically, this also bypasses the HTTP handler chain entirely — it writes the redirect response straight to the net.Conn. So handle_errors cannot intercept this redirect either. It’s a raw TCP-level mechanism, not an HTTP middleware mechanism.
To enable it, place it before the tls listener wrapper in your server config:
{
servers :443 {
listener_wrappers {
http_redirect
tls
}
}
}
Now, if the machine-generated answer is to be believed, and the error is generated inside Go’s standard library, which does not call handle_errors, the only option to modify Caddy’s behavior is through the http_redirect listen wrapper, which however only does a 308 redirect and offers nowhere near the level of customization that handle_errors provides, which i would like much more.
Is this edge case an intended feature/oversight or simply a misconfiguration on my end?
thanks in advance,
-A happy caddy user