Conditionally enable TLS based on dynamic srv result

1. The problem I’m having:

I’m trying to set up my Caddy config so that I can manage everything centrally from my DNS server, and have Caddy determine the upstream using SRV records. However, some of my backends use plain HTTP, while others use HTTPS (with a self-signed cert). To differentiate, I set the SRV protocol as either _tcp for HTTP, or _tls for HTTPS.

However, I can’t figure out how to set tls conditionally based on the result of the SRV lookup. I’ve tried first having it attempt to reverse_proxy using _tls, and then using handle_error to catch the error when it can’t find any upstreams using that protocol, which works, except I also want to display a custom error page when neither the HTTP nor HTTPS backend can be found, and I can’t figure out how to handle errors that happen inside a handle_error. One thing to note is that if I’m using multiple handle_errors with no status_codes, then the last one seems to win.

When attempting to access one of the HTTP endpoints, I get:

~ $ curl -vL https://wake.lab.kolber.co/
* Host wake.lab.kolber.co:443 was resolved.
* IPv6: (none)
* IPv4: 10.0.2.2
*   Trying 10.0.2.2:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_CHACHA20_POLY1305_SHA256 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=*.lab.kolber.co
*  start date: Oct 13 14:37:43 2025 GMT
*  expire date: Jan 11 14:37:42 2026 GMT
*  subjectAltName: host "wake.lab.kolber.co" matched cert's "*.lab.kolber.co"
*  issuer: C=US; O=Let's Encrypt; CN=E8
*  SSL certificate verify ok.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 2: Public key type RSA (4096/152 Bits/secBits), signed using sha256WithRSAEncryption
* Established connection to wake.lab.kolber.co (10.0.2.2 port 443) from 172.19.64.137 port 52992
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://wake.lab.kolber.co/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: wake.lab.kolber.co]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.16.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: wake.lab.kolber.co
> User-Agent: curl/8.16.0
> Accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Request completely sent off
< HTTP/2 400
< alt-svc: h3=":443"; ma=2592000
< content-type: text/html
< server: Caddy
< content-length: 805
< date: Wed, 19 Nov 2025 18:06:14 GMT
<
<html>
<head>
                <style>
                                body {
                                                background-color: aliceblue;
                                                font-family: "Consolas", monospace
                                }
                                div {
                                                display: flex;
                                                flex-direction: column;
                                                align-items: center;
                                                justify-content: center;
                                }
                                div > * {
                                        text-align: center;
                                }
                </style>
</head>
<body>
                <div style="height: 100%;">
                                <div style="height: 40vh; width: 80vw; background-color: white; box-shadow: 0px 0px 5px 0px lightgrey;">
                                                <h1>Unable to resolve IP for wake.lab.kolber.co</h1>
                                                <p>Please ensure the SRV record for the service you are trying to reach has been properly configured.</p>
                                                <pre>503 Service Unavailable: no upstreams available</pre>
                                                <pre>reverseproxy.(*Handler).proxyLoopIteration (reverseproxy.go:524)</pre>
                                </div>
                </div>
</body>
* Connection #0 to host wake.lab.kolber.co:443 left intact

2. Error messages and/or full log output:

When I try to have the second handle within the handle_errors, or when I try to use two handle_errors, I get the following error:

Nov 19 13:17:28 union caddy[5676]: {"level":"debug","ts":1763576248.4881272,"logger":"http.reverse_proxy.upstreams.srv","msg":"refreshing SRV upstreams","service":"wake","proto":"tls","name":"lab.kolber.co"}
Nov 19 13:17:28 union caddy[5676]: {"level":"error","ts":1763576248.4896758,"logger":"http.handlers.reverse_proxy","msg":"failed getting dynamic upstreams; falling back to static upstreams","error":"lookup _wake._tls.lab.kolber.co on 10.0.2.1:53: no such host"}
Nov 19 13:17:28 union caddy[5676]: {"level":"debug","ts":1763576248.4899945,"logger":"http.log.error","msg":"no upstreams available","request":{"remote_ip":"10.0.2.110","remote_port":"53630","client_ip":"10.0.2.110","proto":"HTTP/2.0","method":"GET","host":"wake.lab.kolber.co","uri":"/","headers":{"User-Agent":["curl/8.16.0"],"Accept":["*/*"]},"tls":{"resumed":false,"version":772,"cipher_suite":4867,"proto":"h2","server_name":"wake.lab.kolber.co"}},"duration":0.001855468,"status":503,"err_id":"t2gjgekuz","err_trace":"reverseproxy.(*Handler).proxyLoopIteration (reverseproxy.go:524)"}

3. Caddy version:

v2.10.2

4. How I installed and ran Caddy:

Installed using apt install caddy, then replaced the binary with one downloaded from Download Caddy that contains the Cloudflare module.

a. System environment:

Raspberry Pi 4B

~ $ lsb_release -a
Distributor ID: Debian
Description:    Debian GNU/Linux 12 (bookworm)
Release:        12
Codename:       bookworm
~ $ systemctl --version
systemd 252 (252.39-1~deb12u1)
+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT -GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY +P11KIT +QRENCODE +TPM2 +BZIP2 +LZ4 +XZ +ZLIB +ZSTD -BPF_FRAMEWORK -XKBCOMMON +UTMP +SYSVINIT default-hierarchy=unified

b. Command:

sudo systemctl start reload caddy, it’s enabled to start on boot automatically.

