HTTP wildcard certificates override non-wildcard routes

1. The problem I’m having:

I wanted to setup a wildcard route to respond 501 for unregistered domains. I didn’t want to have automatic certificate generation here so I enabled it for HTTP only and I found caddy was preferring that wildcard route for the non-wildcard domains when I tried to access over http (instead of redirecting them to https). Seems like you can override this if you add explicit http and https markers to each of the non-wildcard domains but is this behaviour intentional? I think it might make more sense to prioritise wildcards below non-wildcard routes when both are defined (regardless of TLS support)

2. Error messages and/or full log output:

With the config added below:

$ curl -D - http://a.localhost
HTTP/1.1 501 Not Implemented
Server: Caddy
Date: Sat, 28 Dec 2024 22:31:14 GMT
Content-Length: 0

$ curl -D - https://a.localhost
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
server: Caddy
content-length: 0
date: Sat, 28 Dec 2024 22:31:17 GMT

$ curl -D - http://b.localhost
HTTP/1.1 200 OK
Server: Caddy
Date: Sat, 28 Dec 2024 22:31:21 GMT
Content-Length: 0

$ curl -D - http://undefined.localhost
HTTP/1.1 501 Not Implemented
Server: Caddy
Date: Sat, 28 Dec 2024 22:31:24 GMT
Content-Length: 0
{"level":"info","ts":1735424328.636559,"logger":"admin.api","msg":"load complete"}
{"level":"info","ts":1735424328.6676583,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"}
{"level":"info","ts":1735424451.1167917,"logger":"admin.api","msg":"received request","method":"POST","host":"localhost:2019","uri":"/load","remote_ip":"127.0.0.1","remote_port":"37726","headers":{"Accept-Encoding":["gzip"],"Content-Length":["742"],"Content-Type":["application/json"],"Origin":["http://localhost:2019"],"User-Agent":["Go-http-client/1.1"]}}
{"level":"info","ts":1735424451.1172092,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforce_origin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]}
{"level":"info","ts":1735424451.1175604,"logger":"http.auto_https","msg":"server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS","server_name":"srv0","https_port":443}
{"level":"info","ts":1735424451.117572,"logger":"http.auto_https","msg":"enabling automatic HTTP->HTTPS redirects","server_name":"srv0"}
{"level":"warn","ts":1735424451.1175828,"logger":"http.auto_https","msg":"server is listening only on the HTTP port, so no automatic HTTPS will be applied to this server","server_name":"srv1","http_port":80}
{"level":"info","ts":1735424451.1196654,"logger":"http","msg":"enabling HTTP/3 listener","addr":":443"}
{"level":"info","ts":1735424451.1196833,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"info","ts":1735424451.1197073,"logger":"http.log","msg":"server running","name":"srv1","protocols":["h1","h2","h3"]}
{"level":"info","ts":1735424451.11971,"logger":"http","msg":"enabling automatic TLS certificate management","domains":["b.localhost","a.localhost"]}
{"level":"info","ts":1735424451.120065,"logger":"pki.ca.local","msg":"root certificate is already trusted by system","path":"storage:pki/authorities/local/root.crt"}
{"level":"info","ts":1735424451.1200814,"logger":"http","msg":"servers shutting down with eternal grace period"}
{"level":"info","ts":1735424451.1207426,"msg":"autosaved config (load with --resume flag)","file":"/config/caddy/autosave.json"}
{"level":"info","ts":1735424451.1207514,"logger":"admin.api","msg":"load complete"}
{"level":"info","ts":1735424451.1209931,"logger":"tls.obtain","msg":"acquiring lock","identifier":"b.localhost"}
{"level":"info","ts":1735424451.1210327,"logger":"tls.obtain","msg":"acquiring lock","identifier":"a.localhost"}
{"level":"info","ts":1735424451.1257498,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"}
{"level":"info","ts":1735424451.1332452,"logger":"tls.obtain","msg":"lock acquired","identifier":"a.localhost"}
{"level":"info","ts":1735424451.133246,"logger":"tls.obtain","msg":"lock acquired","identifier":"b.localhost"}
{"level":"info","ts":1735424451.133311,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"a.localhost"}
{"level":"info","ts":1735424451.1333406,"logger":"tls.obtain","msg":"obtaining certificate","identifier":"b.localhost"}
{"level":"info","ts":1735424451.1344528,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"b.localhost","issuer":"local"}
{"level":"info","ts":1735424451.1345117,"logger":"tls.obtain","msg":"releasing lock","identifier":"b.localhost"}
{"level":"info","ts":1735424451.1348107,"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"a.localhost","issuer":"local"}
{"level":"info","ts":1735424451.1349168,"logger":"tls.obtain","msg":"releasing lock","identifier":"a.localhost"}
{"level":"warn","ts":1735424451.1357522,"logger":"tls","msg":"stapling OCSP","error":"no OCSP stapling for [a.localhost]: no OCSP server specified in certificate","identifiers":["a.localhost"]}
{"level":"warn","ts":1735424451.1357672,"logger":"tls","msg":"stapling OCSP","error":"no OCSP stapling for [b.localhost]: no OCSP server specified in certificate","identifiers":["b.localhost"]}

3. Caddy version:

v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=

4. How I installed and ran Caddy:

Docker

a. System environment:

Linux HOSTNAME 6.12.6-arch1-1 #1 SMP PREEMPT_DYNAMIC Thu, 19 Dec 2024 21:29:01 +0000 x86_64 GNU/Linux

b. Command:

docker-compose up

c. Service/unit/compose file:

services:
  caddy:
    container_name: ms-caddy
    image: caddy:latest
    logging:
      driver: journald
    networks:
      default: null
    ports:
      - mode: ingress
        target: 80
        published: "80"
        protocol: tcp
      - mode: ingress
        target: 443
        published: "443"
        protocol: tcp
      - mode: ingress
        target: 443
        published: "443"
        protocol: udp
    volumes:
      - type: bind
        source: /home/mohkale/.config/dotfiles/prog/media-server/proxy
        target: /etc/caddy
        bind:
          create_host_path: true
      - type: bind
        source: /home/mohkale/.config/media-server/caddy/data
        target: /data
        bind:
          create_host_path: true
      - type: bind
        source: /home/mohkale/.config/media-server/caddy/config
        target: /config
        bind:
          create_host_path: true
networks:
  default:
    name: media-server_default

d. My complete Caddy config:

a.localhost {
	respond 200
}

http://*.localhost {
	respond 501
}

http://b.localhost,
https://b.localhost {
	respond 200
}

