Why Caddy emits empty 200 OK responses by default

We regularly get inquiries or complaints about Caddy emitting a 200 response for a request that was successful, but not configured to be handled. An empty 200 OK is Caddy’s default response when the server is working and the request was successful, but there was no route configured to handle it.

No-op requests

For example, in the simplest case, this Caddyfile:


which adapts to this JSON:

  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [

will emit an empty response with 200 OK as its status code to every request. That is the no-op behavior.

Nothing’s wrong, right?

Sometimes people are surprised to get a 200 OK even though they haven’t configured any routes. Why would the server say that things are OK when no routes were found? Most complaints ask that 404 Not Found be returned instead.

Or if users do have routes configured but they still get an empty 200 OK response, they find that confusing. It’s usually because none of their handlers got invoked for a certain request they expected to be handled by one of their routes. Or sometimes a request is matched to a route and even handled, but none of the handlers terminate the chain (i.e. none of the handlers originate content, or actually write a response), thus forcing Caddy’s HTTP server to write the default response… of 200 OK, instead of forcibly closing the connection.

Why Caddy does this

This behavior is intentional, and through all the discussions so far, the current behavior still remains the most correct. There are several reasons for this.

One is grounded plainly in HTTP spec. 200 OK literally means “the request has succeeded.” That is indeed the case here even if the server’s configuration didn’t have anything specific for that request. The server successfully received, decoded, parsed, and evaluated the request. It just wasn’t configured to do anything.

According to RFC 9110, the response content for a 200 OK is a representation depending on the method. For example, for GET/HEAD requests, it’s the “target resource”, which is explained in a separate section, but basically:

Upon receipt of a client’s request, a server reconstructs the target URI from the received components in accordance with their local configuration and incoming connection context.

Which is also the case here. The “target” is reconstructed by the HTTP server which parsed the request, but the local configuration doesn’t originate any content with it. In other words, no application-layer code produced any target resource. Thus an empty body is appropriate.

Emitting another class of status code is inappropriate because it isn’t an interim informational response (1xx), a redirect (3xx), a client error (4xx; necessarily – we can’t always distinguish between client error and faulty/incomplete server config), or a server error (5xx; the server operated properly according to its configuration). 501 is not appopriate because that status code is about the method, but we do not know what about the request made it a no-op. (We often get requests to change the default to 404, but that means “Not Found” – but we don’t know that anything wasn’t found, because we weren’t looking for anything!)

This “no-op” problem is similar to that of NULL values: is NULL == NULL? NULL is a lack of data. Some SQL dialects treat NULLs as distinct (note that you don’t do col = NULL when selecting; you have to do col IS NULL because you’re not comparing equality). In Go, you can compare against null, i.e. v == nil which checks for a pointer to 0. But sometimes you want NULL to equal NULL, because the lack of information is the information you need. Similarly, with web server config, we are lacking information. The lack of a config to handle any and all given requests is not useful, because we can’t assume a user writes a config that they intend to cover the space of all possible requests. I don’t write configs that do. You probably don’t either – even if you think you do, have you tested literally all possible requests?

Some people think Caddy should not respond to requests that it isn’t configured to handle. But not responding to a request just leaves the client hanging, keeping the connection open, waiting for a response. It leaks resources on both the client and the server, so this solution is unacceptable. If we forcefully close the connection, we break other streams on the same connection that may be succeeding, and we also send a bad signal to clients which may behave undesirably (i.e. force-closing is ungraceful, breaking, abrasive behavior). We can’t do this either.

Talking about “handling” a request is also confusing and ambiguous. Caddy’s HTTP server “handles” requests even without any user-configured handler. Does handling simply mean to decode a request and write a response? Does it mean to try matching the request to a route? Are routes the application itself or a means to select the application? Does it mean to match a request and then manipulate the request but not write a response? So when we talk about “handling” I’m not even sure what we mean, ha.

Requests to change the current behavior haven’t been compelling so far because there’s actually a signal in the logs that indicates a “default” response was written, so you can detect that it may not have been what you intend, and the “default” response can be customized very easily with one line of config :+1:!

How to detect no-op requests

In your access logs (the log directive), no-op requests are logged with a status of 0.

I think that’s logical for a no-op, but I can see how a status of 0 is confusing if the client receives a 200. However, the access logs do not say what the client receives: only the client can tell you that. We can log how big the response body is but we can’t log how many bytes the client actually receives with certainty, only the client knows. Thus, the access logs are a reflection of what the server saw come in on a request and how the server was configured to handle a request, and nothing about what the client is experiencing.

How to change no-op behavior

Default status codes can also be configured very easily, essentially by adding a “fallback” handler that matches every request (i.e. doesn’t have a matcher). In a Caddyfile:

respond X

will do the trick. (Replace X with your status code of choice.) This requires that you choose a suitable status code depending on the semantics of your application and what you intend your server to handle.


    "handler": "static_response",
    "status": X,

(again, replace X with your status code). You can even write a body and headers if you want to!

The point is, a “fallback” handler is simply one that originates a response that doesn’t have a matcher. That will match all requests on that server/site.


Re how to change no-op behavior, Caddy gives static response handlers priority over any reverse_proxy directive, so the suggested workaround doesn’t actually behave like a fallback handler, clobbering defined proxies. In theory, the route directive could be used to define an explicit matching order but that isn’t working for me and regardless, it adds configuration complexity. How about explicitly adding a noop/catchall/fallback directive? Eg: fallback { respond 404 }

Open a new topic and we’ll figure out why it isn’t working.

You can also use handle to define routing. See:

Ah, thanks, I missed that article while looking through the docs and wiki. With proper ordering, a new fallback directive wouldn’t help much, offering at most a bit of discoverability.

Sidenote: consider renaming that article to “Ordering routes and handlers in the Caddyfile”. I likely overlooked it thinking “Composing in the Caddyfile” was about using the import directive and/or named matchers, along the lines of object composition.

1 Like