c. Service/unit/compose file:

# /lib/systemd/system/caddy.service
# caddy.service
#
# For using Caddy with a config file.
#
# Make sure the ExecStart and ExecReload commands are correct
# for your installation.
#
# See https://caddyserver.com/docs/install for instructions.
#
# WARNING: This service does not use the --resume flag, so if you
# use the API to make changes, they will be overwritten by the
# Caddyfile next time the service is restarted. If you intend to
# use Caddy's API to configure it, add the --resume flag to the
# `caddy run` command or use the caddy-api.service file instead.

[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s

d. My complete Caddy config:

import Caddyfile.dns

{
	debug
	# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

http://* {
	redir https://{host}.lab.kolber.co
}

*.lab.kolber.co {
	tls {
		import dns_cloudflare
		resolvers 1.1.1.1 1.0.0.1
	}

	# Extract the subdomain for SRV record lookup
	map {host} {subdomain} {
		~(.*)\.lab\.kolber\.co$ ${1}
	}

	handle {
		reverse_proxy {
			# Use the proto "_tls" to indicate that Caddy should use HTTPS
			dynamic srv {
				service {subdomain}
				proto tls
				name lab.kolber.co
				refresh 30s
			}

			transport http {
				tls
				# Allow self-signed certificates
				tls_insecure_skip_verify
			}
		}
	}

	# Define a named matcher that matches when no SRV record exists for HTTPS
	@srv_tls_error expression {http.err.message} == "no upstreams available"

	# Caddy returns an error when it can't find SRV records. We take
	# advantage of that to ensure that the error page is only shown if
	# both HTTPS and HTTP lookups fail.
	handle_errors {
		# If it was a SRV lookup error, try to look up an HTTP service
		# instead to see if it exists.
		handle @srv_tls_error {
			reverse_proxy {
				dynamic srv {
					service {subdomain}
					proto tcp
					name lab.kolber.co
					refresh 30s
				}
			}
		}

		# If we're here, it means both lookups have failed, and we should
		# present a custom error page.
		handle {
			header Content-Type text/html
			respond <<HTML
				<html>
				<head>
						<style>
								body {
										background-color: aliceblue;
										font-family: "Consolas", monospace
								}
								div {
										display: flex;
										flex-direction: column;
										align-items: center;
										justify-content: center;
								}
								div > * {
									text-align: center;
								}
						</style>
				</head>
				<body>
						<div style="height: 100%;">
								<div style="height: 40vh; width: 80vw; background-color: white; box-shadow: 0px 0px 5px 0px lightgrey;">
										<h1>Unable to resolve IP for {host}</h1>
										<p>Please ensure the SRV record for the service you are trying to reach has been properly configured.</p>
										<pre>{err.status_code} {err.status_text}: {err.message}</pre>
										<pre>{err.trace}</pre>
								</div>
						</div>
				</body>
				</html>
				HTML 400
		}
	}
}

I’ve also tried this instead:

import Caddyfile.dns

{
	debug
	# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

http://* {
	redir https://{host}.lab.kolber.co
}

*.lab.kolber.co {
	tls {
		import dns_cloudflare
		resolvers 1.1.1.1 1.0.0.1
	}

	# Extract the subdomain for SRV record lookup
	map {host} {subdomain} {
		~(.*)\.lab\.kolber\.co$ ${1}
	}

	handle {
		reverse_proxy {
			# Use the proto "_tls" to indicate that Caddy should use HTTPS
			dynamic srv {
				service {subdomain}
				proto tls
				name lab.kolber.co
				refresh 30s
			}

			transport http {
				tls
				# Allow self-signed certificates
				tls_insecure_skip_verify
			}
		}
	}

	# Caddy returns an error when it can't find SRV records. We take
	# advantage of that to ensure that the error page is only shown if
	# both HTTPS and HTTP lookups fail.
	handle_errors {
		# If it was a SRV lookup error, try to look up an HTTP service
		# instead to see if it exists.
		handle {
			reverse_proxy {
				dynamic srv {
					service {subdomain}
					proto tcp
					name lab.kolber.co
					refresh 30s
				}
			}
		}
	}

		# If we're here, it means both lookups have failed, and we should
		# present a custom error page.
	handle_errors {
		header Content-Type text/html
		respond <<HTML
			<html>
			<head>
					<style>
							body {
									background-color: aliceblue;
									font-family: "Consolas", monospace
							}
							div {
									display: flex;
									flex-direction: column;
									align-items: center;
									justify-content: center;
							}
							div > * {
								text-align: center;
							}
					</style>
			</head>
			<body>
					<div style="height: 100%;">
							<div style="height: 40vh; width: 80vw; background-color: white; box-shadow: 0px 0px 5px 0px lightgrey;">
									<h1>Unable to resolve IP for {host}</h1>
									<p>Please ensure the SRV record for the service you are trying to reach has been properly configured.</p>
									<pre>{err.status_code} {err.status_text}: {err.message}</pre>
									<pre>{err.trace}</pre>
							</div>
					</div>
			</body>
			</html>
			HTML 400
	}
}

5. Links to relevant resources:

I’ve tried handle (Caddyfile directive) — Caddy Documentation and route (Caddyfile directive) — Caddy Documentation, with no dice.

Hi, anyone know if this is possible?

I don’t think it’s possible at this time. reverse_proxy uses a single transport config, and using tls requires a separate transport config from non-tls.

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.