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.