5. Links to relevant resources:

It’s obvious if you adapt it to the native JSON config to inspect what Caddy actually runs:

{
	"apps": {
		"http": {
			"servers": {
				"srv0": {
					"listen": [
						":443"
					],
					"routes": [
						{
							"match": [
								{
									"host": [
										"a.localhost"
									]
								}
							],
							"handle": [
								{
									"handler": "subroute",
									"routes": [
										{
											"handle": [
												{
													"handler": "static_response",
													"status_code": 200
												}
											]
										}
									]
								}
							],
							"terminal": true
						},
						{
							"match": [
								{
									"host": [
										"b.localhost"
									]
								}
							],
							"handle": [
								{
									"handler": "subroute",
									"routes": [
										{
											"handle": [
												{
													"handler": "static_response",
													"status_code": 200
												}
											]
										}
									]
								}
							],
							"terminal": true
						}
					]
				},
				"srv1": {
					"listen": [
						":80"
					],
					"routes": [
						{
							"match": [
								{
									"host": [
										"b.localhost"
									]
								}
							],
							"handle": [
								{
									"handler": "subroute",
									"routes": [
										{
											"handle": [
												{
													"handler": "static_response",
													"status_code": 200
												}
											]
										}
									]
								}
							],
							"terminal": true
						},
						{
							"match": [
								{
									"host": [
										"*.localhost"
									]
								}
							],
							"handle": [
								{
									"handler": "subroute",
									"routes": [
										{
											"handle": [
												{
													"handler": "static_response",
													"status_code": 501
												}
											]
										}
									]
								}
							],
							"terminal": true
						}
					]
				}
			}
		}
	}
}

You’ll see Caddy produces 2 servers: one listening on port 443, and another listens on port 80. Because port 80 is explicitly configured, the automatic redirect is disabled. Requests coming on port 80 are routed to handlers defined in the server managing port 80 (i.e. srv1, and the same goes for port 443 (i.e. srv0). You see srv1 has 2 matchers, one for b.localhost and the other for *.localhost. The host a.localhost is managed by srv0, which manages port 443. That’s why HTTPS request to a.localhost are handled as you assumed, but requests on port 80 violated your assumptions.

In cases like these, you’ll have to add an http and https definitions for a.localhost, and maybe configure a redirect to HTTPS if it’s received with protocol http.

That is what I’ve ended up doing. I guess my question was more whether the current behaviour from caddy is the most intuitive way for this. If I want to support wildcards for http only I’m basically forced to define http and https routes for all non-wildcard domains. Which is a bit klunky :disappointed:.