1. The problem I’m having:
I am trying to use Caddy as an intermediate certification authority.
I have a root key on an airgapped system and created an intermediate certificate for Caddy to use. I want Caddy to use this intermediate certificate as its “root”. It should create it’s own weekly intermediate certificate out of it and sign my website’s certs with that. Essentially do the same as using internal TLS, just using my own provided cert as root.
Now this all actually already works. I put the intermediate certificate I created on my root as Caddy’s root. It then just created its own intermediate from that and signs my things with it.
The problem is that, as the actual root which clients have installed is one layer lower. There is one certificate missing in the chain. Caddy doesn’t serve the root certificate it uses.
I’m wondering if there is some way to achieve, what I’m trying to do. To essentially serve the root cert as well. If there’s some other way to do it, I’d love to know about it as well of course.
2. Error messages and/or full log output:
I am trying to access my website locally, the DNS points to the local Caddy server.
curl -vL crispy-caesus.eu:
* Host crispy-caesus.eu:80 was resolved.
* IPv6: (none)
* IPv4: 192.168.42.1
* Trying 192.168.42.1:80...
* Established connection to crispy-caesus.eu (192.168.42.1 port 80) from 192.168.42.69 port 55472
* using HTTP/1.x
> GET / HTTP/1.1
> Host: crispy-caesus.eu
> User-Agent: curl/8.18.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 308 Permanent Redirect
< Connection: close
< Location: https://crispy-caesus.eu/
< Server: Caddy
< Date: Thu, 22 Jan 2026 19:04:15 GMT
< Content-Length: 0
<
* shutting down connection #0
* Clear auth, redirects to port from 80 to 443
* Issue another request to this URL: 'https://crispy-caesus.eu/'
* Host crispy-caesus.eu:443 was resolved.
* IPv6: (none)
* IPv4: 192.168.42.1
* Trying 192.168.42.1:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* SSL Trust Anchors:
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* 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_AES_128_GCM_SHA256 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
* subject:
* start date: Jan 22 16:58:34 2026 GMT
* expire date: Jan 23 04:58:34 2026 GMT
* issuer: CN=Caddy Local Authority - ECC Intermediate
* Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ED25519
* subjectAltName: "crispy-caesus.eu" matches cert's "crispy-caesus.eu"
* SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
* closing connection #1
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
openssl s_client -connect crispy-caesus.eu:443:
Connecting to 192.168.42.1
CONNECTED(00000003)
depth=1 CN=Caddy Local Authority - ECC Intermediate
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0
verify return:1
---
Certificate chain
0 s:
i:CN=Caddy Local Authority - ECC Intermediate
a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
v:NotBefore: Jan 22 16:58:34 2026 GMT; NotAfter: Jan 23 04:58:34 2026 GMT
1 s:CN=Caddy Local Authority - ECC Intermediate
i:CN=heinrich.crispy-caesus.eu
a:PKEY: EC, (prime256v1); sigalg: ED25519
v:NotBefore: Jan 21 23:20:11 2026 GMT; NotAfter: Jan 28 23:20:11 2026 GMT
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIBxjCCAWugAwIBAgIRAOolbQ4Veu/Fg+mncYLpZBcwCgYIKoZIzj0EAwIwMzEx
MC8GA1UEAxMoQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0
ZTAeFw0yNjAxMjIxNjU4MzRaFw0yNjAxMjMwNDU4MzRaMAAwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAARBQvULkoO3qT9mlFHhqJa1GIQjvPb+6k5r9yhUqVdJvbHz
pwvdIYQ99epVJfkGYdy+hWn8qRXu706QIdWoaxyQo4GSMIGPMA4GA1UdDwEB/wQE
AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFDVa
e1XdJOwMVJUOzW20BdspUzO0MB8GA1UdIwQYMBaAFGqhNZtfIrN+RxHjH0DysXGp
4BDPMB4GA1UdEQEB/wQUMBKCEGNyaXNweS1jYWVzdXMuZXUwCgYIKoZIzj0EAwID
SQAwRgIhAK+5mzUWM0NvJ9Ut5jGay7KWTV3Bxh9ArXJrRApHfJPAAiEA9zepCzMs
jNTjTNBFM4bfUt3rEMJ3UrPHKCrWMOAi+CA=
-----END CERTIFICATE-----
subject=
issuer=CN=Caddy Local Authority - ECC Intermediate
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: ecdsa_secp256r1_sha256
Negotiated TLS1.3 group: X25519MLKEM768
---
SSL handshake has read 2335 bytes and written 1613 bytes
Verification error: unable to get local issuer certificate
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Protocol: TLSv1.3
Server public key is 256 bit
This TLS version forbids renegotiation.
No ALPN negotiated
Early data was not sent
Verify return code: 20 (unable to get local issuer certificate)
---
---
Post-Handshake New Session Ticket arrived:
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_128_GCM_SHA256
Session-ID: 010F84C7593DBD6A02699328C8F5A8F66F3B6CAC25872BE15DD3351D4D5DB36B
Session-ID-ctx:
Resumption PSK: 4058F18FB204322405538B68787F09AA183FC8EA7F5CC9AE49D1C14BBAEFEC32
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 604800 (seconds)
TLS session ticket:
0000 - 40 95 d8 0a 3f ad 6a 49-0a a3 22 5c dc e5 cc ba @...?.jI.."\....
0010 - db bc a8 0d f3 0e 66 57-8d 81 dd 1e 83 c4 97 3e ......fW.......>
0020 - 20 7e 46 77 41 24 79 0d-2c 55 bc ce d7 47 26 0a ~FwA$y.,U...G&.
0030 - 9d 24 a1 f0 29 a4 20 09-86 ce 5c a0 52 42 ab 44 .$..). ...\.RB.D
0040 - fd 07 0f 1f 2c f5 c9 4a-d4 1c 2e 8c ee bd 05 52 ....,..J.......R
0050 - a5 ee e2 a1 47 f6 7c 43-53 86 2d 50 9d 7d 93 2d ....G.|CS.-P.}.-
0060 - 20 64 d2 8c 9e 8d cf 11-67 d......g
Start Time: 1769109210
Timeout : 7200 (sec)
Verify return code: 20 (unable to get local issuer certificate)
Extended master secret: no
Max Early Data: 0
---
3. Caddy version:
v2.10.0
4. How I installed and ran Caddy:
a. System environment:
OS: Alpine Linux v3.22
Arch: aarch64
Device: Raspberry Pi 5 Model B Rev 1.1
Repositories:
http://ftp.halifax.rwth-aachen.de/alpine/v3.22/main
http://ftp.halifax.rwth-aachen.de/alpine/v3.22/community
b. Command:
doas service caddy restart
c. Service/unit/compose file:
openrc-script (official from the package)
#!/sbin/openrc-run
supervisor=supervise-daemon
name="Caddy web server"
description="Fast, multi-platform web server with automatic HTTPS"
description_checkconfig="Check configuration"
description_reload="Reload configuration without downtime"
: ${caddy_opts:="--config /etc/caddy/Caddyfile --adapter caddyfile"}
command=/usr/sbin/caddy
command_args="run $caddy_opts"
command_user=caddy:caddy
extra_commands="checkconfig"
extra_started_commands="reload"
capabilities="^cap_net_bind_service"
depend() {
need net localmount
after firewall
}
checkconfig() {
ebegin "Checking configuration for $name"
su ${command_user%:*} -s /bin/sh -c "$command validate $caddy_opts"
eend $?
}
reload() {
ebegin "Reloading $name"
su ${command_user%:*} -s /bin/sh -c "$command reload --force $caddy_opts"
eend $?
}
stop_pre() {
if [ "$RC_CMD" = restart ]; then
checkconfig || return $?
fi
}
(the conf.d/caddy doesn’t exist)
d. My complete Caddy config:
{
pki {
ca local {
intermediate_cn "Caddy Intermediate heinrich.crispy-caesus.eu"
root {
cert /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
key /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.key
}
}
}
}
grafana.crispy-caesus.eu {
reverse_proxy otto:3000
tls internal
log
}
navidrome.crispy-caesus.eu {
reverse_proxy horst:4533
tls internal
log
}
lastcloud.crispy-caesus.eu {
reverse_proxy sabine:80
tls internal
log
}
synapse.crispy-caesus.eu {
reverse_proxy /_matrix/* eren:8008
reverse_proxy /_synapse/client/* eren:8008
tls internal
log
}
immich.crispy-caesus.eu, photos.crispy-caesus.eu {
reverse_proxy agathe:2283
tls internal
log
}
proxmox.crispy-caesus.eu {
reverse_proxy pve:8006
tls internal
log
}
prometheus.crispy-caesus.eu {
reverse_proxy otto:9090
tls internal
log
}
crispy-caesus.eu {
reverse_proxy frank:8080
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `{"m.server": "synapse.crispy-caesus.eu:443"}`
respond /.well-known/matrix/client `{"m.homeserver":{"base_url":"https://synapse.crispy-caesus.eu"}}`
tls internal
log
}
Thanks for taking the time to read through this